sharekit-cli 0.1.0 → 0.2.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 +13 -0
- data/README.md +56 -6
- data/docs/adr/2026-07-26-redact-always-ai-triage.md +57 -0
- data/lib/sharekit/cli/finding.rb +9 -1
- data/lib/sharekit/cli/redactor.rb +71 -0
- data/lib/sharekit/cli/reporter.rb +21 -4
- data/lib/sharekit/cli/triage.rb +166 -0
- data/lib/sharekit/cli/version.rb +1 -1
- data/lib/sharekit/cli.rb +47 -7
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7b29c0f30ebaaf2763d471c1ef587cc8fa1d6d32e1a1964ea6749f9b6e9fde48
|
|
4
|
+
data.tar.gz: 2afc9c73934a31825c8ef0e0531ab85b8e386cebf9daf0f2ec12bb34f994fce4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7e8314ef9e2cc42af165c8d18c9b6e1aced24c1de9a9ad7bec43a491d7c0f5929f479d939c8073c4739a2884a89f594a869ac0b47f6ef577074f0ce8e8e14357
|
|
7
|
+
data.tar.gz: af337d25884e2fce90ee4dc98855d1be0f6dee87c25c5c08064594f9d2fd1e107f54fed9e7bfc92a46656836863d5f291273e41dd6a7a0897c756b6c95696e8a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.2.0] - 2026-07-26
|
|
4
|
+
|
|
5
|
+
- `scan --ai-triage` labels each finding `true_positive`, `false_positive` or
|
|
6
|
+
`uncertain` with a confidence and a one-line rationale, via RubyLLM structured
|
|
7
|
+
output. Works against any RubyLLM provider; `--provider ollama` needs no setup.
|
|
8
|
+
- `ruby_llm` is an **optional** dependency, not a runtime one: `scan` alone pulls
|
|
9
|
+
nothing new, and `--ai-triage` without it reports how to install it. Run
|
|
10
|
+
`gem install ruby_llm` to enable triage.
|
|
11
|
+
- Secret values are never transmitted, and there is no opt-out. Findings are
|
|
12
|
+
reduced to their shape (length plus Shannon entropy) before the prompt is
|
|
13
|
+
built. See `docs/adr/2026-07-26-redact-always-ai-triage.md`.
|
|
14
|
+
- Verdicts are advisory: the high-severity gate ignores them.
|
|
15
|
+
|
|
3
16
|
## [0.1.0] - 2026-07-25
|
|
4
17
|
|
|
5
18
|
- Initial release
|
data/README.md
CHANGED
|
@@ -20,6 +20,13 @@ idioms that don't have a clean TS equivalent:
|
|
|
20
20
|
without the scanner caring which.
|
|
21
21
|
- **`case/in` pattern matching** in `Reporter.format_line`, matching directly on the `Finding`'s
|
|
22
22
|
deconstructed fields instead of manual attribute access.
|
|
23
|
+
- **`Data#with` for the triage pass.** Verdicts are layered onto findings by returning new
|
|
24
|
+
`Finding`s, so the scan result is never mutated and an untriaged finding is structurally
|
|
25
|
+
the same object it always was. The triage fields default to `nil` via a keyword-splat
|
|
26
|
+
`super`, which is how `Data.define` takes defaults at all.
|
|
27
|
+
- **`autoload` for the AI path.** `require "ruby_llm"` pulls in a provider stack that a plain
|
|
28
|
+
`scan` never touches, so `Triage` is resolved on first reference. One stdlib line instead of
|
|
29
|
+
an optional-dependency dance.
|
|
23
30
|
- **Thor** for CLI dispatch instead of hand-parsed `ARGV`/flags.
|
|
24
31
|
- **`git ls-files`, not a hand-rolled ignore-file parser.** `scan` defers to git for
|
|
25
32
|
`.gitignore`-aware file listing when run inside a repo (via `Open3`, argv-array — no shell
|
|
@@ -27,17 +34,14 @@ idioms that don't have a clean TS equivalent:
|
|
|
27
34
|
|
|
28
35
|
## Install
|
|
29
36
|
|
|
30
|
-
Not yet published to RubyGems. Install straight from git:
|
|
31
|
-
|
|
32
37
|
```bash
|
|
33
|
-
gem install
|
|
34
|
-
gem specific_install https://github.com/LucasSantana-Dev/sharekit-cli.git
|
|
38
|
+
gem install sharekit-cli
|
|
35
39
|
```
|
|
36
40
|
|
|
37
41
|
Or add to a `Gemfile`:
|
|
38
42
|
|
|
39
43
|
```ruby
|
|
40
|
-
gem "sharekit-cli"
|
|
44
|
+
gem "sharekit-cli"
|
|
41
45
|
```
|
|
42
46
|
|
|
43
47
|
## Usage
|
|
@@ -67,6 +71,52 @@ otherwise. `init` copies `~/.claude/CLAUDE.md` and `~/.cursor/.cursorrules` into
|
|
|
67
71
|
(or writes placeholders if they don't exist) and scans everything it copies for secrets before
|
|
68
72
|
reporting success.
|
|
69
73
|
|
|
74
|
+
### AI triage
|
|
75
|
+
|
|
76
|
+
`--ai-triage` labels each finding as a real leak, a placeholder, or unclear, so a docs
|
|
77
|
+
sample stops looking exactly like a live key.
|
|
78
|
+
|
|
79
|
+
It needs one extra gem, which `scan` itself does not:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
gem install ruby_llm
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
That is deliberate. A secret scanner shouldn't drag a provider stack into every install, so
|
|
86
|
+
`ruby_llm` is optional: `sharekit-cli scan` never loads it, and `--ai-triage` without it fails
|
|
87
|
+
with instructions rather than a `LoadError`.
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
sharekit-cli scan --ai-triage # any configured provider
|
|
91
|
+
sharekit-cli scan --ai-triage --provider ollama --model qwen3:4b \
|
|
92
|
+
--assume-model-exists # fully local
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
⚠ Secret patterns detected:
|
|
97
|
+
config/secrets.env:1 [AWS Access Key ID] …_KEY=AKIAEXAMPLEKEY000000
|
|
98
|
+
→ likely real (95% confident) Config path, not a test or docs fixture.
|
|
99
|
+
docs/example.md:3 [AWS Access Key ID] …ple `AKIAIOSFODNN7EXAMPLE…
|
|
100
|
+
→ likely placeholder (98% confident) Documentation path, context says "for example".
|
|
101
|
+
|
|
102
|
+
⚠ AI verdicts are advisory: the high-severity gate ignores them.
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Secret values are never sent, and there is no flag to send them.** Before a prompt is
|
|
106
|
+
built, each finding's line is re-read from disk and every secret in it is replaced by its
|
|
107
|
+
shape alone, as `[REDACTED chars=20 entropy=3.8]`. What the model gets is the rule name,
|
|
108
|
+
the path, the line number, that shape, and whatever context survived. Redaction runs on the
|
|
109
|
+
whole line rather than the truncated `preview` for a reason: truncation can cut a secret so
|
|
110
|
+
that its own rule no longer matches it, and the fragment would survive. Reasoning is in
|
|
111
|
+
[the ADR](docs/adr/2026-07-26-redact-always-ai-triage.md).
|
|
112
|
+
|
|
113
|
+
Credentials come from `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`. `--provider ollama` needs
|
|
114
|
+
neither and defaults to `http://localhost:11434/v1` (override with `OLLAMA_API_BASE`), which
|
|
115
|
+
keeps even the redacted shape on your machine.
|
|
116
|
+
|
|
117
|
+
Verdicts annotate; they never unblock. A model deciding a live key isn't a key is not a
|
|
118
|
+
failure mode worth having, so the gate stays independent of it.
|
|
119
|
+
|
|
70
120
|
### Rules
|
|
71
121
|
|
|
72
122
|
| Rule | Severity |
|
|
@@ -84,7 +134,7 @@ reporting success.
|
|
|
84
134
|
|
|
85
135
|
```bash
|
|
86
136
|
bin/setup # install dependencies
|
|
87
|
-
bundle exec rspec # run the test suite (
|
|
137
|
+
bundle exec rspec # run the test suite (92 examples)
|
|
88
138
|
bundle exec rubocop # lint
|
|
89
139
|
bundle exec bundler-audit check --update # dependency vulnerability scan
|
|
90
140
|
bin/console # interactive prompt
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Redact always in AI triage
|
|
2
|
+
|
|
3
|
+
- Status: accepted
|
|
4
|
+
- Date: 2026-07-26
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
`scan` is rule-driven, so it reports what a regex matched, not what the match
|
|
9
|
+
means. `AKIAIOSFODNN7EXAMPLE` in `docs/example.md` and a live key in
|
|
10
|
+
`config/secrets.env` produce the same high-severity finding, and the gate blocks
|
|
11
|
+
on both. Classifying findings needs a model, which means deciding what a secret
|
|
12
|
+
scanner is allowed to transmit about the secrets it just found.
|
|
13
|
+
|
|
14
|
+
The tool's whole promise is that it runs before you publish something. Sending
|
|
15
|
+
credentials to a third-party API in order to ask whether they are credentials
|
|
16
|
+
would invert that promise, and "we only send it when you pass `--raw`" still
|
|
17
|
+
ships a foot-gun in a security tool.
|
|
18
|
+
|
|
19
|
+
## Decision
|
|
20
|
+
|
|
21
|
+
Triage sends a finding's shape, never its value, with no opt-out.
|
|
22
|
+
|
|
23
|
+
- Every payload line is passed through `Redactor` first. A masked token is
|
|
24
|
+
replaced by its length and Shannon entropy, e.g. `[REDACTED chars=20
|
|
25
|
+
entropy=3.8]`.
|
|
26
|
+
- Redaction runs against the line re-read from disk, not the `Finding`'s
|
|
27
|
+
`preview`. Truncation can cut a secret mid-token so its own rule no longer
|
|
28
|
+
matches it, and the surviving fragment would ride along into the prompt.
|
|
29
|
+
- Every `Scanner` rule is applied, not just the one that produced the finding,
|
|
30
|
+
so a second secret sharing the line is masked too. Sensitive env-var values are
|
|
31
|
+
masked by key name, and a generic backstop masks any remaining run of 20+
|
|
32
|
+
characters from the secret alphabet.
|
|
33
|
+
- `--provider ollama` works with no configuration, so the redacted shape can stay
|
|
34
|
+
on the machine as well.
|
|
35
|
+
- Verdicts are advisory. The high-severity gate ignores them.
|
|
36
|
+
|
|
37
|
+
## Consequences
|
|
38
|
+
|
|
39
|
+
Classification accuracy is lower than it would be with raw values: the model
|
|
40
|
+
judges from rule name, path, line number, entropy and surviving context. That
|
|
41
|
+
turned out to be enough on the cases that matter, because the strongest signal is
|
|
42
|
+
the path (`docs/`, `spec/`, `*.example`) rather than the characters.
|
|
43
|
+
|
|
44
|
+
The generic backstop is deliberately over-eager. It masks long URLs, hashes and
|
|
45
|
+
base64 in prose, which makes some prompts noisier than necessary. Safety over
|
|
46
|
+
prompt tidiness: the claim "nothing 20 characters or longer from the secret
|
|
47
|
+
alphabet survives" is only true if it applies to material no rule claimed.
|
|
48
|
+
|
|
49
|
+
Keeping the gate model-independent means a false `false_positive` cannot unblock
|
|
50
|
+
a publish. It also means triage does not reduce the work when the verdict is
|
|
51
|
+
right, it only tells you where to look first. Letting verdicts relax the gate is
|
|
52
|
+
the obvious next request; it stays refused.
|
|
53
|
+
|
|
54
|
+
## Revisit when
|
|
55
|
+
|
|
56
|
+
Someone needs triage to change the exit code, or a provider offers an
|
|
57
|
+
attestable-local guarantee strong enough to reconsider what may be transmitted.
|
data/lib/sharekit/cli/finding.rb
CHANGED
|
@@ -7,14 +7,22 @@ module Sharekit
|
|
|
7
7
|
# Immutable value object for one scan hit. Data.define gives us structural
|
|
8
8
|
# equality and Hash-pattern deconstruction (`in {severity:, rule:}`) for free —
|
|
9
9
|
# a plain TS interface has neither.
|
|
10
|
-
|
|
10
|
+
#
|
|
11
|
+
# The triage fields default to nil, so a scan-only run builds a Finding the
|
|
12
|
+
# same way it always did, and `Triage` layers verdicts on with `#with` rather
|
|
13
|
+
# than mutating anything.
|
|
14
|
+
Finding = Data.define(:rule, :file, :line, :preview, :severity,
|
|
15
|
+
:verdict, :confidence, :rationale) do
|
|
11
16
|
include Comparable
|
|
12
17
|
|
|
18
|
+
def initialize(verdict: nil, confidence: nil, rationale: nil, **) = super
|
|
19
|
+
|
|
13
20
|
def <=>(other)
|
|
14
21
|
SEVERITY_RANK.fetch(severity) <=> SEVERITY_RANK.fetch(other.severity)
|
|
15
22
|
end
|
|
16
23
|
|
|
17
24
|
def high? = severity == :high
|
|
25
|
+
def triaged? = !verdict.nil?
|
|
18
26
|
end
|
|
19
27
|
end
|
|
20
28
|
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "scanner"
|
|
4
|
+
|
|
5
|
+
module Sharekit
|
|
6
|
+
module Cli
|
|
7
|
+
# Strips secret material out of a line before it can leave the machine.
|
|
8
|
+
#
|
|
9
|
+
# Redaction runs against the *original* line, never a Finding's truncated
|
|
10
|
+
# preview: truncation can cut a secret mid-token so that the rule pattern no
|
|
11
|
+
# longer matches it, which would let the surviving fragment through.
|
|
12
|
+
#
|
|
13
|
+
# A masked secret is replaced by its shape (length + Shannon entropy), which
|
|
14
|
+
# is what a classifier actually needs to tell a live key from EXAMPLE_KEY.
|
|
15
|
+
module Redactor
|
|
16
|
+
# Backstop for high-entropy material no Scanner rule claims. Runs last, so
|
|
17
|
+
# rule-labelled hits keep their own mask.
|
|
18
|
+
GENERIC_SECRET_PATTERN = %r{[A-Za-z0-9+/=_-]{20,}}
|
|
19
|
+
MASK_PREFIX = "[REDACTED"
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# Masks every secret-shaped run in +line+: every Scanner rule, then
|
|
24
|
+
# sensitive env-var values, then the generic backstop. All rules run, not
|
|
25
|
+
# just the one that produced the finding, so a second secret sharing the
|
|
26
|
+
# line cannot ride along into a prompt.
|
|
27
|
+
def redact(line)
|
|
28
|
+
masked = Scanner::RULES.reduce(line) do |acc, rule|
|
|
29
|
+
acc.gsub(rule.pattern) { |hit| mask(hit) }
|
|
30
|
+
end
|
|
31
|
+
mask_env_values(masked).gsub(GENERIC_SECRET_PATTERN) { |hit| mask(hit) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def mask(secret)
|
|
35
|
+
"#{MASK_PREFIX} chars=#{secret.length} entropy=#{format("%.1f", entropy(secret))}]"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Shannon entropy in bits per character. Random tokens land near 4+ bits;
|
|
39
|
+
# placeholders like AKIAIOSFODNN7EXAMPLE sit noticeably lower.
|
|
40
|
+
def entropy(str)
|
|
41
|
+
return 0.0 if str.empty?
|
|
42
|
+
|
|
43
|
+
length = str.length.to_f
|
|
44
|
+
str.each_char.tally.values.sum do |count|
|
|
45
|
+
probability = count / length
|
|
46
|
+
-probability * Math.log2(probability)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Scanner's ENV_VAR_PATTERN ends in `(.*)$`, which swallows the rest of the line,
|
|
51
|
+
# so the first assignment is the only one it ever examines. That is fine for
|
|
52
|
+
# reporting one finding per line, but as a redaction pass it leaks: in
|
|
53
|
+
# `PATH=/usr/bin API_KEY=short` the boring first key short-circuits the check and
|
|
54
|
+
# the real secret is never masked, while being too short for the generic
|
|
55
|
+
# backstop. Redaction therefore matches one assignment at a time. Quoted forms
|
|
56
|
+
# are listed explicitly because a bare `\S*` stops at the space in `KEY="a b"`.
|
|
57
|
+
ENV_ASSIGNMENT_PATTERN = /(?:^|\s)(?:export\s+)?([A-Z_]+)=("[^"]*"|'[^']*'|\S*)/
|
|
58
|
+
|
|
59
|
+
def mask_env_values(line)
|
|
60
|
+
line.gsub(ENV_ASSIGNMENT_PATTERN) do |hit|
|
|
61
|
+
key = Regexp.last_match(1)
|
|
62
|
+
value = Regexp.last_match(2)
|
|
63
|
+
next hit unless Scanner::SENSITIVE_KEY_PATTERN.match?(key)
|
|
64
|
+
next hit if value.empty? || value.start_with?(MASK_PREFIX)
|
|
65
|
+
|
|
66
|
+
hit.sub(value, mask(value))
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -8,6 +8,10 @@ module Sharekit
|
|
|
8
8
|
YELLOW = "\e[33m"
|
|
9
9
|
RESET = "\e[0m"
|
|
10
10
|
|
|
11
|
+
VERDICT_LABELS = { "true_positive" => "likely real",
|
|
12
|
+
"false_positive" => "likely placeholder",
|
|
13
|
+
"uncertain" => "unclear" }.freeze
|
|
14
|
+
|
|
11
15
|
module_function
|
|
12
16
|
|
|
13
17
|
def report(findings, force: false)
|
|
@@ -16,19 +20,32 @@ module Sharekit
|
|
|
16
20
|
return
|
|
17
21
|
end
|
|
18
22
|
|
|
23
|
+
print_findings(findings)
|
|
24
|
+
gate!(findings, force:)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def print_findings(findings)
|
|
19
28
|
puts "#{YELLOW}\n ⚠ Secret patterns detected:#{RESET}"
|
|
20
29
|
findings.each { |finding| puts "#{YELLOW} #{format_line(finding)}#{RESET}" }
|
|
21
|
-
puts "#{YELLOW}\n ⚠ Review and redact secrets before pushing to a public repository
|
|
22
|
-
|
|
23
|
-
|
|
30
|
+
puts "#{YELLOW}\n ⚠ Review and redact secrets before pushing to a public repository.#{RESET}"
|
|
31
|
+
if findings.any?(&:triaged?)
|
|
32
|
+
puts "#{YELLOW} ⚠ AI verdicts are advisory: the high-severity gate ignores them.#{RESET}"
|
|
33
|
+
end
|
|
34
|
+
puts ""
|
|
24
35
|
end
|
|
25
36
|
|
|
26
37
|
# Data.define objects deconstruct into a Hash for free, so `in` can match
|
|
27
38
|
# directly on the fields we care about — no case/when + manual field access.
|
|
39
|
+
# The untriaged branch comes first, so a nil verdict never reaches the
|
|
40
|
+
# formatting that assumes one.
|
|
28
41
|
def format_line(finding)
|
|
29
42
|
case finding
|
|
30
|
-
in { file:, line:, rule:, preview: }
|
|
43
|
+
in { verdict: nil, file:, line:, rule:, preview: }
|
|
31
44
|
"#{file}:#{line} [#{rule}] #{preview}"
|
|
45
|
+
in { verdict:, confidence:, rationale:, file:, line:, rule:, preview: }
|
|
46
|
+
"#{file}:#{line} [#{rule}] #{preview}\n" \
|
|
47
|
+
" → #{VERDICT_LABELS.fetch(verdict, verdict)} " \
|
|
48
|
+
"(#{(confidence * 100).round}% confident) #{rationale}"
|
|
32
49
|
end
|
|
33
50
|
end
|
|
34
51
|
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
require "ruby_llm/schema"
|
|
5
|
+
require_relative "redactor"
|
|
6
|
+
|
|
7
|
+
module Sharekit
|
|
8
|
+
module Cli
|
|
9
|
+
# Classifies findings as real leaks or false positives with an LLM.
|
|
10
|
+
#
|
|
11
|
+
# SECURITY: the model never receives secret material, and there is no flag to
|
|
12
|
+
# opt out. Every payload line is rebuilt from the file on disk and passed
|
|
13
|
+
# through Redactor first, so what ships is the finding's *shape*: rule name,
|
|
14
|
+
# path, line number, length and entropy of the masked token, and whatever
|
|
15
|
+
# surrounding context survived redaction. Point --provider at Ollama to keep
|
|
16
|
+
# even that on the machine.
|
|
17
|
+
#
|
|
18
|
+
# Verdicts are advisory. Triage annotates output; it never relaxes the
|
|
19
|
+
# high-severity gate, because a model must not be what decides a live key
|
|
20
|
+
# isn't a key.
|
|
21
|
+
module Triage
|
|
22
|
+
VERDICTS = %w[true_positive false_positive uncertain].freeze
|
|
23
|
+
|
|
24
|
+
# Ollama's conventional local endpoint, so --provider ollama needs no setup.
|
|
25
|
+
OLLAMA_DEFAULT_BASE = "http://localhost:11434/v1"
|
|
26
|
+
|
|
27
|
+
RATIONALE_LIMIT = 120
|
|
28
|
+
|
|
29
|
+
SOURCE_UNAVAILABLE = "[source unavailable]"
|
|
30
|
+
|
|
31
|
+
# A missing API key or an unknown model id raise off a different branch of
|
|
32
|
+
# RubyLLM's hierarchy than provider/HTTP faults, and they are the two most
|
|
33
|
+
# likely failures here, so both branches are caught.
|
|
34
|
+
FAILURES = [RubyLLM::Error, RubyLLM::ConfigurationError,
|
|
35
|
+
RubyLLM::ModelNotFoundError, Faraday::Error].freeze
|
|
36
|
+
|
|
37
|
+
# Findings go over as an indexed list in one request, and verdicts are
|
|
38
|
+
# matched back by index. A verdict for an index the model skipped or
|
|
39
|
+
# invented is dropped rather than shifting every later finding's answer.
|
|
40
|
+
class Schema < RubyLLM::Schema
|
|
41
|
+
array :verdicts do
|
|
42
|
+
object do
|
|
43
|
+
integer :index, description: "index of the finding being judged"
|
|
44
|
+
string :verdict, enum: VERDICTS
|
|
45
|
+
number :confidence, description: "0.0 to 1.0"
|
|
46
|
+
string :rationale, description: "one sentence, at most #{RATIONALE_LIMIT} characters"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
SYSTEM_PROMPT = <<~PROMPT
|
|
52
|
+
You triage secret-scanner findings for a tool that runs before someone
|
|
53
|
+
publishes a directory. For each finding decide whether it is a real leaked
|
|
54
|
+
credential (true_positive), a placeholder, example, test fixture or
|
|
55
|
+
documentation sample (false_positive), or genuinely unclear (uncertain).
|
|
56
|
+
|
|
57
|
+
Secret values have been redacted before reaching you. A masked token shows
|
|
58
|
+
only its length and Shannon entropy, as
|
|
59
|
+
"[REDACTED chars=20 entropy=3.8]". Judge from the rule name, the file path,
|
|
60
|
+
the line number, and the surrounding context. High entropy and a config or
|
|
61
|
+
source path suggest a real credential; low entropy, or paths like
|
|
62
|
+
spec/, test/, fixtures/, docs/, README, *.example, *.sample suggest not.
|
|
63
|
+
|
|
64
|
+
Never claim to have read the secret. Reply for every index you are given.
|
|
65
|
+
PROMPT
|
|
66
|
+
|
|
67
|
+
module_function
|
|
68
|
+
|
|
69
|
+
# Returns a new array of findings, each with triage fields filled where the
|
|
70
|
+
# model answered. Findings the model skipped come back untouched.
|
|
71
|
+
def call(findings, **chat_options)
|
|
72
|
+
return findings if findings.empty?
|
|
73
|
+
|
|
74
|
+
verdicts = request(findings, **chat_options)
|
|
75
|
+
findings.each_with_index.map do |finding, index|
|
|
76
|
+
verdict = verdicts[index]
|
|
77
|
+
verdict ? finding.with(**verdict) : finding
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def request(findings, **chat_options)
|
|
82
|
+
configure
|
|
83
|
+
chat = RubyLLM.chat(**chat_options.compact)
|
|
84
|
+
.with_instructions(SYSTEM_PROMPT)
|
|
85
|
+
.with_schema(Schema)
|
|
86
|
+
# On 1.16 a schema-backed reply arrives parsed on #content (there is no
|
|
87
|
+
# #parsed), and stays a String if the provider returned unparseable JSON.
|
|
88
|
+
parse(chat.ask(prompt_for(findings)).content, findings.size)
|
|
89
|
+
rescue *FAILURES => e
|
|
90
|
+
raise Error, "AI triage failed: #{e.class}: #{e.message}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# RubyLLM reads no provider credentials from the environment on its own,
|
|
94
|
+
# and a CLI has nowhere else to get them.
|
|
95
|
+
def configure
|
|
96
|
+
RubyLLM.configure do |config|
|
|
97
|
+
config.anthropic_api_key = ENV.fetch("ANTHROPIC_API_KEY", nil)
|
|
98
|
+
config.openai_api_key = ENV.fetch("OPENAI_API_KEY", nil)
|
|
99
|
+
config.ollama_api_base = ENV.fetch("OLLAMA_API_BASE", OLLAMA_DEFAULT_BASE)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Rebuilds each finding's line from disk so redaction sees the whole line,
|
|
104
|
+
# never the already-truncated preview.
|
|
105
|
+
def prompt_for(findings)
|
|
106
|
+
lines = Hash.new { |cache, path| cache[path] = read_lines(path) }
|
|
107
|
+
|
|
108
|
+
findings.each_with_index.map { |finding, index| entry(finding, index, lines) }.join("\n")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# There is deliberately no fallback to `finding.preview` when the file cannot be
|
|
112
|
+
# read: the preview is truncated, and a fragment cut mid-token matches neither its
|
|
113
|
+
# own rule nor the generic backstop, which is the exact leak reading from disk
|
|
114
|
+
# exists to prevent. Losing one finding's context is the cheaper failure. The path
|
|
115
|
+
# is redacted too, so a token embedded in a filename cannot ride along.
|
|
116
|
+
def entry(finding, index, lines)
|
|
117
|
+
raw = lines[finding.file]&.[](finding.line - 1)
|
|
118
|
+
<<~ENTRY
|
|
119
|
+
#{index}. rule=#{finding.rule} severity=#{finding.severity}
|
|
120
|
+
location=#{Redactor.redact(finding.file)}:#{finding.line}
|
|
121
|
+
context=#{raw ? Redactor.redact(raw.chomp) : SOURCE_UNAVAILABLE}
|
|
122
|
+
ENTRY
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def read_lines(path)
|
|
126
|
+
File.readlines(path, encoding: "UTF-8")
|
|
127
|
+
rescue ArgumentError, SystemCallError
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def parse(content, count)
|
|
132
|
+
return {} unless content.is_a?(Hash)
|
|
133
|
+
|
|
134
|
+
Array(content["verdicts"]).each_with_object({}) do |row, acc|
|
|
135
|
+
index = verdict_index(row, count)
|
|
136
|
+
next unless index
|
|
137
|
+
|
|
138
|
+
acc[index] = { verdict: row["verdict"],
|
|
139
|
+
confidence: row["confidence"].to_f.clamp(0.0, 1.0),
|
|
140
|
+
rationale: rationale(row["rationale"]) }
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Models routinely blow past the length the schema asks for, and these land
|
|
145
|
+
# in a terminal one line per finding, so the limit is enforced here too.
|
|
146
|
+
def rationale(raw)
|
|
147
|
+
text = raw.to_s.strip
|
|
148
|
+
return text if text.length <= RATIONALE_LIMIT
|
|
149
|
+
|
|
150
|
+
"#{text[0, RATIONALE_LIMIT - 1]}…"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Only an in-range index paired with a label we asked for is usable; anything
|
|
154
|
+
# else is dropped so it cannot shift another finding's verdict.
|
|
155
|
+
def verdict_index(row, count)
|
|
156
|
+
return unless row.is_a?(Hash)
|
|
157
|
+
|
|
158
|
+
index = row["index"]
|
|
159
|
+
return unless index.is_a?(Integer) && index.between?(0, count - 1)
|
|
160
|
+
return unless VERDICTS.include?(row["verdict"])
|
|
161
|
+
|
|
162
|
+
index
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
data/lib/sharekit/cli/version.rb
CHANGED
data/lib/sharekit/cli.rb
CHANGED
|
@@ -9,9 +9,15 @@ require_relative "cli/file_lister"
|
|
|
9
9
|
require_relative "cli/init"
|
|
10
10
|
|
|
11
11
|
module Sharekit
|
|
12
|
+
# Command-line entry points for scanning a directory for leaked secrets and
|
|
13
|
+
# scaffolding a publishable profile.
|
|
12
14
|
module Cli
|
|
13
15
|
class Error < StandardError; end
|
|
14
16
|
|
|
17
|
+
# Loading ruby_llm pulls in a provider stack that a plain `scan` never needs,
|
|
18
|
+
# so Triage is resolved on first reference instead of at boot.
|
|
19
|
+
autoload :Triage, "sharekit/cli/triage"
|
|
20
|
+
|
|
15
21
|
# Thor gives us subcommand dispatch, flag parsing, and --help for free —
|
|
16
22
|
# the TS original hand-parses process.argv/flags itself.
|
|
17
23
|
class App < Thor
|
|
@@ -22,14 +28,23 @@ module Sharekit
|
|
|
22
28
|
type: :boolean,
|
|
23
29
|
default: false,
|
|
24
30
|
desc: "exit 0 even if high-severity findings detected"
|
|
31
|
+
method_option :ai_triage,
|
|
32
|
+
type: :boolean,
|
|
33
|
+
default: false,
|
|
34
|
+
desc: "label findings true/false positive with an LLM (secret values are redacted first)"
|
|
35
|
+
method_option :model,
|
|
36
|
+
type: :string,
|
|
37
|
+
desc: "model id for --ai-triage (default: RubyLLM's configured default)"
|
|
38
|
+
method_option :provider,
|
|
39
|
+
type: :string,
|
|
40
|
+
desc: "provider for --ai-triage, e.g. anthropic, openai, ollama"
|
|
41
|
+
method_option :assume_model_exists,
|
|
42
|
+
type: :boolean,
|
|
43
|
+
default: false,
|
|
44
|
+
desc: "skip model-registry validation, needed for local Ollama models"
|
|
25
45
|
def scan(dir = ".")
|
|
26
|
-
findings =
|
|
27
|
-
|
|
28
|
-
rescue ArgumentError, Errno::ENOENT, Errno::EACCES => e
|
|
29
|
-
puts " ~ Skipped #{path}: #{e.class}"
|
|
30
|
-
[]
|
|
31
|
-
end
|
|
32
|
-
|
|
46
|
+
findings = collect_findings(dir)
|
|
47
|
+
findings = triage(findings) if options[:ai_triage]
|
|
33
48
|
Reporter.report(findings, force: options[:force])
|
|
34
49
|
rescue Error => e
|
|
35
50
|
warn e.message
|
|
@@ -55,6 +70,31 @@ module Sharekit
|
|
|
55
70
|
end
|
|
56
71
|
|
|
57
72
|
no_commands do
|
|
73
|
+
def collect_findings(dir)
|
|
74
|
+
FileLister.list(dir).flat_map do |path|
|
|
75
|
+
Scanner.scan(File.read(path, encoding: "UTF-8"), file: path).to_a
|
|
76
|
+
rescue ArgumentError, Errno::ENOENT, Errno::EACCES => e
|
|
77
|
+
puts " ~ Skipped #{path}: #{e.class}"
|
|
78
|
+
[]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def triage(findings)
|
|
83
|
+
return findings if findings.empty?
|
|
84
|
+
|
|
85
|
+
puts "\n … AI triage: #{findings.size} finding(s), secret values redacted before sending"
|
|
86
|
+
Triage.call(findings,
|
|
87
|
+
model: options[:model],
|
|
88
|
+
provider: options[:provider]&.to_sym,
|
|
89
|
+
assume_model_exists: options[:assume_model_exists])
|
|
90
|
+
rescue LoadError => e
|
|
91
|
+
# ruby_llm is optional: a scan-only install should not have to carry a
|
|
92
|
+
# provider stack. Autoloading Triage is what surfaces its absence here.
|
|
93
|
+
raise Error, "--ai-triage needs the ruby_llm gem, which is not installed. " \
|
|
94
|
+
"Install it with `gem install ruby_llm` (or add it to your Gemfile). " \
|
|
95
|
+
"(#{e.message})"
|
|
96
|
+
end
|
|
97
|
+
|
|
58
98
|
def print_init_summary(result)
|
|
59
99
|
result.created.each { |path| puts " + #{path}" }
|
|
60
100
|
puts "\n ✓ Created profile at #{result.profile_dir}\n\n"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: sharekit-cli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lucas Santana
|
|
@@ -38,13 +38,16 @@ files:
|
|
|
38
38
|
- LICENSE.txt
|
|
39
39
|
- README.md
|
|
40
40
|
- Rakefile
|
|
41
|
+
- docs/adr/2026-07-26-redact-always-ai-triage.md
|
|
41
42
|
- exe/sharekit-cli
|
|
42
43
|
- lib/sharekit/cli.rb
|
|
43
44
|
- lib/sharekit/cli/file_lister.rb
|
|
44
45
|
- lib/sharekit/cli/finding.rb
|
|
45
46
|
- lib/sharekit/cli/init.rb
|
|
47
|
+
- lib/sharekit/cli/redactor.rb
|
|
46
48
|
- lib/sharekit/cli/reporter.rb
|
|
47
49
|
- lib/sharekit/cli/scanner.rb
|
|
50
|
+
- lib/sharekit/cli/triage.rb
|
|
48
51
|
- lib/sharekit/cli/version.rb
|
|
49
52
|
- sig/sharekit/cli.rbs
|
|
50
53
|
homepage: https://github.com/LucasSantana-Dev/sharekit-cli
|