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 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
+ ![Ruby](https://img.shields.io/badge/Ruby-3.0%2B-CC342D?logo=ruby&logoColor=white)
6
+ ![Dependencies](https://img.shields.io/badge/runtime_deps-zero-brightgreen)
7
+ ![Tests](https://img.shields.io/badge/tests-36_passing-brightgreen)
8
+ ![License](https://img.shields.io/badge/license-MIT-blue)
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,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+ require "secretsweep"
6
+
7
+ exit SecretSweep::CLI.start(ARGV)
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecretSweep
4
+ VERSION = "0.1.0"
5
+ end
@@ -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: []