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 +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +44 -13
- data/lib/scryer/cli.rb +13 -0
- data/lib/scryer/report_renderer.rb +50 -2
- data/lib/scryer/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9cd7338bfd390fb74f2a1fae54f3132250ae75bd721bc1f65c42f9b80899e2e8
|
|
4
|
+
data.tar.gz: 984d8060977af0b21d2740384664350f9ee2b08e846bbdf71e3f069af40db386
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
464
|
-
|
|
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
|
-
|
|
470
|
-
|
|
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
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
|
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
|
|
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("&", "&").gsub("<", "<").gsub(">", ">")
|
|
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; }
|
data/lib/scryer/version.rb
CHANGED