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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +367 -0
  3. data/exe/scryer +7 -0
  4. data/lib/generators/scryer/USAGE +66 -0
  5. data/lib/generators/scryer/install_generator.rb +29 -0
  6. data/lib/generators/scryer/templates/scryer_initializer.rb +28 -0
  7. data/lib/scryer/ai_client.rb +53 -0
  8. data/lib/scryer/ai_fix_suggester.rb +138 -0
  9. data/lib/scryer/ast.rb +277 -0
  10. data/lib/scryer/cache_extractor.rb +124 -0
  11. data/lib/scryer/cli.rb +193 -0
  12. data/lib/scryer/dependency_audit.rb +225 -0
  13. data/lib/scryer/duplicate_detector.rb +103 -0
  14. data/lib/scryer/finding.rb +21 -0
  15. data/lib/scryer/method_extractor.rb +55 -0
  16. data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +108 -0
  17. data/lib/scryer/performance_rules/missing_pagination_rule.rb +132 -0
  18. data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +221 -0
  19. data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +78 -0
  20. data/lib/scryer/query_extractor.rb +123 -0
  21. data/lib/scryer/query_watcher.rb +250 -0
  22. data/lib/scryer/railtie.rb +12 -0
  23. data/lib/scryer/report_renderer.rb +546 -0
  24. data/lib/scryer/rule.rb +43 -0
  25. data/lib/scryer/rule_set.rb +19 -0
  26. data/lib/scryer/rules/command_injection_rule.rb +61 -0
  27. data/lib/scryer/rules/csrf_protection_rule.rb +89 -0
  28. data/lib/scryer/rules/hardcoded_secret_rule.rb +96 -0
  29. data/lib/scryer/rules/mass_assignment_rule.rb +103 -0
  30. data/lib/scryer/rules/open_redirect_rule.rb +57 -0
  31. data/lib/scryer/rules/sql_injection_rule.rb +63 -0
  32. data/lib/scryer/rules/unsafe_deserialization_rule.rb +71 -0
  33. data/lib/scryer/rules/weak_crypto_rule.rb +66 -0
  34. data/lib/scryer/rules/xss_unsafe_html_rule.rb +70 -0
  35. data/lib/scryer/scanner.rb +129 -0
  36. data/lib/scryer/version.rb +3 -0
  37. data/lib/scryer.rb +65 -0
  38. data/lib/tasks/scryer.rake +172 -0
  39. 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)} &middot; #{escape(h["release_label"] || "no release label")} &middot;
95
+ #{escape(h["scanned_at"])} &middot; #{h["files_scanned"]} files scanned
96
+ #{h["parse_errors"].any? ? "&middot; <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"])}) &middot;
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">&#9656;</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"]}&ndash;#{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> &middot; #{summary}</span>
378
+ <span class="accordion-meta">#{g["members"].size} occurrences<span class="chevron">&#9656;</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"] ? " &middot; <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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
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
@@ -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