leakless 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bd6f5b211ad396db44eaf4b45609debe5f7ae536ba35578e0c17cee12dcd612d
4
+ data.tar.gz: 120fd84a65818fcf740ed1d4e94c5b7e7d3b0a458d0390d9c2b901851993c5ce
5
+ SHA512:
6
+ metadata.gz: dfe5a15dddb34990e7ee5cbbc5cad90bbd8c13c5bf84ab1685c793e375f16259784feb3c8dff35e8b4c16d9d1cd49ec0261b5bbb98e62627b0f9d4daefb41e0f
7
+ data.tar.gz: 8c593ba6521db2f2f1e48ec81237c371435c3566fe759ad199983281ea6ef356efe8ee36816d6998ef878c86880025a66cd4c8addeb070f04a63129ad10dea94
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.4.10
data/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.3.0] - 2026-07-27
4
+
5
+ - Gem renamed from `sharekit-cli` to `leakless`. Module `Sharekit::Cli` is now
6
+ `Leakless::Cli`, the executable is now `leakless`, and the repository moved to
7
+ `github.com/LucasSantana-Dev/leakless`. No behavior changes. `sharekit-cli`
8
+ 0.2.0 remains on RubyGems as the final release under the old name.
9
+
10
+ ## [0.2.0] - 2026-07-26
11
+
12
+ - `scan --ai-triage` labels each finding `true_positive`, `false_positive` or
13
+ `uncertain` with a confidence and a one-line rationale, via RubyLLM structured
14
+ output. Works against any RubyLLM provider; `--provider ollama` needs no setup.
15
+ - `ruby_llm` is an **optional** dependency, not a runtime one: `scan` alone pulls
16
+ nothing new, and `--ai-triage` without it reports how to install it. Run
17
+ `gem install ruby_llm` to enable triage.
18
+ - Secret values are never transmitted, and there is no opt-out. Findings are
19
+ reduced to their shape (length plus Shannon entropy) before the prompt is
20
+ built. See `docs/adr/2026-07-26-redact-always-ai-triage.md`.
21
+ - Verdicts are advisory: the high-severity gate ignores them.
22
+
23
+ ## [0.1.0] - 2026-07-25
24
+
25
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Lucas Santana
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # leakless
2
+
3
+ A small CLI that scaffolds a publishable "AI coding setup" profile and scans a directory for
4
+ leaked secrets — private keys, cloud/API tokens, sensitive env vars — blocking on high-severity
5
+ findings unless you pass `--force`.
6
+
7
+ It's a Ruby port of the `init` and `scan` commands from
8
+ [sharekit](https://github.com/LucasSantana-Dev/sharekit), a TypeScript CLI I built and publish
9
+ to npm. This port is deliberately not a line-for-line translation — it's rebuilt around Ruby
10
+ idioms that don't have a clean TS equivalent:
11
+
12
+ - **`Data.define`** for the immutable `Finding` value object, with `Comparable` mixed in for
13
+ free severity-ranked sorting, and free `Hash` deconstruction for pattern matching.
14
+ - **A rule table, not an if/elsif chain.** Each secret pattern is a `Scanner::Rule` (a `Data`
15
+ object: pattern + severity + preview metadata), stored in a frozen array. Adding a rule means
16
+ appending to `RULES` — the scan loop itself never changes.
17
+ - **`Enumerator` dual API.** `Scanner.scan` yields to a block when one is given, or returns an
18
+ `Enumerator` when it isn't (`return to_enum(:scan, ...) unless block_given?`) — the same
19
+ pattern as `Array#each`. Callers can stream (`each { }`) or collect (`.to_a`, `.first`, `.lazy`)
20
+ without the scanner caring which.
21
+ - **`case/in` pattern matching** in `Reporter.format_line`, matching directly on the `Finding`'s
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.
30
+ - **Thor** for CLI dispatch instead of hand-parsed `ARGV`/flags.
31
+ - **`git ls-files`, not a hand-rolled ignore-file parser.** `scan` defers to git for
32
+ `.gitignore`-aware file listing when run inside a repo (via `Open3`, argv-array — no shell
33
+ interpolation), instead of reimplementing gitignore match semantics.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ gem install leakless
39
+ ```
40
+
41
+ Or add to a `Gemfile`:
42
+
43
+ ```ruby
44
+ gem "leakless"
45
+ ```
46
+
47
+ ## Usage
48
+
49
+ ```bash
50
+ leakless scan [DIR] # scan a directory (default: .)
51
+ leakless scan [DIR] --force # don't block on high-severity findings
52
+
53
+ leakless init [SKILL...] # scaffold ./sharekit-profile
54
+ leakless init --dir PATH [SKILL...] # scaffold at a custom path
55
+ leakless init --force # overwrite an existing dir; override secret blocking
56
+ ```
57
+
58
+ ```
59
+ $ leakless scan .
60
+
61
+ ⚠ Secret patterns detected:
62
+ .env:1 [AWS Access Key ID] …Y=AKIAEXAMPLEKEY000000
63
+
64
+ ⚠ Review and redact secrets before pushing to a public repository.
65
+
66
+ Secrets export blocked: 1 high-severity finding(s) detected. Review and remove secrets, or re-run with --force to override.
67
+ ```
68
+
69
+ Both commands exit `1` when a high-severity secret is found and `--force` wasn't passed; `0`
70
+ otherwise. `init` copies `~/.claude/CLAUDE.md` and `~/.cursor/.cursorrules` into the new profile
71
+ (or writes placeholders if they don't exist) and scans everything it copies for secrets before
72
+ reporting success.
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: `leakless scan` never loads it, and `--ai-triage` without it fails
87
+ with instructions rather than a `LoadError`.
88
+
89
+ ```bash
90
+ leakless scan --ai-triage # any configured provider
91
+ leakless 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
+
120
+ ### Rules
121
+
122
+ | Rule | Severity |
123
+ |--------------------------------|----------|
124
+ | Private Key Block | high |
125
+ | AWS Access Key ID | high |
126
+ | GitHub Personal Access Token | high |
127
+ | Slack Token | high |
128
+ | Google API Key | high |
129
+ | Bearer Token (JWT) | high |
130
+ | Home Directory Path Leak | low |
131
+ | Env Var: Sensitive Key | medium |
132
+
133
+ ## Development
134
+
135
+ ```bash
136
+ bin/setup # install dependencies
137
+ bundle exec rspec # run the test suite (92 examples)
138
+ bundle exec rubocop # lint
139
+ bundle exec bundler-audit check --update # dependency vulnerability scan
140
+ bin/console # interactive prompt
141
+ ```
142
+
143
+ ## License
144
+
145
+ MIT.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -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/exe/leakless ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "leakless"
5
+
6
+ Leakless::Cli::App.start(ARGV)
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Leakless
6
+ module Cli
7
+ # Picks which files a scan should look at. Inside a git repo, defers to
8
+ # `git ls-files` (tracked + untracked-but-not-ignored) so .gitignore is
9
+ # respected for free instead of reimplementing its match semantics.
10
+ # Outside a git repo, falls back to a plain glob with noise directories
11
+ # excluded.
12
+ module FileLister
13
+ DEFAULT_EXCLUDED_DIRS = %w[.git node_modules vendor .bundle].freeze
14
+
15
+ module_function
16
+
17
+ def list(dir)
18
+ git_tracked_files(dir) || glob_files(dir)
19
+ end
20
+
21
+ # Returns nil (not an empty array) when +dir+ isn't inside a git repo or
22
+ # git isn't installed, so the caller falls back to glob_files instead of
23
+ # silently scanning nothing.
24
+ def git_tracked_files(dir)
25
+ out, status = Open3.capture2("git", "-C", dir, "ls-files", "--cached", "--others", "--exclude-standard")
26
+ return nil unless status.success?
27
+
28
+ out.lines.map { |line| File.join(dir, line.chomp) }
29
+ rescue Errno::ENOENT
30
+ nil
31
+ end
32
+
33
+ def glob_files(dir)
34
+ Dir.glob("#{dir}/**/*").select { |path| File.file?(path) && !excluded?(path) }
35
+ end
36
+
37
+ def excluded?(path)
38
+ DEFAULT_EXCLUDED_DIRS.any? { |dir_name| path.split(File::SEPARATOR).include?(dir_name) }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Leakless
4
+ module Cli
5
+ SEVERITY_RANK = { high: 2, medium: 1, low: 0 }.freeze
6
+
7
+ # Immutable value object for one scan hit. Data.define gives us structural
8
+ # equality and Hash-pattern deconstruction (`in {severity:, rule:}`) for free —
9
+ # a plain TS interface has neither.
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
16
+ include Comparable
17
+
18
+ def initialize(verdict: nil, confidence: nil, rationale: nil, **) = super
19
+
20
+ def <=>(other)
21
+ SEVERITY_RANK.fetch(severity) <=> SEVERITY_RANK.fetch(other.severity)
22
+ end
23
+
24
+ def high? = severity == :high
25
+ def triaged? = !verdict.nil?
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Leakless
6
+ module Cli
7
+ # Scaffolds a publishable sharekit profile: sharekit.toml, a claude/
8
+ # CLAUDE.md, a cursor/.cursorrules, an empty shared/, and any requested
9
+ # skill directories — copied from the caller's own AI-tooling config, so
10
+ # the result becomes an install target for `sharekit install <user>`.
11
+ class Init
12
+ Result = Data.define(:profile_dir, :findings, :created)
13
+ SourceFile = Data.define(:source, :dest, :placeholder)
14
+
15
+ def self.call(profile_dir:, skill_names: [], source_root: ENV.fetch("HOME", Dir.pwd), force: false)
16
+ new(profile_dir:, skill_names:, source_root:, force:).call
17
+ end
18
+
19
+ def initialize(profile_dir:, skill_names:, source_root:, force:)
20
+ @profile_dir = profile_dir
21
+ @skill_names = skill_names
22
+ @source_root = source_root
23
+ @force = force
24
+ @findings = []
25
+ @created = []
26
+ end
27
+
28
+ STANDARD_FILES = [
29
+ SourceFile.new(source: [".claude", "CLAUDE.md"], dest: %w[claude CLAUDE.md],
30
+ placeholder: "# My AI coding instructions\n"),
31
+ SourceFile.new(source: [".cursor", ".cursorrules"], dest: %w[cursor .cursorrules],
32
+ placeholder: "# Cursor IDE rules\n")
33
+ ].freeze
34
+
35
+ def call
36
+ prepare_profile_dir
37
+ write_manifest
38
+ STANDARD_FILES.each { |file| scaffold(file) }
39
+ scaffold_shared
40
+ @skill_names.each { |name| scaffold_skill(name) }
41
+
42
+ Result.new(profile_dir: @profile_dir, findings: @findings, created: @created)
43
+ end
44
+
45
+ private
46
+
47
+ def prepare_profile_dir
48
+ if Dir.exist?(@profile_dir)
49
+ raise Error, "Profile directory already exists: #{@profile_dir}. Use --force to overwrite." unless @force
50
+
51
+ FileUtils.rm_rf(@profile_dir)
52
+ end
53
+ FileUtils.mkdir_p(@profile_dir)
54
+ end
55
+
56
+ def write_manifest
57
+ path = File.join(@profile_dir, "sharekit.toml")
58
+ File.write(path, <<~TOML)
59
+ [profile]
60
+ name = "#{ENV.fetch("USER", "unknown")}"
61
+ version = "0.1.0"
62
+ description = "My AI coding setup"
63
+ TOML
64
+ @created << path
65
+ end
66
+
67
+ def scaffold(file)
68
+ source = File.join(@source_root, *file.source)
69
+ dest = File.join(@profile_dir, *file.dest)
70
+ FileUtils.mkdir_p(File.dirname(dest))
71
+ if File.exist?(source)
72
+ FileUtils.cp(source, dest)
73
+ scan_and_record(dest)
74
+ else
75
+ File.write(dest, file.placeholder)
76
+ end
77
+ @created << dest
78
+ end
79
+
80
+ def scaffold_shared
81
+ dir = File.join(@profile_dir, "shared")
82
+ FileUtils.mkdir_p(dir)
83
+ FileUtils.touch(File.join(dir, ".gitkeep"))
84
+ @created << dir
85
+ end
86
+
87
+ def scaffold_skill(name)
88
+ source_dir = File.join(@source_root, ".claude", "skills", name)
89
+ unless Dir.exist?(source_dir)
90
+ puts " ~ skill '#{name}' not found at #{source_dir}"
91
+ return
92
+ end
93
+
94
+ dest_base = File.join(@profile_dir, "claude", "skills", name)
95
+ Dir.glob("#{source_dir}/**/*").select { |f| File.file?(f) }.each do |source_file|
96
+ copy_skill_file(source_file, source_dir, dest_base)
97
+ end
98
+ end
99
+
100
+ def copy_skill_file(source_file, source_dir, dest_base)
101
+ dest_file = File.join(dest_base, source_file.delete_prefix("#{source_dir}/"))
102
+ FileUtils.mkdir_p(File.dirname(dest_file))
103
+ FileUtils.cp(source_file, dest_file)
104
+ scan_and_record(dest_file)
105
+ @created << dest_file
106
+ end
107
+
108
+ def scan_and_record(path)
109
+ content = File.read(path, encoding: "UTF-8")
110
+ @findings.concat(Scanner.scan(content, file: path).to_a)
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "scanner"
4
+
5
+ module Leakless
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
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Leakless
4
+ module Cli
5
+ # Formats findings for terminal output and gates high-severity secrets.
6
+ module Reporter
7
+ GREEN = "\e[32m"
8
+ YELLOW = "\e[33m"
9
+ RESET = "\e[0m"
10
+
11
+ VERDICT_LABELS = { "true_positive" => "likely real",
12
+ "false_positive" => "likely placeholder",
13
+ "uncertain" => "unclear" }.freeze
14
+
15
+ module_function
16
+
17
+ def report(findings, force: false)
18
+ if findings.empty?
19
+ puts "#{GREEN} ✓ No secrets detected.\n#{RESET}"
20
+ return
21
+ end
22
+
23
+ print_findings(findings)
24
+ gate!(findings, force:)
25
+ end
26
+
27
+ def print_findings(findings)
28
+ puts "#{YELLOW}\n ⚠ Secret patterns detected:#{RESET}"
29
+ findings.each { |finding| puts "#{YELLOW} #{format_line(finding)}#{RESET}" }
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 ""
35
+ end
36
+
37
+ # Data.define objects deconstruct into a Hash for free, so `in` can match
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.
41
+ def format_line(finding)
42
+ case finding
43
+ in { verdict: nil, file:, line:, rule:, preview: }
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}"
49
+ end
50
+ end
51
+
52
+ def gate!(findings, force:)
53
+ high = findings.select(&:high?)
54
+ return if high.empty? || force
55
+
56
+ raise Error, "Secrets export blocked: #{high.size} high-severity finding(s) detected. " \
57
+ "Review and remove secrets, or re-run with --force to override."
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "finding"
4
+
5
+ module Leakless
6
+ module Cli
7
+ # Rule-driven secret scanner. Each rule is data (pattern + metadata), not a
8
+ # branch in an if/elsif chain — adding a rule means appending to RULES,
9
+ # never touching the scan loop.
10
+ class Scanner
11
+ Rule = Data.define(:name, :pattern, :severity, :context_before, :max_len, :leading_ellipsis)
12
+
13
+ RULES = [
14
+ Rule.new(name: "Private Key Block",
15
+ pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
16
+ severity: :high, context_before: 0, max_len: 40, leading_ellipsis: false),
17
+ Rule.new(name: "AWS Access Key ID",
18
+ pattern: /AKIA[0-9A-Z]{16}/,
19
+ severity: :high, context_before: 5, max_len: 20, leading_ellipsis: true),
20
+ Rule.new(name: "GitHub Personal Access Token",
21
+ pattern: /ghp_[A-Za-z0-9_]{20,}/,
22
+ severity: :high, context_before: 5, max_len: 40, leading_ellipsis: true),
23
+ Rule.new(name: "GitHub Personal Access Token",
24
+ pattern: /github_pat_[A-Za-z0-9_]{20,}/,
25
+ severity: :high, context_before: 5, max_len: 40, leading_ellipsis: true),
26
+ Rule.new(name: "Slack Token",
27
+ pattern: /xox[baprs]-[A-Za-z0-9-]{10,}/,
28
+ severity: :high, context_before: 5, max_len: 40, leading_ellipsis: true),
29
+ Rule.new(name: "Google API Key",
30
+ pattern: /AIza[0-9A-Za-z\-_]{30,40}/,
31
+ severity: :high, context_before: 5, max_len: 40, leading_ellipsis: true),
32
+ Rule.new(name: "Bearer Token",
33
+ pattern: /Bearer eyJ[A-Za-z0-9._-]*\.[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+/,
34
+ severity: :high, context_before: 0, max_len: 30, leading_ellipsis: false),
35
+ Rule.new(name: "Home Directory Path Leak",
36
+ pattern: %r{(/Users/[a-zA-Z0-9_-]+|/home/[a-zA-Z0-9_-]+)},
37
+ severity: :low, context_before: 5, max_len: 40, leading_ellipsis: true)
38
+ ].freeze
39
+
40
+ ENV_VAR_PATTERN = /(?:^|\s)(?:export\s+)?([A-Z_]+?)=(.*)$/
41
+ SENSITIVE_KEY_PATTERN = /(SECRET|TOKEN|PASSWORD|API_KEY|APIKEY|ACCESS_KEY)/i
42
+ PLACEHOLDER_PREFIXES = ['""', "''", "xxx", "<", "changeme", "your-", "your_"].freeze
43
+
44
+ class << self
45
+ # Scans +content+ line by line. Yields each Finding as it's found when
46
+ # a block is given; otherwise returns an Enumerator (same dual API as
47
+ # Array#each), so callers can stream (`each { }`) or collect (`.to_a`,
48
+ # `.first`, `.lazy`) without the scanner caring which.
49
+ def scan(content, file: nil)
50
+ return to_enum(:scan, content, file:) unless block_given?
51
+
52
+ content.each_line.with_index(1) do |raw_line, line_num|
53
+ line = raw_line.chomp
54
+ finding = rule_finding(line, file, line_num) || env_var_finding(line, file, line_num)
55
+ yield finding if finding
56
+ end
57
+ end
58
+
59
+ def truncate_preview(line, idx, context_before: 5, max_len: 40, leading_ellipsis: true)
60
+ start = [0, idx - context_before].max
61
+ finish = [line.length, idx + max_len].min
62
+ leading = leading_ellipsis && start.positive? ? "…" : ""
63
+ trailing = finish < line.length ? "…" : ""
64
+ "#{leading}#{line[start...finish]}#{trailing}"
65
+ end
66
+
67
+ private
68
+
69
+ def rule_finding(line, file, line_num)
70
+ rule = RULES.find { |r| r.pattern.match?(line) }
71
+ return unless rule
72
+
73
+ match = rule.pattern.match(line)
74
+ preview = truncate_preview(
75
+ line, match.begin(0),
76
+ context_before: rule.context_before, max_len: rule.max_len, leading_ellipsis: rule.leading_ellipsis
77
+ )
78
+ Finding.new(rule: rule.name, file:, line: line_num, preview:, severity: rule.severity)
79
+ end
80
+
81
+ def env_var_finding(line, file, line_num)
82
+ match = ENV_VAR_PATTERN.match(line)
83
+ return unless match
84
+
85
+ key_name = match[1].upcase
86
+ value = match[2]
87
+ return unless SENSITIVE_KEY_PATTERN.match?(key_name)
88
+ return if placeholder?(value)
89
+
90
+ Finding.new(rule: "Env Var: Sensitive Key", file:, line: line_num,
91
+ preview: "#{key_name}=#{truncate_value(value)}", severity: :medium)
92
+ end
93
+
94
+ def placeholder?(value)
95
+ return true if value.empty? || value == '""' || value == "''"
96
+
97
+ PLACEHOLDER_PREFIXES.any? { |prefix| value.start_with?(prefix) }
98
+ end
99
+
100
+ def truncate_value(value)
101
+ value.length > 8 ? "#{value[0, 8]}…" : value
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -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 Leakless
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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Leakless
4
+ module Cli
5
+ VERSION = "0.3.0"
6
+ end
7
+ end
data/lib/leakless.rb ADDED
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require_relative "leakless/version"
5
+ require_relative "leakless/finding"
6
+ require_relative "leakless/scanner"
7
+ require_relative "leakless/reporter"
8
+ require_relative "leakless/file_lister"
9
+ require_relative "leakless/init"
10
+
11
+ module Leakless
12
+ # Command-line entry points for scanning a directory for leaked secrets and
13
+ # scaffolding a publishable profile.
14
+ module Cli
15
+ class Error < StandardError; end
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, "leakless/triage"
20
+
21
+ # Thor gives us subcommand dispatch, flag parsing, and --help for free —
22
+ # the TS original hand-parses process.argv/flags itself.
23
+ class App < Thor
24
+ def self.exit_on_failure? = true
25
+
26
+ desc "scan [DIR]", "scan a directory for secret patterns"
27
+ method_option :force,
28
+ type: :boolean,
29
+ default: false,
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"
45
+ def scan(dir = ".")
46
+ findings = collect_findings(dir)
47
+ findings = triage(findings) if options[:ai_triage]
48
+ Reporter.report(findings, force: options[:force])
49
+ rescue Error => e
50
+ warn e.message
51
+ exit 1
52
+ end
53
+
54
+ desc "init [SKILL...]", "scaffold a publishable sharekit profile"
55
+ method_option :force,
56
+ type: :boolean,
57
+ default: false,
58
+ desc: "overwrite existing dir; override secret blocking"
59
+ method_option :dir,
60
+ type: :string,
61
+ default: "./sharekit-profile",
62
+ desc: "profile directory to create"
63
+ def init(*skill_names)
64
+ result = Init.call(profile_dir: options[:dir], skill_names:, force: options[:force])
65
+ print_init_summary(result)
66
+ Reporter.report(result.findings, force: options[:force])
67
+ rescue Error => e
68
+ warn e.message
69
+ exit 1
70
+ end
71
+
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
+
98
+ def print_init_summary(result)
99
+ result.created.each { |path| puts " + #{path}" }
100
+ puts "\n ✓ Created profile at #{result.profile_dir}\n\n"
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
data/sig/leakless.rbs ADDED
@@ -0,0 +1,6 @@
1
+ module Leakless
2
+ module Cli
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
metadata ADDED
@@ -0,0 +1,79 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: leakless
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Lucas Santana
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: thor
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.3'
26
+ description: A small rule-driven CLI that scans files for private keys, cloud/API
27
+ tokens, and sensitive env vars, and blocks on high-severity findings unless --force
28
+ is given.
29
+ email:
30
+ - lucas.diassantana@gmail.com
31
+ executables:
32
+ - leakless
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - ".ruby-version"
37
+ - CHANGELOG.md
38
+ - LICENSE.txt
39
+ - README.md
40
+ - Rakefile
41
+ - docs/adr/2026-07-26-redact-always-ai-triage.md
42
+ - exe/leakless
43
+ - lib/leakless.rb
44
+ - lib/leakless/file_lister.rb
45
+ - lib/leakless/finding.rb
46
+ - lib/leakless/init.rb
47
+ - lib/leakless/redactor.rb
48
+ - lib/leakless/reporter.rb
49
+ - lib/leakless/scanner.rb
50
+ - lib/leakless/triage.rb
51
+ - lib/leakless/version.rb
52
+ - sig/leakless.rbs
53
+ homepage: https://github.com/LucasSantana-Dev/leakless
54
+ licenses:
55
+ - MIT
56
+ metadata:
57
+ allowed_push_host: https://rubygems.org
58
+ homepage_uri: https://github.com/LucasSantana-Dev/leakless
59
+ source_code_uri: https://github.com/LucasSantana-Dev/leakless/tree/main
60
+ changelog_uri: https://github.com/LucasSantana-Dev/leakless/blob/main/CHANGELOG.md
61
+ rubygems_mfa_required: 'true'
62
+ rdoc_options: []
63
+ require_paths:
64
+ - lib
65
+ required_ruby_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: 3.2.0
70
+ required_rubygems_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ requirements: []
76
+ rubygems_version: 3.6.9
77
+ specification_version: 4
78
+ summary: Scan a directory for leaked secrets before you publish it.
79
+ test_files: []