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 +7 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE.txt +21 -0
- data/README.md +145 -0
- data/Rakefile +12 -0
- data/docs/adr/2026-07-26-redact-always-ai-triage.md +57 -0
- data/exe/leakless +6 -0
- data/lib/leakless/file_lister.rb +42 -0
- data/lib/leakless/finding.rb +28 -0
- data/lib/leakless/init.rb +114 -0
- data/lib/leakless/redactor.rb +71 -0
- data/lib/leakless/reporter.rb +61 -0
- data/lib/leakless/scanner.rb +106 -0
- data/lib/leakless/triage.rb +166 -0
- data/lib/leakless/version.rb +7 -0
- data/lib/leakless.rb +105 -0
- data/sig/leakless.rbs +6 -0
- metadata +79 -0
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,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,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
|
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
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: []
|