scryer 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e1c8d4523447f9bdd1ea796fd27177efa18d6f726be6764e08aac1b18e05482b
4
- data.tar.gz: de971058c3bfd42ad8ed9eabb6b633b305d1d10dc4971ea50e606fdefd573067
3
+ metadata.gz: 9cd7338bfd390fb74f2a1fae54f3132250ae75bd721bc1f65c42f9b80899e2e8
4
+ data.tar.gz: 984d8060977af0b21d2740384664350f9ee2b08e846bbdf71e3f069af40db386
5
5
  SHA512:
6
- metadata.gz: 516425ec399996c7d20d20d6e8e222f188bf227cf911c401cdf514c084de22adcb9d7d15d2629cbb4cf79d0f3ef1f939058e64d24d14f0b3a3239d3922289270
7
- data.tar.gz: 8c5efc1769526f6d8ed9b335a53129ac8f461156719e846d00f30e1ceae340f9b774bd83c89814689351b87c1200fb28fb5c76f4420bb4d86a296042717254d8
6
+ metadata.gz: 7d88896585a86b731a7d0600036f9fc4fe75195fa55d41296670e8e63834ead37f2a699f4865ebd77181802022964ece7c3634559bdc2d7699a0084f805c7feb
7
+ data.tar.gz: 110d568db0d07ee2cb649ba10ca18c324bb8cf073aef7fe3ca2e866c9e994ed5356d3fc88507ff22b32078cd42f74b8d2792a0c3b0857dcc183d6a5ae13b15cb
data/CHANGELOG.md CHANGED
@@ -3,6 +3,23 @@
3
3
  All notable changes to this project are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [0.3.0] - 2026-08-11
7
+
8
+ - New `scryer -r PATH`/`--require PATH` flag (repeatable): requires a Ruby file before scanning.
9
+ This is the standalone executable's only way to run `Scryer.configure` (set `c.ai_client`,
10
+ `c.skip_rules`, `c.dirs`, etc.) before a scan starts — unlike a Rails app, `scryer` has no
11
+ `config/initializers/` to autoload, so AI-assisted fix suggestions and other config were
12
+ previously unreachable outside Rails. See the README's AI-assisted fix suggestions section for
13
+ a full step-by-step for both the Rails and standalone paths.
14
+ - Fixed: the HTML report rendered a finding's `suggested_fix` as one plain escaped string, so
15
+ Markdown from an AI-rewritten fix (`**bold**`, ```` ```fenced code``` ````) showed up as literal
16
+ asterisks/backticks instead of formatted text. Added a small Markdown-to-HTML renderer
17
+ (bold, inline code, fenced code blocks, paragraphs) used for every `suggested_fix` — static
18
+ template text or LLM rewrite, security/performance/style/dependency findings alike. Raw text is
19
+ always escaped before any tag is added, so this doesn't open an HTML/script injection path even
20
+ for a fully untrusted LLM response; JSON/CSV output is unaffected and still carries the original
21
+ raw text.
22
+
6
23
  ## [0.2.0] - 2026-08-11
7
24
 
8
25
  - New `style` rule category (`Scryer::RuleSet` category `"style"`, alongside `security` and
@@ -43,5 +60,6 @@ Initial release.
43
60
  actual request handling.
44
61
  - `scryer` standalone executable and `scryer:report`/`scryer:audit_dependencies` Rails rake tasks.
45
62
 
63
+ [0.3.0]: https://github.com/ramlaxmanyadav/scryer/releases/tag/v0.3.0
46
64
  [0.2.0]: https://github.com/ramlaxmanyadav/scryer/releases/tag/v0.2.0
47
65
  [0.1.0]: https://github.com/ramlaxmanyadav/scryer/releases/tag/v0.1.0
data/README.md CHANGED
@@ -450,24 +450,54 @@ This is the same OSV.dev client `vulnerable_gems` uses, exposed as a one-off loo
450
450
  Every rule already ships a generic, human-reviewable `suggested_fix` — that's always there and
451
451
  needs nothing configured. `Scryer::AiFixSuggester` optionally rewrites that text per finding using
452
452
  an LLM, so the suggestion is written against the finding's actual offending line instead of a
453
- generic template.
453
+ generic template. This is entirely opt-in: with no client configured, `AiFixSuggester` makes zero
454
+ network calls and every finding keeps its original `suggested_fix` — nothing below is required to
455
+ use the rest of Scryer.
454
456
 
455
- **Provider-agnostic by design — bring any LLM.** Scryer doesn't depend on or assume any specific
456
- vendor's API or SDK (consistent with the zero-runtime-dependency design described in the gemspec).
457
- Configure `c.ai_client` to any object, or even a bare `Proc`/lambda, that responds to `#call(prompt)`
458
- (or `#complete(prompt)`) and returns the model's reply as a `String`:
457
+ ### Step by step
458
+
459
+ **Inside a Rails app** `config/initializers/scryer.rb` is autoloaded at boot, so setting
460
+ `c.ai_client` there is picked up automatically the next time you scan:
459
461
 
460
462
  ```ruby
461
463
  # config/initializers/scryer.rb
462
464
  Scryer.configure do |c|
463
- # Simplest form: any callable. Wire up whatever SDK/gem you already use
464
- # Scryer never requires one itself.
465
+ c.ai_client = ->(prompt) { MyLlmClient.chat(prompt) } # any callable see below for real examples
466
+ end
467
+ ```
468
+
469
+ ```bash
470
+ bin/rails scryer:report
471
+ ```
472
+
473
+ That's the whole flow — no extra flag, no second command. Look for `Scryer: rewriting suggested
474
+ fixes via the configured AI client...` in the task's own output, then open the report: every
475
+ finding's `suggested_fix` is now the LLM's rewrite instead of the generic template.
476
+
477
+ **Outside Rails (the `scryer` executable)** — there's no `config/initializers/` to autoload here,
478
+ so `Scryer.configure` needs to actually run before the scan starts. That's what `-r`/`--require` is
479
+ for: point it at a small Ruby file that calls `Scryer.configure`, and `scryer` requires it first:
480
+
481
+ ```ruby
482
+ # scryer_config.rb — anywhere in your project, any filename
483
+ Scryer.configure do |c|
465
484
  c.ai_client = ->(prompt) { MyLlmClient.chat(prompt) }
466
485
  end
467
486
  ```
468
487
 
469
- This is entirely opt-in: `c.ai_client` is `nil` by default, and with no client configured
470
- `AiFixSuggester` makes zero network calls and every finding keeps its original `suggested_fix`.
488
+ ```bash
489
+ scryer -r ./scryer_config.rb
490
+ ```
491
+
492
+ Same output, same "rewriting suggested fixes..." line, same result — `-r` is the only difference
493
+ between the two paths, and it's required precisely because the standalone executable has nothing
494
+ else to make `Scryer.configure` code actually run before it scans.
495
+
496
+ **Provider-agnostic by design — bring any LLM.** Scryer doesn't depend on or assume any specific
497
+ vendor's API or SDK (consistent with the zero-runtime-dependency design described in the gemspec).
498
+ `c.ai_client` accepts any object, or even a bare `Proc`/lambda, that responds to `#call(prompt)`
499
+ (or `#complete(prompt)`) and returns the model's reply as a `String` — the two examples above used
500
+ a placeholder; `Scryer::AiClient` below is a ready-made adapter for a real HTTP endpoint.
471
501
 
472
502
  ### `Scryer::AiClient` — a ready-made HTTP adapter
473
503
 
@@ -508,10 +538,11 @@ a JSON body.
508
538
 
509
539
  `bin/rails scryer:report` and the `scryer` executable both check `Scryer.configuration.ai_client`
510
540
  after scanning and, if set, call `Scryer::AiFixSuggester.enhance_result!(result)` before rendering
511
- — one LLM call per security/performance finding, run across a small thread pool (same pattern as
512
- the dependency audit's OSV.dev lookups) rather than one at a time. A client that raises, times out,
513
- or returns nothing usable just leaves that finding's original `suggested_fix` in place a failed
514
- enrichment never fails the scan.
541
+ — one LLM call per security/performance/style finding (plus dependency findings too, if the
542
+ dependency audit ran), run across a small thread pool (same pattern as the dependency audit's
543
+ OSV.dev lookups) rather than one at a time. A client that raises, times out, or returns nothing
544
+ usable just leaves that finding's original `suggested_fix` in place — a failed enrichment never
545
+ fails the scan.
515
546
 
516
547
  ```ruby
517
548
  Scryer::AiFixSuggester.enhance!(finding) # one Finding, in place
data/lib/scryer/cli.rb CHANGED
@@ -23,6 +23,13 @@ module Scryer
23
23
  def run
24
24
  options = parse(@argv)
25
25
  return 0 if options[:exit_early]
26
+
27
+ # The standalone executable has no equivalent of a Rails app's
28
+ # config/initializers/scryer.rb getting autoloaded at boot — this is
29
+ # the only way to run Scryer.configure (set c.ai_client, c.skip_rules,
30
+ # c.dirs, ...) before a scan starts outside Rails.
31
+ Array(options[:require]).each { |path| require File.expand_path(path) }
32
+
26
33
  return check_gem(options[:check_gem]) if options[:check_gem]
27
34
 
28
35
  root = File.expand_path(options[:path] || Dir.pwd)
@@ -160,6 +167,12 @@ module Scryer
160
167
  "Write a report to PATH (repeatable). Format is inferred from the " \
161
168
  "extension: .json, .html, or .csv.") { |v| options[:outputs] << v }
162
169
  opts.on("-p PATH", "--path PATH", "Root directory to scan (default: current directory).") { |v| options[:path] = v }
170
+ opts.on("-r PATH", "--require PATH",
171
+ "Require a Ruby file before scanning (repeatable) — the file can call " \
172
+ "Scryer.configure to set c.ai_client, c.skip_rules, c.dirs, etc. This is the " \
173
+ "standalone executable's equivalent of a Rails app's config/initializers/scryer.rb " \
174
+ "getting autoloaded at boot; without it there's no way to configure anything " \
175
+ "outside Rails.") { |v| (options[:require] ||= []) << v }
163
176
  opts.on("--audit-deps",
164
177
  "Check Gemfile.lock for known-vulnerable gems (via OSV.dev — needs network) and " \
165
178
  "insecure git/http sources (offline), instead of running the normal static scan. " \
@@ -353,7 +353,7 @@ module Scryer
353
353
  </div>
354
354
  <p>#{escape(f["message"])}</p>
355
355
  #{f["code_snippet"] ? "<pre>#{escape(f["code_snippet"])}</pre>" : ""}
356
- <div class="fix"><strong>Suggested fix:</strong> #{escape(f["suggested_fix"])}</div>
356
+ <div class="fix"><strong>Suggested fix:</strong>#{render_markdown(f["suggested_fix"])}</div>
357
357
  </div>
358
358
  ROW
359
359
  end
@@ -421,7 +421,7 @@ module Scryer
421
421
  </div>
422
422
  <p>#{escape(f["message"])}</p>
423
423
  #{patched.empty? ? "" : "<p class=\"loc\">Patched version(s): #{escape(patched.join(", "))}</p>"}
424
- <div class="fix"><strong>Suggested fix:</strong> #{escape(f["suggested_fix"])}</div>
424
+ <div class="fix"><strong>Suggested fix:</strong>#{render_markdown(f["suggested_fix"])}</div>
425
425
  </div>
426
426
  ROW
427
427
  end
@@ -430,6 +430,48 @@ module Scryer
430
430
  text.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
431
431
  end
432
432
 
433
+ CODE_FENCE = /```\w*\n?(.*?)```/m
434
+ BOLD = /\*\*(.+?)\*\*/
435
+ INLINE_CODE = /`([^`]+?)`/
436
+
437
+ # Suggested-fix text is always human-written prose, whether it came from
438
+ # a rule's static template (occasional inline `code`) or an LLM rewrite
439
+ # (often full Markdown: **bold**, ```fenced code```, paragraphs) — see
440
+ # AiFixSuggester's prompts, which explicitly ask for a
441
+ # "before/after code example". Rendering it as one plain escaped string
442
+ # left literal ** and ``` visible in the HTML report instead of actual
443
+ # formatting. This renders the handful of constructs those two sources
444
+ # actually produce; anything unrecognized just passes through as escaped
445
+ # text, same as before.
446
+ #
447
+ # Every code path here escapes raw text *before* adding any HTML tags of
448
+ # its own, so nothing in `text` (however untrusted — this may be verbatim
449
+ # LLM output) can inject markup; the only unescaped HTML is the literal
450
+ # tag strings this method writes itself.
451
+ def render_markdown(text)
452
+ return "" if text.nil?
453
+
454
+ html = +""
455
+ pos = 0
456
+ text.to_s.scan(CODE_FENCE) do
457
+ match = Regexp.last_match
458
+ html << render_prose(text[pos...match.begin(0)])
459
+ html << "<pre>#{escape(match[1].strip)}</pre>"
460
+ pos = match.end(0)
461
+ end
462
+ html << render_prose(text[pos..])
463
+ html
464
+ end
465
+
466
+ def render_prose(text)
467
+ return "" if text.nil? || text.strip.empty?
468
+
469
+ text.strip.split(/\n{2,}/).map do |paragraph|
470
+ formatted = escape(paragraph.strip).gsub(BOLD, '<strong>\1</strong>').gsub(INLINE_CODE, '<code>\1</code>')
471
+ "<p>#{formatted.gsub("\n", "<br>")}</p>"
472
+ end.join
473
+ end
474
+
433
475
  def static_csv_row(f)
434
476
  [
435
477
  f["category"], f["rule_id"], f["severity"],
@@ -474,7 +516,13 @@ module Scryer
474
516
  .badge.kind { background: #ede9fe; color: #5b21b6; }
475
517
  .loc { color: #64748b; font-size: 0.8rem; }
476
518
  pre { background: #0f172a; color: #e2e8f0; padding: 0.5rem 0.75rem; border-radius: 6px; overflow-x: auto; font-size: 0.8rem; }
519
+ code { background: #e2e8f0; color: #334155; padding: 0.1rem 0.35rem; border-radius: 4px; font-size: 0.85em; }
477
520
  .fix { background: #eef2ff; border-radius: 6px; padding: 0.5rem 0.75rem; font-size: 0.875rem; }
521
+ .fix > strong { display: block; margin-bottom: 0.35rem; }
522
+ .fix p { margin: 0 0 0.5rem; }
523
+ .fix p:last-child { margin-bottom: 0; }
524
+ .fix pre { margin: 0.5rem 0 0; }
525
+ .fix pre:last-child { margin-bottom: 0; }
478
526
  .dup-member { margin-top: 0.5rem; }
479
527
  .dup-member:first-child { margin-top: 0; }
480
528
  .muted { color: #94a3b8; }
@@ -1,3 +1,3 @@
1
1
  module Scryer
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: scryer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ram Laxman Yadav