sharekit-cli 0.1.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 +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +95 -0
- data/Rakefile +12 -0
- data/exe/sharekit-cli +6 -0
- data/lib/sharekit/cli/file_lister.rb +42 -0
- data/lib/sharekit/cli/finding.rb +20 -0
- data/lib/sharekit/cli/init.rb +114 -0
- data/lib/sharekit/cli/reporter.rb +44 -0
- data/lib/sharekit/cli/scanner.rb +106 -0
- data/lib/sharekit/cli/version.rb +7 -0
- data/lib/sharekit/cli.rb +65 -0
- data/sig/sharekit/cli.rbs +6 -0
- metadata +76 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0cde6c59b8fa8f0a9b7405d8f67ccba50390a1cfeb03a10628f39cfc7b1c823c
|
|
4
|
+
data.tar.gz: 9d86a7780e71640e1597d57912113a9c154256eb799aa2e28e7408686dbb2844
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7bea1ae6dc74aab3e5a3a57d45e4c9ad986b046485f26020a2bbafbb1cb626250e30a748490b671cd6be9c78be62d729cdade72df9e6afcacb04496ca24bf8c5
|
|
7
|
+
data.tar.gz: 8a02b6895b8318687478166602ec1341cc537b2d44e4b3351ab6771807c2df9679a07b380d99557c11caf50f9a9267221716e1db0823c6111e951df1f064585b
|
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.4.10
|
data/CHANGELOG.md
ADDED
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,95 @@
|
|
|
1
|
+
# sharekit-cli
|
|
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
|
+
- **Thor** for CLI dispatch instead of hand-parsed `ARGV`/flags.
|
|
24
|
+
- **`git ls-files`, not a hand-rolled ignore-file parser.** `scan` defers to git for
|
|
25
|
+
`.gitignore`-aware file listing when run inside a repo (via `Open3`, argv-array — no shell
|
|
26
|
+
interpolation), instead of reimplementing gitignore match semantics.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
Not yet published to RubyGems. Install straight from git:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
gem install specific_install
|
|
34
|
+
gem specific_install https://github.com/LucasSantana-Dev/sharekit-cli.git
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Or add to a `Gemfile`:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
gem "sharekit-cli", git: "https://github.com/LucasSantana-Dev/sharekit-cli.git"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
sharekit-cli scan [DIR] # scan a directory (default: .)
|
|
47
|
+
sharekit-cli scan [DIR] --force # don't block on high-severity findings
|
|
48
|
+
|
|
49
|
+
sharekit-cli init [SKILL...] # scaffold ./sharekit-profile
|
|
50
|
+
sharekit-cli init --dir PATH [SKILL...] # scaffold at a custom path
|
|
51
|
+
sharekit-cli init --force # overwrite an existing dir; override secret blocking
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
$ sharekit-cli scan .
|
|
56
|
+
|
|
57
|
+
⚠ Secret patterns detected:
|
|
58
|
+
.env:1 [AWS Access Key ID] …Y=AKIAEXAMPLEKEY000000
|
|
59
|
+
|
|
60
|
+
⚠ Review and redact secrets before pushing to a public repository.
|
|
61
|
+
|
|
62
|
+
Secrets export blocked: 1 high-severity finding(s) detected. Review and remove secrets, or re-run with --force to override.
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Both commands exit `1` when a high-severity secret is found and `--force` wasn't passed; `0`
|
|
66
|
+
otherwise. `init` copies `~/.claude/CLAUDE.md` and `~/.cursor/.cursorrules` into the new profile
|
|
67
|
+
(or writes placeholders if they don't exist) and scans everything it copies for secrets before
|
|
68
|
+
reporting success.
|
|
69
|
+
|
|
70
|
+
### Rules
|
|
71
|
+
|
|
72
|
+
| Rule | Severity |
|
|
73
|
+
|--------------------------------|----------|
|
|
74
|
+
| Private Key Block | high |
|
|
75
|
+
| AWS Access Key ID | high |
|
|
76
|
+
| GitHub Personal Access Token | high |
|
|
77
|
+
| Slack Token | high |
|
|
78
|
+
| Google API Key | high |
|
|
79
|
+
| Bearer Token (JWT) | high |
|
|
80
|
+
| Home Directory Path Leak | low |
|
|
81
|
+
| Env Var: Sensitive Key | medium |
|
|
82
|
+
|
|
83
|
+
## Development
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
bin/setup # install dependencies
|
|
87
|
+
bundle exec rspec # run the test suite (61 examples)
|
|
88
|
+
bundle exec rubocop # lint
|
|
89
|
+
bundle exec bundler-audit check --update # dependency vulnerability scan
|
|
90
|
+
bin/console # interactive prompt
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT.
|
data/Rakefile
ADDED
data/exe/sharekit-cli
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Sharekit
|
|
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,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sharekit
|
|
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
|
+
Finding = Data.define(:rule, :file, :line, :preview, :severity) do
|
|
11
|
+
include Comparable
|
|
12
|
+
|
|
13
|
+
def <=>(other)
|
|
14
|
+
SEVERITY_RANK.fetch(severity) <=> SEVERITY_RANK.fetch(other.severity)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def high? = severity == :high
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Sharekit
|
|
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,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sharekit
|
|
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
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def report(findings, force: false)
|
|
14
|
+
if findings.empty?
|
|
15
|
+
puts "#{GREEN} ✓ No secrets detected.\n#{RESET}"
|
|
16
|
+
return
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
puts "#{YELLOW}\n ⚠ Secret patterns detected:#{RESET}"
|
|
20
|
+
findings.each { |finding| puts "#{YELLOW} #{format_line(finding)}#{RESET}" }
|
|
21
|
+
puts "#{YELLOW}\n ⚠ Review and redact secrets before pushing to a public repository.\n#{RESET}"
|
|
22
|
+
|
|
23
|
+
gate!(findings, force:)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Data.define objects deconstruct into a Hash for free, so `in` can match
|
|
27
|
+
# directly on the fields we care about — no case/when + manual field access.
|
|
28
|
+
def format_line(finding)
|
|
29
|
+
case finding
|
|
30
|
+
in { file:, line:, rule:, preview: }
|
|
31
|
+
"#{file}:#{line} [#{rule}] #{preview}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def gate!(findings, force:)
|
|
36
|
+
high = findings.select(&:high?)
|
|
37
|
+
return if high.empty? || force
|
|
38
|
+
|
|
39
|
+
raise Error, "Secrets export blocked: #{high.size} high-severity finding(s) detected. " \
|
|
40
|
+
"Review and remove secrets, or re-run with --force to override."
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "finding"
|
|
4
|
+
|
|
5
|
+
module Sharekit
|
|
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
|
data/lib/sharekit/cli.rb
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require_relative "cli/version"
|
|
5
|
+
require_relative "cli/finding"
|
|
6
|
+
require_relative "cli/scanner"
|
|
7
|
+
require_relative "cli/reporter"
|
|
8
|
+
require_relative "cli/file_lister"
|
|
9
|
+
require_relative "cli/init"
|
|
10
|
+
|
|
11
|
+
module Sharekit
|
|
12
|
+
module Cli
|
|
13
|
+
class Error < StandardError; end
|
|
14
|
+
|
|
15
|
+
# Thor gives us subcommand dispatch, flag parsing, and --help for free —
|
|
16
|
+
# the TS original hand-parses process.argv/flags itself.
|
|
17
|
+
class App < Thor
|
|
18
|
+
def self.exit_on_failure? = true
|
|
19
|
+
|
|
20
|
+
desc "scan [DIR]", "scan a directory for secret patterns"
|
|
21
|
+
method_option :force,
|
|
22
|
+
type: :boolean,
|
|
23
|
+
default: false,
|
|
24
|
+
desc: "exit 0 even if high-severity findings detected"
|
|
25
|
+
def scan(dir = ".")
|
|
26
|
+
findings = FileLister.list(dir).flat_map do |path|
|
|
27
|
+
Scanner.scan(File.read(path, encoding: "UTF-8"), file: path).to_a
|
|
28
|
+
rescue ArgumentError, Errno::ENOENT, Errno::EACCES => e
|
|
29
|
+
puts " ~ Skipped #{path}: #{e.class}"
|
|
30
|
+
[]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
Reporter.report(findings, force: options[:force])
|
|
34
|
+
rescue Error => e
|
|
35
|
+
warn e.message
|
|
36
|
+
exit 1
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
desc "init [SKILL...]", "scaffold a publishable sharekit profile"
|
|
40
|
+
method_option :force,
|
|
41
|
+
type: :boolean,
|
|
42
|
+
default: false,
|
|
43
|
+
desc: "overwrite existing dir; override secret blocking"
|
|
44
|
+
method_option :dir,
|
|
45
|
+
type: :string,
|
|
46
|
+
default: "./sharekit-profile",
|
|
47
|
+
desc: "profile directory to create"
|
|
48
|
+
def init(*skill_names)
|
|
49
|
+
result = Init.call(profile_dir: options[:dir], skill_names:, force: options[:force])
|
|
50
|
+
print_init_summary(result)
|
|
51
|
+
Reporter.report(result.findings, force: options[:force])
|
|
52
|
+
rescue Error => e
|
|
53
|
+
warn e.message
|
|
54
|
+
exit 1
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
no_commands do
|
|
58
|
+
def print_init_summary(result)
|
|
59
|
+
result.created.each { |path| puts " + #{path}" }
|
|
60
|
+
puts "\n ✓ Created profile at #{result.profile_dir}\n\n"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sharekit-cli
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.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
|
+
- sharekit-cli
|
|
33
|
+
extensions: []
|
|
34
|
+
extra_rdoc_files: []
|
|
35
|
+
files:
|
|
36
|
+
- ".ruby-version"
|
|
37
|
+
- CHANGELOG.md
|
|
38
|
+
- LICENSE.txt
|
|
39
|
+
- README.md
|
|
40
|
+
- Rakefile
|
|
41
|
+
- exe/sharekit-cli
|
|
42
|
+
- lib/sharekit/cli.rb
|
|
43
|
+
- lib/sharekit/cli/file_lister.rb
|
|
44
|
+
- lib/sharekit/cli/finding.rb
|
|
45
|
+
- lib/sharekit/cli/init.rb
|
|
46
|
+
- lib/sharekit/cli/reporter.rb
|
|
47
|
+
- lib/sharekit/cli/scanner.rb
|
|
48
|
+
- lib/sharekit/cli/version.rb
|
|
49
|
+
- sig/sharekit/cli.rbs
|
|
50
|
+
homepage: https://github.com/LucasSantana-Dev/sharekit-cli
|
|
51
|
+
licenses:
|
|
52
|
+
- MIT
|
|
53
|
+
metadata:
|
|
54
|
+
allowed_push_host: https://rubygems.org
|
|
55
|
+
homepage_uri: https://github.com/LucasSantana-Dev/sharekit-cli
|
|
56
|
+
source_code_uri: https://github.com/LucasSantana-Dev/sharekit-cli/tree/main
|
|
57
|
+
changelog_uri: https://github.com/LucasSantana-Dev/sharekit-cli/blob/main/CHANGELOG.md
|
|
58
|
+
rubygems_mfa_required: 'true'
|
|
59
|
+
rdoc_options: []
|
|
60
|
+
require_paths:
|
|
61
|
+
- lib
|
|
62
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - ">="
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: 3.2.0
|
|
67
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
68
|
+
requirements:
|
|
69
|
+
- - ">="
|
|
70
|
+
- !ruby/object:Gem::Version
|
|
71
|
+
version: '0'
|
|
72
|
+
requirements: []
|
|
73
|
+
rubygems_version: 3.6.9
|
|
74
|
+
specification_version: 4
|
|
75
|
+
summary: Scan a directory for leaked secrets before you publish it.
|
|
76
|
+
test_files: []
|