scryer 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/README.md +367 -0
- data/exe/scryer +7 -0
- data/lib/generators/scryer/USAGE +66 -0
- data/lib/generators/scryer/install_generator.rb +29 -0
- data/lib/generators/scryer/templates/scryer_initializer.rb +28 -0
- data/lib/scryer/ai_client.rb +53 -0
- data/lib/scryer/ai_fix_suggester.rb +138 -0
- data/lib/scryer/ast.rb +277 -0
- data/lib/scryer/cache_extractor.rb +124 -0
- data/lib/scryer/cli.rb +193 -0
- data/lib/scryer/dependency_audit.rb +225 -0
- data/lib/scryer/duplicate_detector.rb +103 -0
- data/lib/scryer/finding.rb +21 -0
- data/lib/scryer/method_extractor.rb +55 -0
- data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +108 -0
- data/lib/scryer/performance_rules/missing_pagination_rule.rb +132 -0
- data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +221 -0
- data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +78 -0
- data/lib/scryer/query_extractor.rb +123 -0
- data/lib/scryer/query_watcher.rb +250 -0
- data/lib/scryer/railtie.rb +12 -0
- data/lib/scryer/report_renderer.rb +546 -0
- data/lib/scryer/rule.rb +43 -0
- data/lib/scryer/rule_set.rb +19 -0
- data/lib/scryer/rules/command_injection_rule.rb +61 -0
- data/lib/scryer/rules/csrf_protection_rule.rb +89 -0
- data/lib/scryer/rules/hardcoded_secret_rule.rb +96 -0
- data/lib/scryer/rules/mass_assignment_rule.rb +103 -0
- data/lib/scryer/rules/open_redirect_rule.rb +57 -0
- data/lib/scryer/rules/sql_injection_rule.rb +63 -0
- data/lib/scryer/rules/unsafe_deserialization_rule.rb +71 -0
- data/lib/scryer/rules/weak_crypto_rule.rb +66 -0
- data/lib/scryer/rules/xss_unsafe_html_rule.rb +70 -0
- data/lib/scryer/scanner.rb +129 -0
- data/lib/scryer/version.rb +3 -0
- data/lib/scryer.rb +65 -0
- data/lib/tasks/scryer.rake +172 -0
- metadata +106 -0
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "time"
|
|
3
|
+
|
|
4
|
+
module Scryer
|
|
5
|
+
# Turns a Scanner::Result into the exact JSON shape documented in
|
|
6
|
+
# CONTRACT2.md (also the ingest POST body shape) and a self-contained HTML
|
|
7
|
+
# report (inline CSS, no external assets except optionally linking out —
|
|
8
|
+
# here, none at all, so it works offline too). The HTML report is laid out
|
|
9
|
+
# similarly to a Brakeman report: an overview, a summary of counts, the
|
|
10
|
+
# full list of checks that ran, a breakdown of warnings by type, and then
|
|
11
|
+
# every finding in detail.
|
|
12
|
+
class ReportRenderer
|
|
13
|
+
SEVERITY_ORDER = %w[critical warning info].freeze
|
|
14
|
+
SEVERITY_LABELS = { "critical" => "Critical", "warning" => "Warning", "info" => "Info" }.freeze
|
|
15
|
+
CSV_HEADERS = %w[kind identifier severity location message suggested_fix code_snippet url].freeze
|
|
16
|
+
|
|
17
|
+
# `dependency_findings` is an optional array of Scryer::DependencyAudit::
|
|
18
|
+
# Finding (insecure_sources + vulnerable_gems) — pass it to fold a
|
|
19
|
+
# bundler-audit-like dependency audit into the same report as the static
|
|
20
|
+
# scan, instead of the audit living in separate `--audit-deps` output.
|
|
21
|
+
# Defaults to empty so existing callers that only run the static scan are
|
|
22
|
+
# unaffected.
|
|
23
|
+
def initialize(result:, project_name:, release_label: nil, git_commit_sha: nil, git_branch: nil,
|
|
24
|
+
dependency_findings: [], scanned_at: Time.now)
|
|
25
|
+
@result = result
|
|
26
|
+
@project_name = project_name
|
|
27
|
+
@release_label = release_label
|
|
28
|
+
@git_commit_sha = git_commit_sha
|
|
29
|
+
@git_branch = git_branch
|
|
30
|
+
@dependency_findings = dependency_findings
|
|
31
|
+
@scanned_at = scanned_at
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def as_hash
|
|
35
|
+
{
|
|
36
|
+
"project_name" => @project_name,
|
|
37
|
+
"scryer_version" => Scryer::VERSION,
|
|
38
|
+
"ruby_version" => RUBY_VERSION,
|
|
39
|
+
"scanned_at" => @scanned_at.utc.iso8601,
|
|
40
|
+
"release_label" => @release_label,
|
|
41
|
+
"git_commit_sha" => @git_commit_sha,
|
|
42
|
+
"git_branch" => @git_branch,
|
|
43
|
+
"files_scanned" => @result.files_scanned,
|
|
44
|
+
"parse_errors" => @result.parse_errors.map { |pe| { "file" => pe[:file], "error" => pe[:error] } },
|
|
45
|
+
"security_findings" => @result.security_findings.map(&:to_h),
|
|
46
|
+
"performance_findings" => @result.performance_findings.map(&:to_h),
|
|
47
|
+
"duplicate_groups" => @result.duplicate_groups.map { |g| duplicate_group_hash(g) },
|
|
48
|
+
"dependency_findings" => @dependency_findings.map(&:to_h)
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def as_json
|
|
53
|
+
JSON.pretty_generate(as_hash)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Flat, one-row-per-finding CSV — security + performance findings plus
|
|
57
|
+
# any dependency findings, in that order — for dropping into a
|
|
58
|
+
# spreadsheet or importing into a ticketing tool. Deliberately excludes
|
|
59
|
+
# duplicate-code groups: they're nested member lists, not a single
|
|
60
|
+
# actionable item, so they don't fit a flat "one row = one thing to
|
|
61
|
+
# fix" table (see as_json for the full nested data). No `csv` stdlib
|
|
62
|
+
# dependency — RFC4180-style quoting is small enough to hand-roll, same
|
|
63
|
+
# reasoning as this gem's other hand-rolled parsers/writers.
|
|
64
|
+
def as_csv
|
|
65
|
+
h = as_hash
|
|
66
|
+
rows = [CSV_HEADERS]
|
|
67
|
+
h["security_findings"].each { |f| rows << static_csv_row(f) }
|
|
68
|
+
h["performance_findings"].each { |f| rows << static_csv_row(f) }
|
|
69
|
+
h["dependency_findings"].each { |f| rows << dependency_csv_row(f) }
|
|
70
|
+
|
|
71
|
+
rows.map { |row| row.map { |field| csv_field(field) }.join(",") }.join("\n")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def as_html
|
|
75
|
+
h = as_hash
|
|
76
|
+
security = h["security_findings"]
|
|
77
|
+
performance = h["performance_findings"]
|
|
78
|
+
all_findings = security + performance
|
|
79
|
+
by_severity = all_findings.group_by { |f| f["severity"] }
|
|
80
|
+
duplicate_groups = h["duplicate_groups"]
|
|
81
|
+
dependency_findings = h["dependency_findings"]
|
|
82
|
+
|
|
83
|
+
<<~HTML
|
|
84
|
+
<!DOCTYPE html>
|
|
85
|
+
<html>
|
|
86
|
+
<head>
|
|
87
|
+
<meta charset="utf-8">
|
|
88
|
+
<title>Scryer report — #{escape(@project_name)}</title>
|
|
89
|
+
<style>#{CSS}</style>
|
|
90
|
+
</head>
|
|
91
|
+
<body>
|
|
92
|
+
<h1>Scryer report</h1>
|
|
93
|
+
<p class="meta">
|
|
94
|
+
#{escape(@project_name)} · #{escape(h["release_label"] || "no release label")} ·
|
|
95
|
+
#{escape(h["scanned_at"])} · #{h["files_scanned"]} files scanned
|
|
96
|
+
#{h["parse_errors"].any? ? "· <span class=\"crit\">#{h["parse_errors"].size} parse error(s)</span>" : ""}
|
|
97
|
+
</p>
|
|
98
|
+
|
|
99
|
+
#{render_toc(h)}
|
|
100
|
+
|
|
101
|
+
<section id="overview">
|
|
102
|
+
<h2>Overview</h2>
|
|
103
|
+
#{render_overview_table(h)}
|
|
104
|
+
</section>
|
|
105
|
+
|
|
106
|
+
<section id="summary">
|
|
107
|
+
<h2>Summary</h2>
|
|
108
|
+
#{render_summary_table(security, performance, duplicate_groups, dependency_findings)}
|
|
109
|
+
</section>
|
|
110
|
+
|
|
111
|
+
<section id="checks-performed">
|
|
112
|
+
<h2>Checks performed</h2>
|
|
113
|
+
#{render_checks_performed}
|
|
114
|
+
</section>
|
|
115
|
+
|
|
116
|
+
<section id="warnings-by-type">
|
|
117
|
+
<h2>Warnings by type</h2>
|
|
118
|
+
#{render_warnings_by_type(all_findings)}
|
|
119
|
+
</section>
|
|
120
|
+
|
|
121
|
+
<section id="findings">
|
|
122
|
+
<h2>Findings (#{all_findings.size}) #{expand_collapse_controls("#findings")}</h2>
|
|
123
|
+
#{render_severity_section("critical", by_severity["critical"] || [])}
|
|
124
|
+
#{render_severity_section("warning", by_severity["warning"] || [])}
|
|
125
|
+
#{render_severity_section("info", by_severity["info"] || [])}
|
|
126
|
+
</section>
|
|
127
|
+
|
|
128
|
+
<section id="duplicates">
|
|
129
|
+
<h2>Duplicate code groups (#{duplicate_groups.size}) #{expand_collapse_controls("#duplicates")}</h2>
|
|
130
|
+
#{render_duplicate_groups(duplicate_groups)}
|
|
131
|
+
</section>
|
|
132
|
+
|
|
133
|
+
<section id="dependency-audit">
|
|
134
|
+
<h2>Dependency audit (#{dependency_findings.size}) #{expand_collapse_controls("#dependency-audit")}</h2>
|
|
135
|
+
#{render_dependency_findings(dependency_findings)}
|
|
136
|
+
</section>
|
|
137
|
+
|
|
138
|
+
<section id="errors">
|
|
139
|
+
<h2>Files that couldn't be parsed (#{h["parse_errors"].size})</h2>
|
|
140
|
+
#{render_parse_errors(h["parse_errors"])}
|
|
141
|
+
</section>
|
|
142
|
+
|
|
143
|
+
<p class="footer">
|
|
144
|
+
Generated by Scryer v#{h["scryer_version"]} (Ruby #{escape(h["ruby_version"])}) ·
|
|
145
|
+
heuristic static analysis, not full data-flow/taint analysis — review every finding in
|
|
146
|
+
its surrounding context before acting on it.
|
|
147
|
+
</p>
|
|
148
|
+
|
|
149
|
+
<script>#{JS}</script>
|
|
150
|
+
</body>
|
|
151
|
+
</html>
|
|
152
|
+
HTML
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def render_toc(h)
|
|
158
|
+
<<~HTML
|
|
159
|
+
<nav class="toc">
|
|
160
|
+
<a href="#overview">Overview</a>
|
|
161
|
+
<a href="#summary">Summary</a>
|
|
162
|
+
<a href="#checks-performed">Checks performed</a>
|
|
163
|
+
<a href="#warnings-by-type">Warnings by type</a>
|
|
164
|
+
<a href="#findings">Findings</a>
|
|
165
|
+
<a href="#duplicates">Duplicate code (#{h["duplicate_groups"].size})</a>
|
|
166
|
+
<a href="#dependency-audit">Dependency audit (#{h["dependency_findings"].size})</a>
|
|
167
|
+
<a href="#errors">Parse errors (#{h["parse_errors"].size})</a>
|
|
168
|
+
</nav>
|
|
169
|
+
HTML
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def render_overview_table(h)
|
|
173
|
+
rows = {
|
|
174
|
+
"Project" => h["project_name"],
|
|
175
|
+
"Scanned at" => h["scanned_at"],
|
|
176
|
+
"Release label" => h["release_label"] || "—",
|
|
177
|
+
"Git branch" => h["git_branch"] || "—",
|
|
178
|
+
"Git commit" => h["git_commit_sha"] || "—",
|
|
179
|
+
"Files scanned" => h["files_scanned"],
|
|
180
|
+
"Scryer version" => h["scryer_version"],
|
|
181
|
+
"Ruby version" => h["ruby_version"]
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
body = rows.map { |k, v| "<tr><th>#{escape(k)}</th><td>#{escape(v)}</td></tr>" }.join
|
|
185
|
+
"<table class=\"kv\">#{body}</table>"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def render_summary_table(security, performance, duplicate_groups, dependency_findings)
|
|
189
|
+
sec_counts = count_by_severity(security)
|
|
190
|
+
perf_counts = count_by_severity(performance)
|
|
191
|
+
total_counts = SEVERITY_ORDER.each_with_object({}) { |s, acc| acc[s] = sec_counts[s] + perf_counts[s] }
|
|
192
|
+
|
|
193
|
+
header = "<tr><th>Category</th>" + SEVERITY_ORDER.map { |s| "<th>#{SEVERITY_LABELS[s]}</th>" }.join + "<th>Total</th></tr>"
|
|
194
|
+
sec_row = summary_row("Security", sec_counts)
|
|
195
|
+
perf_row = summary_row("Performance", perf_counts)
|
|
196
|
+
total_row = summary_row("Total", total_counts, css_class: "total")
|
|
197
|
+
dup_row = "<tr><th>Duplicate code</th><td colspan=\"#{SEVERITY_ORDER.size}\">—</td><td>#{duplicate_groups.size} group(s)</td></tr>"
|
|
198
|
+
deps_row = "<tr><th>Dependency audit</th><td colspan=\"#{SEVERITY_ORDER.size}\">—</td><td>#{dependency_findings.size} finding(s)</td></tr>"
|
|
199
|
+
|
|
200
|
+
"<table class=\"summary\">#{header}#{sec_row}#{perf_row}#{dup_row}#{deps_row}#{total_row}</table>"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def summary_row(label, counts, css_class: nil)
|
|
204
|
+
cells = SEVERITY_ORDER.map { |s| "<td>#{counts[s]}</td>" }.join
|
|
205
|
+
total = counts.values.sum
|
|
206
|
+
"<tr#{css_class ? " class=\"#{css_class}\"" : ""}><th>#{escape(label)}</th>#{cells}<td>#{total}</td></tr>"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def count_by_severity(findings)
|
|
210
|
+
SEVERITY_ORDER.each_with_object(Hash.new(0)) { |s, acc| acc[s] = findings.count { |f| f["severity"] == s } }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def rules_by_category
|
|
214
|
+
Scryer::RuleSet.all.group_by(&:category)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def rule_titles
|
|
218
|
+
Scryer::RuleSet.all.each_with_object({}) { |r, acc| acc[r.rule_id] = r.title }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def render_checks_performed
|
|
222
|
+
security_rules = rules_by_category["security"] || []
|
|
223
|
+
performance_rules = rules_by_category["performance"] || []
|
|
224
|
+
|
|
225
|
+
"<h3>Security (#{security_rules.size})</h3>#{checks_table(security_rules)}" \
|
|
226
|
+
"<h3>Performance (#{performance_rules.size})</h3>#{checks_table(performance_rules)}"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def checks_table(rules)
|
|
230
|
+
return "<p class=\"muted\">None registered.</p>" if rules.empty?
|
|
231
|
+
|
|
232
|
+
rows = rules.sort_by(&:rule_id).map do |rule|
|
|
233
|
+
"<tr><td><code>#{escape(rule.rule_id)}</code></td><td>#{escape(rule.title)}</td>" \
|
|
234
|
+
"<td><span class=\"badge #{rule.default_severity}\">#{rule.default_severity.upcase}</span></td></tr>"
|
|
235
|
+
end.join
|
|
236
|
+
|
|
237
|
+
"<table class=\"checks\"><tr><th>Rule ID</th><th>Description</th><th>Default severity</th></tr>#{rows}</table>"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def render_warnings_by_type(all_findings)
|
|
241
|
+
return "<p class=\"muted\">No findings.</p>" if all_findings.empty?
|
|
242
|
+
|
|
243
|
+
titles = rule_titles
|
|
244
|
+
|
|
245
|
+
by_rule = all_findings.group_by { |f| f["rule_id"] }
|
|
246
|
+
rows = by_rule.sort_by { |rule_id, findings| [-findings.size, rule_id.to_s] }.map do |rule_id, findings|
|
|
247
|
+
worst = SEVERITY_ORDER.find { |s| findings.any? { |f| f["severity"] == s } }
|
|
248
|
+
anchor = rule_group_anchor(worst, rule_id)
|
|
249
|
+
"<tr><td><a class=\"jump-link\" href=\"##{anchor}\"><code>#{escape(rule_id)}</code></a></td>" \
|
|
250
|
+
"<td>#{escape(titles[rule_id] || rule_id)}</td>" \
|
|
251
|
+
"<td>#{escape(findings.first["category"])}</td>" \
|
|
252
|
+
"<td><span class=\"badge #{worst}\">#{SEVERITY_LABELS[worst]}</span></td>" \
|
|
253
|
+
"<td>#{findings.size}</td></tr>"
|
|
254
|
+
end.join
|
|
255
|
+
|
|
256
|
+
"<table class=\"checks\"><tr><th>Rule ID</th><th>Description</th><th>Category</th>" \
|
|
257
|
+
"<th>Highest severity</th><th>Count</th></tr>#{rows}</table>"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Anchor id for the accordion group a given (severity, rule_id) pair
|
|
261
|
+
# renders into — used both when rendering the group itself and when
|
|
262
|
+
# linking to it from the "Warnings by type" table. rule_id is always a
|
|
263
|
+
# simple snake_case identifier (see Rule#rule_id across lib/scryer/
|
|
264
|
+
# rules/*), so no extra escaping beyond the id-safe substitution below is
|
|
265
|
+
# needed.
|
|
266
|
+
def rule_group_anchor(severity, rule_id)
|
|
267
|
+
"rule-#{severity}-#{rule_id.to_s.gsub(/[^a-zA-Z0-9_-]/, "-")}"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def expand_collapse_controls(target_selector)
|
|
271
|
+
"<span class=\"accordion-controls\">" \
|
|
272
|
+
"<button type=\"button\" class=\"expand-all\" data-target=\"#{target_selector}\">Expand all</button>" \
|
|
273
|
+
"<button type=\"button\" class=\"collapse-all\" data-target=\"#{target_selector}\">Collapse all</button>" \
|
|
274
|
+
"</span>"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def render_parse_errors(parse_errors)
|
|
278
|
+
return "<p class=\"muted\">None.</p>" if parse_errors.empty?
|
|
279
|
+
|
|
280
|
+
rows = parse_errors.map do |pe|
|
|
281
|
+
"<tr><td>#{escape(pe["file"])}</td><td>#{escape(pe["error"])}</td></tr>"
|
|
282
|
+
end.join
|
|
283
|
+
|
|
284
|
+
"<table class=\"checks\"><tr><th>File</th><th>Error</th></tr>#{rows}</table>"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def duplicate_group_hash(group)
|
|
288
|
+
{
|
|
289
|
+
"kind" => group.kind,
|
|
290
|
+
"similarity" => group.similarity,
|
|
291
|
+
"members" => group.members.map do |m|
|
|
292
|
+
{
|
|
293
|
+
"file" => m.file,
|
|
294
|
+
"name" => m.name,
|
|
295
|
+
"start_line" => m.start_line,
|
|
296
|
+
"end_line" => m.end_line,
|
|
297
|
+
"source_snippet" => m.source_snippet,
|
|
298
|
+
# Only CacheCallInfo members have a cache_key (the fetch/write's
|
|
299
|
+
# key argument) — nil for method/query duplicate members.
|
|
300
|
+
"cache_key" => m.respond_to?(:cache_key) ? m.cache_key : nil
|
|
301
|
+
}
|
|
302
|
+
end
|
|
303
|
+
}
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def render_severity_section(severity, findings)
|
|
307
|
+
return "" if findings.empty?
|
|
308
|
+
|
|
309
|
+
titles = rule_titles
|
|
310
|
+
by_rule = findings.group_by { |f| f["rule_id"] }
|
|
311
|
+
groups = by_rule.sort_by { |rule_id, fs| [-fs.size, rule_id.to_s] }.map do |rule_id, fs|
|
|
312
|
+
render_rule_group(severity, rule_id, titles[rule_id] || rule_id, fs)
|
|
313
|
+
end.join
|
|
314
|
+
|
|
315
|
+
"<h3 id=\"sev-#{severity}\">#{severity.capitalize} (#{findings.size})</h3>#{groups}"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# One accordion per rule within a severity — collapsed by default since a
|
|
319
|
+
# single rule (e.g. n_plus_one_query) can easily produce hundreds of
|
|
320
|
+
# findings, which made the old flat list impractical to scan. This is
|
|
321
|
+
# also the jump target for the "Warnings by type" table's links.
|
|
322
|
+
def render_rule_group(severity, rule_id, title, findings)
|
|
323
|
+
anchor = rule_group_anchor(severity, rule_id)
|
|
324
|
+
rows = findings.map { |f| render_finding(severity, f) }.join
|
|
325
|
+
|
|
326
|
+
<<~HTML
|
|
327
|
+
<div class="accordion" id="#{anchor}">
|
|
328
|
+
<button type="button" class="accordion-header">
|
|
329
|
+
<span><span class="badge #{severity}">#{severity.upcase}</span> <code>#{escape(rule_id)}</code> #{escape(title)}</span>
|
|
330
|
+
<span class="accordion-meta">#{findings.size} finding#{"s" unless findings.size == 1}<span class="chevron">▸</span></span>
|
|
331
|
+
</button>
|
|
332
|
+
<div class="accordion-body">
|
|
333
|
+
#{rows}
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
HTML
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def render_finding(severity, f)
|
|
340
|
+
<<~ROW
|
|
341
|
+
<div class="finding #{severity}">
|
|
342
|
+
<div class="finding-head">
|
|
343
|
+
<span class="badge #{severity}">#{severity.upcase}</span>
|
|
344
|
+
<code>#{escape(f["rule_id"])}</code>
|
|
345
|
+
<span class="loc">#{escape(f["file"])}#{f["line"] ? ":#{f["line"]}" : ""}</span>
|
|
346
|
+
</div>
|
|
347
|
+
<p>#{escape(f["message"])}</p>
|
|
348
|
+
#{f["code_snippet"] ? "<pre>#{escape(f["code_snippet"])}</pre>" : ""}
|
|
349
|
+
<div class="fix"><strong>Suggested fix:</strong> #{escape(f["suggested_fix"])}</div>
|
|
350
|
+
</div>
|
|
351
|
+
ROW
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
KIND_LABELS = {
|
|
355
|
+
"method_duplicate" => "Method duplicate",
|
|
356
|
+
"query_duplicate" => "Query duplicate",
|
|
357
|
+
"cache_duplicate" => "Cache duplicate"
|
|
358
|
+
}.freeze
|
|
359
|
+
|
|
360
|
+
def render_duplicate_groups(groups)
|
|
361
|
+
return "<p class=\"muted\">None detected.</p>" if groups.empty?
|
|
362
|
+
|
|
363
|
+
groups.each_with_index.map do |g, index|
|
|
364
|
+
members = g["members"].map do |m|
|
|
365
|
+
key_line = m["cache_key"] ? "<p class=\"loc\">cache key: <code>#{escape(m["cache_key"])}</code></p>" : ""
|
|
366
|
+
"<div class=\"dup-member\"><p class=\"loc\">#{escape(m["file"])} — <code>#{escape(m["name"])}</code> " \
|
|
367
|
+
"(lines #{m["start_line"]}–#{m["end_line"]})</p>#{key_line}<pre>#{escape(m["source_snippet"].to_s)}</pre></div>"
|
|
368
|
+
end.join
|
|
369
|
+
|
|
370
|
+
first_member = g["members"].first || {}
|
|
371
|
+
summary = "#{escape(first_member["file"].to_s)}#{g["members"].size > 1 ? " + #{g["members"].size - 1} more" : ""}"
|
|
372
|
+
|
|
373
|
+
<<~HTML
|
|
374
|
+
<div class="accordion dup-group" id="dup-group-#{index}">
|
|
375
|
+
<button type="button" class="accordion-header">
|
|
376
|
+
<span><span class="badge kind">#{escape(KIND_LABELS.fetch(g["kind"], g["kind"]))}</span>
|
|
377
|
+
<strong>#{(g["similarity"] * 100).round}% similar</strong> · #{summary}</span>
|
|
378
|
+
<span class="accordion-meta">#{g["members"].size} occurrences<span class="chevron">▸</span></span>
|
|
379
|
+
</button>
|
|
380
|
+
<div class="accordion-body">#{members}</div>
|
|
381
|
+
</div>
|
|
382
|
+
HTML
|
|
383
|
+
end.join
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# dependency_findings entries are Scryer::DependencyAudit::Finding#to_h
|
|
387
|
+
# shapes (kind/gem_name/installed_version/advisory_id/title/url/
|
|
388
|
+
# patched_versions/message/suggested_fix) — a different shape from the
|
|
389
|
+
# rule-based Finding hashes rendered by render_finding, so this has its
|
|
390
|
+
# own layout rather than reusing render_severity_section.
|
|
391
|
+
def render_dependency_findings(dependency_findings)
|
|
392
|
+
return "<p class=\"muted\">None detected (run with dependency auditing enabled to check " \
|
|
393
|
+
"Gemfile.lock against OSV.dev and for insecure git/http sources).</p>" if dependency_findings.empty?
|
|
394
|
+
|
|
395
|
+
sorted = dependency_findings.sort_by { |f| [SEVERITY_ORDER.index(f["severity"]) || 99, f["gem_name"].to_s] }
|
|
396
|
+
rows = sorted.map { |f| render_dependency_finding(f) }.join
|
|
397
|
+
|
|
398
|
+
"<div class=\"dep-list\">#{rows}</div>"
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def render_dependency_finding(f)
|
|
402
|
+
severity = f["severity"]
|
|
403
|
+
heading = f["kind"] == "insecure_source" ? "Insecure gem source" : "#{escape(f["gem_name"])} #{escape(f["installed_version"])}"
|
|
404
|
+
advisory = f["advisory_id"] ? "<span class=\"loc\">#{escape(f["advisory_id"])}#{f["title"] ? " — #{escape(f["title"])}" : ""}</span>" : ""
|
|
405
|
+
link = f["url"] ? " · <a href=\"#{escape(f["url"])}\" target=\"_blank\" rel=\"noopener\">advisory</a>" : ""
|
|
406
|
+
patched = Array(f["patched_versions"])
|
|
407
|
+
|
|
408
|
+
<<~ROW
|
|
409
|
+
<div class="finding #{severity}">
|
|
410
|
+
<div class="finding-head">
|
|
411
|
+
<span class="badge #{severity}">#{severity.upcase}</span>
|
|
412
|
+
<strong>#{heading}</strong>
|
|
413
|
+
#{advisory}#{link}
|
|
414
|
+
</div>
|
|
415
|
+
<p>#{escape(f["message"])}</p>
|
|
416
|
+
#{patched.empty? ? "" : "<p class=\"loc\">Patched version(s): #{escape(patched.join(", "))}</p>"}
|
|
417
|
+
<div class="fix"><strong>Suggested fix:</strong> #{escape(f["suggested_fix"])}</div>
|
|
418
|
+
</div>
|
|
419
|
+
ROW
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def escape(text)
|
|
423
|
+
text.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def static_csv_row(f)
|
|
427
|
+
[
|
|
428
|
+
f["category"], f["rule_id"], f["severity"],
|
|
429
|
+
"#{f["file"]}#{f["line"] ? ":#{f["line"]}" : ""}",
|
|
430
|
+
f["message"], f["suggested_fix"], f["code_snippet"], nil
|
|
431
|
+
]
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def dependency_csv_row(f)
|
|
435
|
+
identifier = f["kind"] == "insecure_source" ? "insecure_source" : "#{f["gem_name"]} #{f["installed_version"]} (#{f["advisory_id"]})"
|
|
436
|
+
[f["kind"], identifier, f["severity"], "Gemfile.lock", f["message"], f["suggested_fix"], nil, f["url"]]
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def csv_field(value)
|
|
440
|
+
s = value.to_s
|
|
441
|
+
s.match?(/[",\n\r]/) ? "\"#{s.gsub('"', '""')}\"" : s
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
CSS = <<~CSS
|
|
445
|
+
body { font-family: -apple-system, Helvetica, Arial, sans-serif; margin: 2rem; color: #1e293b; }
|
|
446
|
+
h1 { margin-bottom: 0.25rem; }
|
|
447
|
+
h2 { margin-top: 2rem; border-bottom: 1px solid #e2e8f0; padding-bottom: 0.35rem; }
|
|
448
|
+
.meta { color: #64748b; font-size: 0.875rem; margin-top: 0; }
|
|
449
|
+
.crit { color: #b91c1c; font-weight: 600; }
|
|
450
|
+
.toc { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; background: #f8fafc; border: 1px solid #e2e8f0;
|
|
451
|
+
border-radius: 8px; padding: 0.75rem 1rem; font-size: 0.85rem; }
|
|
452
|
+
.toc a { color: #3730a3; text-decoration: none; }
|
|
453
|
+
.toc a:hover { text-decoration: underline; }
|
|
454
|
+
table.kv, table.summary, table.checks { border-collapse: collapse; width: 100%; font-size: 0.875rem; margin-top: 0.5rem; }
|
|
455
|
+
table.kv th, table.kv td, table.summary th, table.summary td, table.checks th, table.checks td {
|
|
456
|
+
border: 1px solid #e2e8f0; padding: 0.4rem 0.65rem; text-align: left;
|
|
457
|
+
}
|
|
458
|
+
table.kv th { width: 12rem; background: #f8fafc; color: #475569; font-weight: 600; }
|
|
459
|
+
table.summary th, table.checks th { background: #f8fafc; color: #475569; }
|
|
460
|
+
table.summary tr.total { font-weight: 700; }
|
|
461
|
+
.finding { border: 1px solid #e2e8f0; border-radius: 8px; padding: 0.75rem 1rem; margin-bottom: 0.75rem; }
|
|
462
|
+
.finding-head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
|
|
463
|
+
.badge { font-size: 0.7rem; font-weight: 700; padding: 0.15rem 0.5rem; border-radius: 4px; }
|
|
464
|
+
.badge.critical { background: #fee2e2; color: #991b1b; }
|
|
465
|
+
.badge.warning { background: #fef3c7; color: #92400e; }
|
|
466
|
+
.badge.info { background: #f1f5f9; color: #475569; }
|
|
467
|
+
.badge.kind { background: #ede9fe; color: #5b21b6; }
|
|
468
|
+
.loc { color: #64748b; font-size: 0.8rem; }
|
|
469
|
+
pre { background: #0f172a; color: #e2e8f0; padding: 0.5rem 0.75rem; border-radius: 6px; overflow-x: auto; font-size: 0.8rem; }
|
|
470
|
+
.fix { background: #eef2ff; border-radius: 6px; padding: 0.5rem 0.75rem; font-size: 0.875rem; }
|
|
471
|
+
.dup-member { margin-top: 0.5rem; }
|
|
472
|
+
.dup-member:first-child { margin-top: 0; }
|
|
473
|
+
.muted { color: #94a3b8; }
|
|
474
|
+
.footer { margin-top: 2.5rem; color: #94a3b8; font-size: 0.8rem; border-top: 1px solid #e2e8f0; padding-top: 0.75rem; }
|
|
475
|
+
|
|
476
|
+
.accordion-controls { float: right; font-weight: 400; }
|
|
477
|
+
.accordion-controls button { font: inherit; font-size: 0.75rem; margin-left: 0.5rem; padding: 0.2rem 0.6rem;
|
|
478
|
+
border: 1px solid #cbd5e1; border-radius: 6px; background: #fff; cursor: pointer; color: #334155; }
|
|
479
|
+
.accordion-controls button:hover { background: #f1f5f9; }
|
|
480
|
+
.accordion { border: 1px solid #e2e8f0; border-radius: 8px; margin-bottom: 0.75rem; overflow: hidden; }
|
|
481
|
+
.accordion-header { all: unset; box-sizing: border-box; display: flex; width: 100%; align-items: center;
|
|
482
|
+
justify-content: space-between; gap: 1rem; background: #f8fafc; padding: 0.6rem 1rem; cursor: pointer; }
|
|
483
|
+
.accordion-header:hover { background: #f1f5f9; }
|
|
484
|
+
.accordion-meta { display: flex; align-items: center; gap: 0.5rem; color: #64748b; font-size: 0.8rem; white-space: nowrap; }
|
|
485
|
+
.chevron { display: inline-block; transition: transform 0.15s ease; }
|
|
486
|
+
.accordion.open > .accordion-header .chevron { transform: rotate(90deg); }
|
|
487
|
+
.accordion-body { display: none; padding: 0.75rem 1rem; }
|
|
488
|
+
.accordion.open > .accordion-body { display: block; }
|
|
489
|
+
.accordion .finding, .accordion .dup-member { margin-bottom: 0.75rem; }
|
|
490
|
+
.accordion .finding:last-child, .accordion .dup-member:last-child { margin-bottom: 0; }
|
|
491
|
+
.accordion:target, .accordion.open:target { outline: 2px solid #6366f1; outline-offset: 2px; }
|
|
492
|
+
a.jump-link { color: inherit; text-decoration: none; }
|
|
493
|
+
a.jump-link:hover code { text-decoration: underline; }
|
|
494
|
+
CSS
|
|
495
|
+
|
|
496
|
+
JS = <<~JS
|
|
497
|
+
(function () {
|
|
498
|
+
function setOpen(acc, open) {
|
|
499
|
+
if (!acc) return;
|
|
500
|
+
acc.classList.toggle("open", open);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
document.addEventListener("click", function (e) {
|
|
504
|
+
var header = e.target.closest(".accordion-header");
|
|
505
|
+
if (header) {
|
|
506
|
+
var acc = header.closest(".accordion");
|
|
507
|
+
setOpen(acc, !acc.classList.contains("open"));
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
var expandBtn = e.target.closest(".expand-all");
|
|
512
|
+
if (expandBtn) {
|
|
513
|
+
document.querySelectorAll(expandBtn.dataset.target + " .accordion").forEach(function (acc) {
|
|
514
|
+
setOpen(acc, true);
|
|
515
|
+
});
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
var collapseBtn = e.target.closest(".collapse-all");
|
|
520
|
+
if (collapseBtn) {
|
|
521
|
+
document.querySelectorAll(collapseBtn.dataset.target + " .accordion").forEach(function (acc) {
|
|
522
|
+
setOpen(acc, false);
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
function openHashTarget() {
|
|
528
|
+
if (!location.hash) return;
|
|
529
|
+
var el;
|
|
530
|
+
try {
|
|
531
|
+
el = document.querySelector(location.hash);
|
|
532
|
+
} catch (err) {
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (!el) return;
|
|
536
|
+
var acc = el.classList.contains("accordion") ? el : el.closest(".accordion");
|
|
537
|
+
setOpen(acc, true);
|
|
538
|
+
el.scrollIntoView();
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
window.addEventListener("hashchange", openHashTarget);
|
|
542
|
+
openHashTarget();
|
|
543
|
+
})();
|
|
544
|
+
JS
|
|
545
|
+
end
|
|
546
|
+
end
|
data/lib/scryer/rule.rb
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
# Base class for a single detection rule. Subclasses implement `#scan` and
|
|
3
|
+
# return an Array of Finding. Every rule gets the parsed sexp tree (so it
|
|
4
|
+
# doesn't have to re-parse), the raw source (for snippet extraction), and
|
|
5
|
+
# the relative file path (for reporting).
|
|
6
|
+
class Rule
|
|
7
|
+
class << self
|
|
8
|
+
attr_accessor :rule_id, :category, :default_severity, :title
|
|
9
|
+
|
|
10
|
+
def inherited(subclass)
|
|
11
|
+
super
|
|
12
|
+
Scryer::RuleSet.register(subclass)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(file:, source:, sexp:)
|
|
17
|
+
@file = file
|
|
18
|
+
@source = source
|
|
19
|
+
@sexp = sexp
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
attr_reader :file, :source, :sexp
|
|
23
|
+
|
|
24
|
+
def scan
|
|
25
|
+
raise NotImplementedError, "#{self.class} must implement #scan"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def finding(line:, message:, suggested_fix:, severity: self.class.default_severity)
|
|
31
|
+
Finding.new(
|
|
32
|
+
rule_id: self.class.rule_id,
|
|
33
|
+
category: self.class.category,
|
|
34
|
+
severity: severity,
|
|
35
|
+
file: file,
|
|
36
|
+
line: line,
|
|
37
|
+
code_snippet: Ast.source_line(source, line),
|
|
38
|
+
message: message,
|
|
39
|
+
suggested_fix: suggested_fix
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
# Registry of every Rule subclass (rules self-register via Rule.inherited).
|
|
3
|
+
# Scanner uses .all to run every registered rule against each parsed file.
|
|
4
|
+
module RuleSet
|
|
5
|
+
class << self
|
|
6
|
+
def register(rule_class)
|
|
7
|
+
registry << rule_class
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def registry
|
|
11
|
+
@registry ||= []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def all
|
|
15
|
+
registry
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module Scryer
|
|
2
|
+
module Rules
|
|
3
|
+
# Flags shell-executing calls (`system`, `` ` ` `` backticks, `%x{}`,
|
|
4
|
+
# `Kernel#exec`, `IO.popen`, `Open3.*`) whose command string contains
|
|
5
|
+
# interpolation — user-controlled input reaching a shell is a command
|
|
6
|
+
# injection risk.
|
|
7
|
+
class CommandInjectionRule < Rule
|
|
8
|
+
self.rule_id = "command_injection"
|
|
9
|
+
self.category = "security"
|
|
10
|
+
self.default_severity = "critical"
|
|
11
|
+
self.title = "Possible command injection via shell call"
|
|
12
|
+
|
|
13
|
+
SHELL_METHODS = %w[system exec popen spawn].freeze
|
|
14
|
+
|
|
15
|
+
def scan
|
|
16
|
+
findings = []
|
|
17
|
+
|
|
18
|
+
Ast.each_node(sexp) do |node|
|
|
19
|
+
# backticks / %x{} literals: [:xstring_literal, [:xstring, [:@tstring_content, ...] or [:string_embexpr, ...]]]
|
|
20
|
+
if Ast.tagged?(node, :xstring_literal) && Ast.string_literal_has_interpolation?(node)
|
|
21
|
+
line = Ast.line_of(node)
|
|
22
|
+
findings << finding(
|
|
23
|
+
line: line,
|
|
24
|
+
message: "Backtick/`%x{}` shell execution contains interpolated input.",
|
|
25
|
+
suggested_fix: "Avoid shelling out with interpolated strings. If you must run a " \
|
|
26
|
+
"command, use `system(\"cmd\", arg1, arg2)` (array form) so each " \
|
|
27
|
+
"argument is passed directly to the OS without going through a shell."
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
next unless Ast.tagged?(node, :method_add_arg, :command, :command_call)
|
|
32
|
+
|
|
33
|
+
inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
|
|
34
|
+
receiver_and_name = Ast.call_name(inner)
|
|
35
|
+
next unless receiver_and_name
|
|
36
|
+
|
|
37
|
+
_receiver, method_name = receiver_and_name
|
|
38
|
+
next unless SHELL_METHODS.include?(method_name)
|
|
39
|
+
|
|
40
|
+
args = Ast.call_arguments(node)
|
|
41
|
+
next if args.empty?
|
|
42
|
+
|
|
43
|
+
first_arg = args.first
|
|
44
|
+
next unless Ast.string_literal_has_interpolation?(first_arg)
|
|
45
|
+
|
|
46
|
+
line = Ast.line_of(first_arg) || Ast.line_of(node)
|
|
47
|
+
findings << finding(
|
|
48
|
+
line: line,
|
|
49
|
+
message: "`#{method_name}` is called with a single interpolated string, which goes " \
|
|
50
|
+
"through a shell — user-controlled input here can inject arbitrary commands.",
|
|
51
|
+
suggested_fix: "Pass arguments as separate strings instead of one interpolated " \
|
|
52
|
+
"string, e.g. `#{method_name}(\"cmd\", user_input)` rather than " \
|
|
53
|
+
"`#{method_name}(\"cmd \#{user_input}\")` — the array form bypasses the shell entirely."
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
findings
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|