secretsweep 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/LICENSE +21 -0
- data/README.md +155 -0
- data/bin/secretsweep +7 -0
- data/lib/secretsweep/cli.rb +73 -0
- data/lib/secretsweep/entropy.rb +38 -0
- data/lib/secretsweep/file_scanner.rb +98 -0
- data/lib/secretsweep/finding.rb +35 -0
- data/lib/secretsweep/git_history_scanner.rb +59 -0
- data/lib/secretsweep/ignore_list.rb +50 -0
- data/lib/secretsweep/pattern_registry.rb +53 -0
- data/lib/secretsweep/reporter.rb +41 -0
- data/lib/secretsweep/scanner.rb +40 -0
- data/lib/secretsweep/version.rb +5 -0
- data/lib/secretsweep.rb +16 -0
- metadata +99 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ec15ec1664c3323a4f84c7e234df7f45f4cd318c38b86c39e068da35f9af80ae
|
|
4
|
+
data.tar.gz: 199353f168d2ec5873496546b788fb5fa34f455a75ce698f4cf1ddfabd89806a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 614b09cb1147c57a9bab4ff1f3274af32d182470a42d6ee8fdd27daa97171e180d35965f2f03ea56f26ecdb87c816939e8ad0a8a3e1069d4f6d6b3535ac2af01
|
|
7
|
+
data.tar.gz: c60c7f54f78f32997565fbd8ed8ca6be614610c16287a6aa4587dace73800aa847eebd017e9a730bd516d34eac47ac39a5d8186412d3f5759d9d832f6fa6f4a3
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Majd
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# SecretSweep
|
|
2
|
+
|
|
3
|
+
**A dependency-free Ruby CLI that scans files and full git history for leaked secrets — API keys, tokens, private keys — using known-format patterns plus entropy analysis for everything else.**
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
## Why this exists
|
|
11
|
+
|
|
12
|
+
Secrets leak into git repos constantly — a hardcoded API key committed
|
|
13
|
+
"just for testing," a `.env` file that wasn't gitignored in time. The
|
|
14
|
+
sneaky part: **removing the secret in the next commit does not remove it
|
|
15
|
+
from git history.** Anyone with `git log -p` can still read it. This tool
|
|
16
|
+
scans both the working tree and full history, and treats them as equally
|
|
17
|
+
important.
|
|
18
|
+
|
|
19
|
+
## How it works
|
|
20
|
+
|
|
21
|
+
Two independent detection layers, deliberately kept separate:
|
|
22
|
+
|
|
23
|
+
1. **Pattern matching** (`PatternRegistry`) — known formats: AWS keys,
|
|
24
|
+
GitHub/Slack/Stripe tokens, PEM private key headers, JWTs, and a
|
|
25
|
+
generic `key = "..."` assignment heuristic.
|
|
26
|
+
2. **Entropy analysis** (`Entropy`) — Shannon entropy per character.
|
|
27
|
+
Catches secrets in formats with no dedicated pattern yet (a new
|
|
28
|
+
provider, an internal auth token) by flagging strings that are
|
|
29
|
+
"suspiciously random" rather than matching a specific shape.
|
|
30
|
+
|
|
31
|
+
Both layers can flag the *same* secret independently — see
|
|
32
|
+
[Known limitations](#known-limitations) below for why that's left as-is
|
|
33
|
+
rather than "fixed."
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
git clone https://github.com/yourusername/SecretSweep.git
|
|
39
|
+
cd SecretSweep
|
|
40
|
+
bundle install
|
|
41
|
+
gem build secretsweep.gemspec
|
|
42
|
+
gem install ./secretsweep-0.1.0.gem
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# Scan the current directory's working tree
|
|
49
|
+
secretsweep .
|
|
50
|
+
|
|
51
|
+
# Also scan full git history (every branch)
|
|
52
|
+
secretsweep . --history
|
|
53
|
+
|
|
54
|
+
# Machine-readable output, e.g. for CI
|
|
55
|
+
secretsweep . --format json
|
|
56
|
+
|
|
57
|
+
# Use a specific ignore file
|
|
58
|
+
secretsweep . --ignore-file .secretsweepignore
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Exit codes: `0` = clean, `1` = secrets found, `2` = error (e.g. not a git
|
|
62
|
+
repo when `--history` was requested).
|
|
63
|
+
|
|
64
|
+
### Example output
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
$ secretsweep . --history
|
|
68
|
+
Found 1 potential secret(s):
|
|
69
|
+
|
|
70
|
+
[aws_access_key_id] config.rb @ 010f580376fd
|
|
71
|
+
AKIA...MPLE (fingerprint: 039a924b0920)
|
|
72
|
+
|
|
73
|
+
To allowlist a confirmed false positive, add its fingerprint to .secretsweepignore
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
That example is real — it's what `secretsweep` reports against a test
|
|
77
|
+
repo where an AWS key was committed and then "removed" in a later
|
|
78
|
+
commit. The removal didn't help; the key is still readable in history.
|
|
79
|
+
|
|
80
|
+
### Ignoring false positives
|
|
81
|
+
|
|
82
|
+
Copy `.secretsweepignore.example` to `.secretsweepignore` in the
|
|
83
|
+
directory you're scanning. Two kinds of entries:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
# Skip scanning these paths entirely
|
|
87
|
+
test/fixtures/**
|
|
88
|
+
|
|
89
|
+
# Allowlist ONE specific confirmed false positive by its fingerprint
|
|
90
|
+
# (does NOT silence the whole rule — just that exact file+type+snippet)
|
|
91
|
+
a1b2c3d4e5f6
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Design decisions worth reading
|
|
95
|
+
|
|
96
|
+
- **Two detection layers, kept intentionally separate.** Pattern
|
|
97
|
+
matching and entropy analysis never share code — a bug in one can't
|
|
98
|
+
silently break the other, and each is independently testable.
|
|
99
|
+
- **Fingerprints, not line numbers, are the identity of a finding.**
|
|
100
|
+
A fingerprint is a hash of `source:type:snippet` — it survives the
|
|
101
|
+
file being edited above the secret, so an ignore-list entry doesn't
|
|
102
|
+
silently stop working the next time someone touches that file.
|
|
103
|
+
- **The ignore list has two granularities on purpose.** A path glob
|
|
104
|
+
silences a whole directory (fixtures, generated files); a fingerprint
|
|
105
|
+
silences exactly one confirmed false positive. Collapsing these into
|
|
106
|
+
one mechanism would force a choice between "too broad" and "too
|
|
107
|
+
narrow" for different real situations.
|
|
108
|
+
- **Git history scanning shells out to the real `git` binary** rather
|
|
109
|
+
than reimplementing pack-file parsing or depending on a native
|
|
110
|
+
extension gem — `git log -p --all` is well-tested, always available
|
|
111
|
+
wherever git itself is, and the output format is stable.
|
|
112
|
+
- **Zero runtime dependencies.** The whole tool is Ruby stdlib
|
|
113
|
+
(`optparse`, `json`, `open3`, `digest`, `pathname`, `find`). Anyone can
|
|
114
|
+
`gem install` it with no dependency resolution at all.
|
|
115
|
+
|
|
116
|
+
## Known limitations
|
|
117
|
+
|
|
118
|
+
- **The same secret can be reported twice**, once by pattern matching
|
|
119
|
+
and once by entropy analysis, if it happens to satisfy both (e.g. a
|
|
120
|
+
GitHub token is both a recognized format *and* high-entropy). This is
|
|
121
|
+
left as-is rather than deduplicated across types: two independent
|
|
122
|
+
detectors agreeing is a legitimate confidence signal, and collapsing
|
|
123
|
+
it would hide which detector(s) actually fired. See
|
|
124
|
+
`docs/COMMIT_PLAN.md` for a possible future confidence-scoring
|
|
125
|
+
approach instead of outright deduplication.
|
|
126
|
+
- **Entropy thresholds are heuristic**, not universally tuned — see
|
|
127
|
+
`docs/COMMIT_PLAN.md` Week 2 for the plan to validate them against a
|
|
128
|
+
larger real-world sample.
|
|
129
|
+
- **History scanning reads the full diff of every commit on every
|
|
130
|
+
branch** (`git log -p --all`) — this is thorough but can be slow on
|
|
131
|
+
very large, long-lived repositories. No pagination/depth limit yet.
|
|
132
|
+
|
|
133
|
+
## Testing
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
rake test
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
36 tests, zero dependencies beyond Ruby's bundled Minitest — no network
|
|
140
|
+
access or external services required to run the suite. Includes a real
|
|
141
|
+
integration test (`git_history_scanner_test.rb`) that creates an actual
|
|
142
|
+
throwaway git repo, commits a secret, removes it in a later commit, and
|
|
143
|
+
verifies `--history` still catches it.
|
|
144
|
+
|
|
145
|
+
## Roadmap
|
|
146
|
+
|
|
147
|
+
See [`docs/COMMIT_PLAN.md`](docs/COMMIT_PLAN.md) for the week-by-week build-out plan.
|
|
148
|
+
|
|
149
|
+
## Tech stack
|
|
150
|
+
|
|
151
|
+
Ruby 3.0+, standard library only. Minitest + Rake for testing.
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT — see [LICENSE](LICENSE).
|
data/bin/secretsweep
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module SecretSweep
|
|
6
|
+
# Thin CLI wrapper. Deliberately kept dumb — argument parsing and exit
|
|
7
|
+
# codes only, no scanning logic — so Scanner/FileScanner/etc. stay
|
|
8
|
+
# usable as a plain Ruby library, e.g. `require "secretsweep"` inside
|
|
9
|
+
# a Rake task, with no CLI involved at all.
|
|
10
|
+
class CLI
|
|
11
|
+
EXIT_CLEAN = 0
|
|
12
|
+
EXIT_SECRETS_FOUND = 1
|
|
13
|
+
EXIT_ERROR = 2
|
|
14
|
+
|
|
15
|
+
def self.start(argv)
|
|
16
|
+
new.run(argv)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run(argv)
|
|
20
|
+
options = parse(argv)
|
|
21
|
+
path = options[:path] || "."
|
|
22
|
+
|
|
23
|
+
scanner = Scanner.new(path, ignore_file: options[:ignore_file], include_history: options[:history])
|
|
24
|
+
findings = scanner.run
|
|
25
|
+
|
|
26
|
+
puts Reporter.render(findings, format: options[:format])
|
|
27
|
+
|
|
28
|
+
findings.empty? ? EXIT_CLEAN : EXIT_SECRETS_FOUND
|
|
29
|
+
rescue GitHistoryScanner::NotAGitRepoError => e
|
|
30
|
+
warn "Error: #{e.message}"
|
|
31
|
+
EXIT_ERROR
|
|
32
|
+
rescue Errno::ENOENT => e
|
|
33
|
+
warn "Error: #{e.message}"
|
|
34
|
+
EXIT_ERROR
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def parse(argv)
|
|
40
|
+
options = { format: :text, history: false }
|
|
41
|
+
|
|
42
|
+
parser = OptionParser.new do |opts|
|
|
43
|
+
opts.banner = "Usage: secretsweep [path] [options]"
|
|
44
|
+
|
|
45
|
+
opts.on("--history", "Also scan full git history, not just the working tree") do
|
|
46
|
+
options[:history] = true
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
opts.on("--format FORMAT", %w[text json], "Output format: text (default) or json") do |fmt|
|
|
50
|
+
options[:format] = fmt.to_sym
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
opts.on("--ignore-file PATH", "Path to a .secretsweepignore file (default: <path>/.secretsweepignore)") do |p|
|
|
54
|
+
options[:ignore_file] = p
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
opts.on("-v", "--version", "Print the version and exit") do
|
|
58
|
+
puts SecretSweep::VERSION
|
|
59
|
+
exit(0)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
opts.on("-h", "--help", "Print this help") do
|
|
63
|
+
puts opts
|
|
64
|
+
exit(0)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
remaining = parser.parse(argv)
|
|
69
|
+
options[:path] = remaining.first
|
|
70
|
+
options
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SecretSweep
|
|
4
|
+
# Computes Shannon entropy of a string — a measure of "randomness" per
|
|
5
|
+
# character, in bits. Real secrets (API keys, tokens) tend to be
|
|
6
|
+
# high-entropy (close to random); English words, file paths, and normal
|
|
7
|
+
# code tend to be low-entropy. This catches secrets that don't match any
|
|
8
|
+
# known regex pattern — a purely regex-based scanner misses those.
|
|
9
|
+
module Entropy
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def shannon(str)
|
|
13
|
+
return 0.0 if str.nil? || str.empty?
|
|
14
|
+
|
|
15
|
+
frequencies = Hash.new(0)
|
|
16
|
+
str.each_char { |c| frequencies[c] += 1 }
|
|
17
|
+
|
|
18
|
+
length = str.length
|
|
19
|
+
frequencies.values.reduce(0.0) do |entropy, count|
|
|
20
|
+
probability = count.to_f / length
|
|
21
|
+
entropy - (probability * Math.log2(probability))
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A candidate string is "suspiciously random" if its entropy per
|
|
26
|
+
# character exceeds this threshold. Tuned empirically: base64-like
|
|
27
|
+
# secrets tend to sit around 4.0-4.5 bits/char; English text sits
|
|
28
|
+
# around 3.5-4.0 for lowercase-heavy content but real code identifiers
|
|
29
|
+
# (snake_case, camelCase) tend lower still.
|
|
30
|
+
DEFAULT_THRESHOLD = 4.3
|
|
31
|
+
|
|
32
|
+
def high_entropy?(str, threshold: DEFAULT_THRESHOLD, min_length: 20)
|
|
33
|
+
return false if str.nil? || str.length < min_length
|
|
34
|
+
|
|
35
|
+
shannon(str) >= threshold
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "find"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module SecretSweep
|
|
7
|
+
# Scans the current state of files on disk (as opposed to GitHistoryScanner,
|
|
8
|
+
# which scans past commits). Skips binary files and common noise
|
|
9
|
+
# directories so a full repo scan doesn't choke on node_modules or .git
|
|
10
|
+
# internals.
|
|
11
|
+
class FileScanner
|
|
12
|
+
SKIP_DIRS = %w[.git node_modules vendor tmp log dist build coverage .bundle].freeze
|
|
13
|
+
MAX_FILE_SIZE = 2 * 1024 * 1024 # 2MB — skip huge files (logs, binaries, lockfiles)
|
|
14
|
+
|
|
15
|
+
def initialize(root, ignore_list: IgnoreList.new([], []))
|
|
16
|
+
@root = root
|
|
17
|
+
@ignore_list = ignore_list
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def scan
|
|
21
|
+
findings = []
|
|
22
|
+
|
|
23
|
+
each_scannable_file do |path|
|
|
24
|
+
relative_path = relative(path)
|
|
25
|
+
next if @ignore_list.path_ignored?(relative_path)
|
|
26
|
+
|
|
27
|
+
findings.concat(scan_file(path, relative_path))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
findings
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def each_scannable_file
|
|
36
|
+
Find.find(@root) do |path|
|
|
37
|
+
if File.directory?(path)
|
|
38
|
+
Find.prune if SKIP_DIRS.include?(File.basename(path))
|
|
39
|
+
next
|
|
40
|
+
end
|
|
41
|
+
next unless File.file?(path)
|
|
42
|
+
next if File.size(path) > MAX_FILE_SIZE
|
|
43
|
+
next if binary?(path)
|
|
44
|
+
|
|
45
|
+
yield path
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def scan_file(path, relative_path)
|
|
50
|
+
findings = []
|
|
51
|
+
|
|
52
|
+
File.foreach(path).with_index(1) do |line, line_number|
|
|
53
|
+
# Guard per-line, not per-file: one malformed line (bad encoding,
|
|
54
|
+
# unexpected control characters) should never discard matches
|
|
55
|
+
# already found earlier in the same file.
|
|
56
|
+
begin
|
|
57
|
+
PatternRegistry.scan_line(line).each do |match|
|
|
58
|
+
findings << Finding.new(relative_path, line_number, match[:type], match[:snippet], nil)
|
|
59
|
+
end
|
|
60
|
+
check_high_entropy_tokens(line, relative_path, line_number, findings)
|
|
61
|
+
rescue ArgumentError, Encoding::InvalidByteSequenceError
|
|
62
|
+
next
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
findings
|
|
67
|
+
rescue ArgumentError, Encoding::InvalidByteSequenceError, Errno::ENOENT
|
|
68
|
+
# File wasn't actually readable/valid text despite passing the
|
|
69
|
+
# binary? heuristic (e.g. removed between listing and reading).
|
|
70
|
+
findings
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Beyond known patterns, flag bare high-entropy tokens assigned to a
|
|
74
|
+
# variable — catches secrets in formats we don't have a specific regex
|
|
75
|
+
# for yet (a new provider's token format, an internal auth token).
|
|
76
|
+
def check_high_entropy_tokens(line, relative_path, line_number, findings)
|
|
77
|
+
return unless line.match?(/=|:/)
|
|
78
|
+
|
|
79
|
+
line.scan(/['"]([A-Za-z0-9\/+_-]{20,})['"]/) do |match|
|
|
80
|
+
token = match[0]
|
|
81
|
+
next unless Entropy.high_entropy?(token)
|
|
82
|
+
|
|
83
|
+
findings << Finding.new(relative_path, line_number, "high_entropy_string", token, nil)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def relative(path)
|
|
88
|
+
Pathname.new(path).relative_path_from(Pathname.new(@root)).to_s
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def binary?(path)
|
|
92
|
+
sample = File.open(path, "rb") { |f| f.read(512) }
|
|
93
|
+
return false if sample.nil?
|
|
94
|
+
|
|
95
|
+
sample.include?("\x00")
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module SecretSweep
|
|
6
|
+
# A single detected secret. Immutable value object — findings are
|
|
7
|
+
# produced once and never mutated, only filtered (by IgnoreList) or
|
|
8
|
+
# rendered (by Reporter).
|
|
9
|
+
Finding = Struct.new(:source, :line_number, :type, :snippet, :commit) do
|
|
10
|
+
def fingerprint
|
|
11
|
+
# Stable identifier for this finding, used by the ignore list to
|
|
12
|
+
# allowlist a specific known false positive without silencing the
|
|
13
|
+
# entire rule everywhere else. Based on content, not line number,
|
|
14
|
+
# so it survives the file being edited above the secret.
|
|
15
|
+
Digest::SHA256.hexdigest("#{source}:#{type}:#{redacted_snippet}")[0, 12]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def redacted_snippet
|
|
19
|
+
return snippet if snippet.length <= 8
|
|
20
|
+
|
|
21
|
+
"#{snippet[0, 4]}...#{snippet[-4, 4]}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_h
|
|
25
|
+
{
|
|
26
|
+
source: source,
|
|
27
|
+
line: line_number,
|
|
28
|
+
type: type,
|
|
29
|
+
snippet: redacted_snippet,
|
|
30
|
+
commit: commit,
|
|
31
|
+
fingerprint: fingerprint
|
|
32
|
+
}.compact
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module SecretSweep
|
|
6
|
+
# Scans git history — not just the working tree — because a secret
|
|
7
|
+
# deleted in the latest commit is still fully readable by anyone who
|
|
8
|
+
# runs `git log -p` or `git show <old-sha>`. This is the check that
|
|
9
|
+
# catches "I removed the key in the next commit" as still a real leak.
|
|
10
|
+
class GitHistoryScanner
|
|
11
|
+
class NotAGitRepoError < StandardError; end
|
|
12
|
+
|
|
13
|
+
def initialize(repo_path, ignore_list: IgnoreList.new([], []))
|
|
14
|
+
@repo_path = repo_path
|
|
15
|
+
@ignore_list = ignore_list
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def scan
|
|
19
|
+
raise NotAGitRepoError, "#{@repo_path} is not a git repository" unless git_repo?
|
|
20
|
+
|
|
21
|
+
findings = []
|
|
22
|
+
current_file = nil
|
|
23
|
+
current_commit = nil
|
|
24
|
+
|
|
25
|
+
each_diff_line do |line|
|
|
26
|
+
if line.start_with?("commit ")
|
|
27
|
+
current_commit = line.split(" ", 2).last[0, 12]
|
|
28
|
+
elsif line.start_with?("+++ b/")
|
|
29
|
+
current_file = line.sub("+++ b/", "").strip
|
|
30
|
+
elsif line.start_with?("+") && !line.start_with?("+++")
|
|
31
|
+
content = line[1..]
|
|
32
|
+
next if current_file.nil? || @ignore_list.path_ignored?(current_file)
|
|
33
|
+
|
|
34
|
+
PatternRegistry.scan_line(content).each do |match|
|
|
35
|
+
findings << Finding.new(current_file, nil, match[:type], match[:snippet], current_commit)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
findings
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def git_repo?
|
|
46
|
+
_out, status = Open3.capture2("git", "-C", @repo_path, "rev-parse", "--is-inside-work-tree")
|
|
47
|
+
status.success?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def each_diff_line
|
|
51
|
+
# -p: show patches (actual added/removed lines), --all: every branch,
|
|
52
|
+
# not just the currently checked-out one — a secret on a stale
|
|
53
|
+
# feature branch is still a leak.
|
|
54
|
+
Open3.popen2("git", "-C", @repo_path, "log", "-p", "--all", "--no-color") do |_in, out, _thread|
|
|
55
|
+
out.each_line { |line| yield line }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SecretSweep
|
|
4
|
+
# Parses a .secretsweepignore file with two kinds of entries:
|
|
5
|
+
# - a bare glob pattern (e.g. `spec/fixtures/**`) skips scanning those
|
|
6
|
+
# paths entirely
|
|
7
|
+
# - a 12-char fingerprint (as printed in a Finding's report) allowlists
|
|
8
|
+
# that ONE specific known false positive, not the whole rule
|
|
9
|
+
#
|
|
10
|
+
# This distinction matters: silencing an entire secret TYPE because one
|
|
11
|
+
# file has a false positive would blind the scanner everywhere else.
|
|
12
|
+
class IgnoreList
|
|
13
|
+
def self.load(path)
|
|
14
|
+
return new([], []) unless path && File.exist?(path)
|
|
15
|
+
|
|
16
|
+
globs = []
|
|
17
|
+
fingerprints = []
|
|
18
|
+
|
|
19
|
+
File.readlines(path).each do |raw_line|
|
|
20
|
+
line = raw_line.strip
|
|
21
|
+
next if line.empty? || line.start_with?("#")
|
|
22
|
+
|
|
23
|
+
if line.match?(/\A[0-9a-f]{12}\z/)
|
|
24
|
+
fingerprints << line
|
|
25
|
+
else
|
|
26
|
+
globs << line
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
new(globs, fingerprints)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(path_globs, fingerprints)
|
|
34
|
+
@path_globs = path_globs
|
|
35
|
+
@fingerprints = fingerprints
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def path_ignored?(path)
|
|
39
|
+
@path_globs.any? { |glob| File.fnmatch(glob, path, File::FNM_PATHNAME | File::FNM_EXTGLOB) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def finding_ignored?(finding)
|
|
43
|
+
@fingerprints.include?(finding.fingerprint)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def reject_ignored(findings)
|
|
47
|
+
findings.reject { |f| finding_ignored?(f) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SecretSweep
|
|
4
|
+
# Known secret formats, keyed by a short type name. Each pattern is
|
|
5
|
+
# intentionally specific (not a generic "any long string" catch-all —
|
|
6
|
+
# that's what Entropy.high_entropy? is for) to keep false positives low
|
|
7
|
+
# for the formats we DO recognize.
|
|
8
|
+
module PatternRegistry
|
|
9
|
+
PATTERNS = {
|
|
10
|
+
"aws_access_key_id" => /\bAKIA[0-9A-Z]{16}\b/,
|
|
11
|
+
"aws_secret_access_key" => /\baws(.{0,20})?(secret|access)[_-]?key\b.{0,5}['"]([A-Za-z0-9\/+=]{40})['"]/i,
|
|
12
|
+
"github_token" => /\bgh[pousr]_[A-Za-z0-9]{36,}\b/,
|
|
13
|
+
"slack_token" => /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/,
|
|
14
|
+
"stripe_live_key" => /\bsk_live_[A-Za-z0-9]{24,}\b/,
|
|
15
|
+
"stripe_test_key" => /\bsk_test_[A-Za-z0-9]{24,}\b/,
|
|
16
|
+
"generic_private_key" => /-----BEGIN (RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/,
|
|
17
|
+
"google_api_key" => /\bAIza[0-9A-Za-z\-_]{35}\b/,
|
|
18
|
+
"generic_api_key_assignment" =>
|
|
19
|
+
/\b(api[_-]?key|secret|token|password|passwd|pwd)\b\s*[:=]\s*['"][A-Za-z0-9\/+=_\-]{16,}['"]/i,
|
|
20
|
+
"jwt" => /\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
# Lines matching any of these are skipped entirely before pattern
|
|
24
|
+
# matching runs — cuts down on noise from example/placeholder values
|
|
25
|
+
# that would otherwise trip the generic_api_key_assignment pattern.
|
|
26
|
+
#
|
|
27
|
+
# "your_api_key" and "xxxx+" are deliberately NOT wrapped in a trailing
|
|
28
|
+
# \b: that phrase commonly appears glued to surrounding text via
|
|
29
|
+
# underscores (e.g. "your_api_key_goes_here"), and \b never fires
|
|
30
|
+
# between two word characters — underscore counts as one — so a
|
|
31
|
+
# trailing boundary there would silently fail to match exactly the
|
|
32
|
+
# placeholder text it exists to catch.
|
|
33
|
+
PLACEHOLDER_HINTS = /\b(example|placeholder|changeme|dummy|fake|sample)\b|your[_-]?api[_-]?key|xxxx+/i
|
|
34
|
+
|
|
35
|
+
# Returns an array of { type:, snippet: } hashes for every match found
|
|
36
|
+
# in the line. The snippet is the full matched text — callers (e.g.
|
|
37
|
+
# Finding) are responsible for redacting it before display/storage.
|
|
38
|
+
def self.scan_line(line)
|
|
39
|
+
# Defensively normalize encoding: a file read outside a UTF-8 locale
|
|
40
|
+
# (e.g. LANG unset) can hand us a string whose declared encoding
|
|
41
|
+
# doesn't match its bytes, which makes regex matching raise instead
|
|
42
|
+
# of just returning "no match". `scrub` replaces invalid byte
|
|
43
|
+
# sequences so scanning degrades gracefully instead of aborting the
|
|
44
|
+
# whole file's scan.
|
|
45
|
+
line = line.to_s.scrub
|
|
46
|
+
return [] if line.match?(PLACEHOLDER_HINTS)
|
|
47
|
+
|
|
48
|
+
PATTERNS.each_with_object([]) do |(type, regex), matches|
|
|
49
|
+
line.scan(regex) { matches << { type: type, snippet: Regexp.last_match(0) } }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SecretSweep
|
|
6
|
+
# Renders a list of Findings for human or machine consumption. Kept
|
|
7
|
+
# separate from Scanner so output format never leaks into detection
|
|
8
|
+
# logic — adding a new format (e.g. SARIF for GitHub code scanning)
|
|
9
|
+
# means touching only this file.
|
|
10
|
+
module Reporter
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def render(findings, format: :text)
|
|
14
|
+
case format
|
|
15
|
+
when :json then render_json(findings)
|
|
16
|
+
else render_text(findings)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def render_text(findings)
|
|
21
|
+
return "No secrets found.\n" if findings.empty?
|
|
22
|
+
|
|
23
|
+
lines = ["Found #{findings.size} potential secret(s):", ""]
|
|
24
|
+
findings.each do |f|
|
|
25
|
+
location = f.commit ? "#{f.source} @ #{f.commit}" : "#{f.source}:#{f.line_number}"
|
|
26
|
+
lines << " [#{f.type}] #{location}"
|
|
27
|
+
lines << " #{f.redacted_snippet} (fingerprint: #{f.fingerprint})"
|
|
28
|
+
end
|
|
29
|
+
lines << ""
|
|
30
|
+
lines << "To allowlist a confirmed false positive, add its fingerprint to .secretsweepignore"
|
|
31
|
+
"#{lines.join("\n")}\n"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def render_json(findings)
|
|
35
|
+
JSON.pretty_generate(
|
|
36
|
+
count: findings.size,
|
|
37
|
+
findings: findings.map(&:to_h)
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SecretSweep
|
|
4
|
+
# Public entry point for running a scan. Combines FileScanner and
|
|
5
|
+
# (optionally) GitHistoryScanner, applies the ignore list once at the
|
|
6
|
+
# end so both scanners share identical allowlisting behavior instead of
|
|
7
|
+
# each reimplementing it.
|
|
8
|
+
class Scanner
|
|
9
|
+
def initialize(path, ignore_file: nil, include_history: false)
|
|
10
|
+
@path = File.expand_path(path)
|
|
11
|
+
@ignore_list = IgnoreList.load(ignore_file || default_ignore_path)
|
|
12
|
+
@include_history = include_history
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run
|
|
16
|
+
findings = FileScanner.new(@path, ignore_list: @ignore_list).scan
|
|
17
|
+
|
|
18
|
+
if @include_history
|
|
19
|
+
findings.concat(GitHistoryScanner.new(@path, ignore_list: @ignore_list).scan)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
dedupe(@ignore_list.reject_ignored(findings))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def default_ignore_path
|
|
28
|
+
candidate = File.join(@path, ".secretsweepignore")
|
|
29
|
+
File.exist?(candidate) ? candidate : nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The same secret often appears in both the working tree AND history
|
|
33
|
+
# (e.g. it's still there right now). Collapse to one entry per unique
|
|
34
|
+
# fingerprint so the report doesn't double-count it, while keeping the
|
|
35
|
+
# entry with the most specific location.
|
|
36
|
+
def dedupe(findings)
|
|
37
|
+
findings.group_by(&:fingerprint).map { |_fp, group| group.first }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/secretsweep.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "secretsweep/version"
|
|
4
|
+
require_relative "secretsweep/entropy"
|
|
5
|
+
require_relative "secretsweep/pattern_registry"
|
|
6
|
+
require_relative "secretsweep/finding"
|
|
7
|
+
require_relative "secretsweep/ignore_list"
|
|
8
|
+
require_relative "secretsweep/file_scanner"
|
|
9
|
+
require_relative "secretsweep/git_history_scanner"
|
|
10
|
+
require_relative "secretsweep/reporter"
|
|
11
|
+
require_relative "secretsweep/scanner"
|
|
12
|
+
require_relative "secretsweep/cli"
|
|
13
|
+
|
|
14
|
+
module SecretSweep
|
|
15
|
+
class Error < StandardError; end
|
|
16
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: secretsweep
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Majd
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: minitest
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '5.20'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '5.20'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rake
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '13.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '13.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rubocop
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.60'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '1.60'
|
|
54
|
+
description: |
|
|
55
|
+
SecretSweep scans a codebase (and optionally its full git history) for
|
|
56
|
+
leaked secrets — API keys, tokens, private keys — using a combination
|
|
57
|
+
of known-format regex patterns and Shannon entropy analysis for
|
|
58
|
+
unknown formats. Pure Ruby standard library, zero runtime dependencies.
|
|
59
|
+
executables:
|
|
60
|
+
- secretsweep
|
|
61
|
+
extensions: []
|
|
62
|
+
extra_rdoc_files: []
|
|
63
|
+
files:
|
|
64
|
+
- LICENSE
|
|
65
|
+
- README.md
|
|
66
|
+
- bin/secretsweep
|
|
67
|
+
- lib/secretsweep.rb
|
|
68
|
+
- lib/secretsweep/cli.rb
|
|
69
|
+
- lib/secretsweep/entropy.rb
|
|
70
|
+
- lib/secretsweep/file_scanner.rb
|
|
71
|
+
- lib/secretsweep/finding.rb
|
|
72
|
+
- lib/secretsweep/git_history_scanner.rb
|
|
73
|
+
- lib/secretsweep/ignore_list.rb
|
|
74
|
+
- lib/secretsweep/pattern_registry.rb
|
|
75
|
+
- lib/secretsweep/reporter.rb
|
|
76
|
+
- lib/secretsweep/scanner.rb
|
|
77
|
+
- lib/secretsweep/version.rb
|
|
78
|
+
homepage: https://github.com/yourusername/SecretSweep
|
|
79
|
+
licenses:
|
|
80
|
+
- MIT
|
|
81
|
+
metadata: {}
|
|
82
|
+
rdoc_options: []
|
|
83
|
+
require_paths:
|
|
84
|
+
- lib
|
|
85
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - ">="
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '3.0'
|
|
90
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - ">="
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: '0'
|
|
95
|
+
requirements: []
|
|
96
|
+
rubygems_version: 3.6.9
|
|
97
|
+
specification_version: 4
|
|
98
|
+
summary: A dependency-free CLI that scans files and git history for leaked secrets.
|
|
99
|
+
test_files: []
|