ace-git-secrets 0.13.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/.ace-defaults/git-secrets/config.yml +63 -0
- data/.ace-defaults/git-secrets/gitleaks.toml +14 -0
- data/.ace-defaults/nav/protocols/guide-sources/ace-git-secrets.yml +10 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-git-secrets.yml +19 -0
- data/CHANGELOG.md +298 -0
- data/LICENSE +21 -0
- data/README.md +40 -0
- data/Rakefile +16 -0
- data/docs/demo/ace-git-secrets-getting-started.gif +0 -0
- data/docs/demo/ace-git-secrets-getting-started.tape.yml +38 -0
- data/docs/demo/fixtures/README.md +3 -0
- data/docs/demo/fixtures/sample.txt +1 -0
- data/docs/getting-started.md +109 -0
- data/docs/handbook.md +43 -0
- data/docs/usage.md +301 -0
- data/exe/ace-git-secrets +19 -0
- data/handbook/agents/security-audit.ag.md +237 -0
- data/handbook/guides/security/ruby.md +27 -0
- data/handbook/guides/security/rust.md +51 -0
- data/handbook/guides/security/typescript.md +33 -0
- data/handbook/guides/security.g.md +155 -0
- data/handbook/skills/as-git-security-audit/SKILL.md +29 -0
- data/handbook/skills/as-git-token-remediation/SKILL.md +21 -0
- data/handbook/workflow-instructions/git/security-audit.wf.md +247 -0
- data/handbook/workflow-instructions/git/token-remediation.wf.md +294 -0
- data/lib/ace/git/secrets/atoms/gitleaks_runner.rb +244 -0
- data/lib/ace/git/secrets/atoms/service_api_client.rb +188 -0
- data/lib/ace/git/secrets/cli/commands/check_release.rb +41 -0
- data/lib/ace/git/secrets/cli/commands/revoke.rb +44 -0
- data/lib/ace/git/secrets/cli/commands/rewrite.rb +46 -0
- data/lib/ace/git/secrets/cli/commands/scan.rb +51 -0
- data/lib/ace/git/secrets/cli.rb +75 -0
- data/lib/ace/git/secrets/commands/check_release_command.rb +48 -0
- data/lib/ace/git/secrets/commands/revoke_command.rb +199 -0
- data/lib/ace/git/secrets/commands/rewrite_command.rb +147 -0
- data/lib/ace/git/secrets/commands/scan_command.rb +113 -0
- data/lib/ace/git/secrets/models/detected_token.rb +129 -0
- data/lib/ace/git/secrets/models/revocation_result.rb +119 -0
- data/lib/ace/git/secrets/models/scan_report.rb +402 -0
- data/lib/ace/git/secrets/molecules/git_rewriter.rb +199 -0
- data/lib/ace/git/secrets/molecules/history_scanner.rb +155 -0
- data/lib/ace/git/secrets/molecules/token_revoker.rb +100 -0
- data/lib/ace/git/secrets/organisms/history_cleaner.rb +201 -0
- data/lib/ace/git/secrets/organisms/release_gate.rb +133 -0
- data/lib/ace/git/secrets/organisms/security_auditor.rb +220 -0
- data/lib/ace/git/secrets/version.rb +9 -0
- data/lib/ace/git/secrets.rb +168 -0
- metadata +227 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
module Secrets
|
|
6
|
+
module Commands
|
|
7
|
+
# CLI command for revoking tokens via provider APIs
|
|
8
|
+
class RevokeCommand
|
|
9
|
+
# Execute revoke command
|
|
10
|
+
# @param options [Hash] Command options
|
|
11
|
+
# @return [Integer] Exit code (0=success, 1=partial/failure, 2=error)
|
|
12
|
+
def self.execute(options)
|
|
13
|
+
new(options).execute
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(options)
|
|
17
|
+
@options = options
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def execute
|
|
21
|
+
tokens = load_tokens
|
|
22
|
+
return 1 if tokens.nil?
|
|
23
|
+
|
|
24
|
+
if tokens.empty?
|
|
25
|
+
puts "No tokens found to revoke."
|
|
26
|
+
puts "Run 'ace-git-secrets scan' first to detect tokens."
|
|
27
|
+
return 0
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Filter by service if specified
|
|
31
|
+
services = @options[:service] ? [@options[:service]] : nil
|
|
32
|
+
|
|
33
|
+
revoker = Molecules::TokenRevoker.new
|
|
34
|
+
results = revoker.revoke_all(tokens, services: services)
|
|
35
|
+
|
|
36
|
+
# Display results
|
|
37
|
+
display_results(results)
|
|
38
|
+
|
|
39
|
+
# Return code based on results
|
|
40
|
+
if results.all?(&:success?)
|
|
41
|
+
0
|
|
42
|
+
elsif results.any?(&:success?)
|
|
43
|
+
1 # Partial success
|
|
44
|
+
else
|
|
45
|
+
1
|
|
46
|
+
end
|
|
47
|
+
rescue => e
|
|
48
|
+
puts "Error: #{e.message}"
|
|
49
|
+
puts e.backtrace.first(5).join("\n") if ENV["DEBUG"]
|
|
50
|
+
2
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def load_tokens
|
|
56
|
+
if @options[:token]
|
|
57
|
+
# Single token provided
|
|
58
|
+
[create_token_from_value(@options[:token])]
|
|
59
|
+
elsif @options[:scan_file]
|
|
60
|
+
load_tokens_from_file
|
|
61
|
+
else
|
|
62
|
+
# Ensure gitleaks is available for scanning
|
|
63
|
+
Atoms::GitleaksRunner.ensure_available!
|
|
64
|
+
|
|
65
|
+
# Scan to find tokens
|
|
66
|
+
scanner = Molecules::HistoryScanner.new(
|
|
67
|
+
gitleaks_config: Ace::Git::Secrets.gitleaks_config_path
|
|
68
|
+
)
|
|
69
|
+
report = scanner.scan(min_confidence: "high")
|
|
70
|
+
report.revocable_tokens
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def create_token_from_value(value)
|
|
75
|
+
# Detect token type from prefix
|
|
76
|
+
token_type = identify_token_type(value)
|
|
77
|
+
|
|
78
|
+
Models::DetectedToken.new(
|
|
79
|
+
token_type: token_type,
|
|
80
|
+
pattern_name: token_type,
|
|
81
|
+
confidence: "high",
|
|
82
|
+
commit_hash: "manual",
|
|
83
|
+
file_path: "manual_input",
|
|
84
|
+
raw_value: value,
|
|
85
|
+
detected_by: "manual"
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Simple token type detection from value prefix
|
|
90
|
+
# @param value [String] Token value
|
|
91
|
+
# @return [String] Token type
|
|
92
|
+
def identify_token_type(value)
|
|
93
|
+
case value
|
|
94
|
+
when /\Aghp_/ then "github_pat_classic"
|
|
95
|
+
when /\Agho_/ then "github_oauth"
|
|
96
|
+
when /\Aghs_/ then "github_app"
|
|
97
|
+
when /\Aghr_/ then "github_refresh"
|
|
98
|
+
when /\Agithub_pat_/ then "github_pat_fine"
|
|
99
|
+
when /\Ask-ant-/ then "anthropic_api_key"
|
|
100
|
+
when /\Ask-/ then "openai_api_key"
|
|
101
|
+
when /\AAKIA/ then "aws_access_key"
|
|
102
|
+
when /\AASIA/ then "aws_session"
|
|
103
|
+
when /\AAIza/ then "google_api_key"
|
|
104
|
+
when /\Axox[baprs]-/ then "slack_token"
|
|
105
|
+
when /\Anpm_/ then "npm_token"
|
|
106
|
+
else "unknown"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def load_tokens_from_file
|
|
111
|
+
require "json"
|
|
112
|
+
|
|
113
|
+
file_path = @options[:scan_file]
|
|
114
|
+
|
|
115
|
+
unless File.exist?(file_path)
|
|
116
|
+
warn "Scan file not found: #{file_path}"
|
|
117
|
+
return nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
content = File.read(file_path)
|
|
121
|
+
data = JSON.parse(content)
|
|
122
|
+
|
|
123
|
+
unless data.is_a?(Hash) && data["tokens"].is_a?(Array)
|
|
124
|
+
warn "Invalid scan file format: expected {\"tokens\": [...]}"
|
|
125
|
+
return nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Validate that raw_value is present - required for revocation
|
|
129
|
+
tokens_without_raw = data["tokens"].select { |t| t["raw_value"].nil? || t["raw_value"].empty? }
|
|
130
|
+
unless tokens_without_raw.empty?
|
|
131
|
+
warn "Error: Scan file missing raw_value for #{tokens_without_raw.size} token(s)."
|
|
132
|
+
warn "The scan file was likely saved without raw token values."
|
|
133
|
+
warn "Re-run: ace-git-secrets scan (saves with raw values by default)"
|
|
134
|
+
return nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
data["tokens"].map do |t|
|
|
138
|
+
Models::DetectedToken.new(
|
|
139
|
+
token_type: t["token_type"],
|
|
140
|
+
pattern_name: t["pattern_name"],
|
|
141
|
+
confidence: t["confidence"],
|
|
142
|
+
commit_hash: t["commit_hash"],
|
|
143
|
+
file_path: t["file_path"],
|
|
144
|
+
line_number: t["line_number"],
|
|
145
|
+
raw_value: t["raw_value"],
|
|
146
|
+
detected_by: t["detected_by"] || "scan_file"
|
|
147
|
+
)
|
|
148
|
+
end.select(&:revocable?)
|
|
149
|
+
rescue JSON::ParserError => e
|
|
150
|
+
warn "Invalid JSON in scan file: #{e.message}"
|
|
151
|
+
nil
|
|
152
|
+
rescue Errno::EACCES
|
|
153
|
+
warn "Permission denied reading scan file: #{file_path}"
|
|
154
|
+
nil
|
|
155
|
+
rescue => e
|
|
156
|
+
warn "Error loading scan file: #{e.class.name}: #{e.message}"
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def display_results(results)
|
|
161
|
+
puts "Token Revocation Results"
|
|
162
|
+
puts "=" * 50
|
|
163
|
+
puts
|
|
164
|
+
|
|
165
|
+
results.each do |result|
|
|
166
|
+
status_icon = case result.status
|
|
167
|
+
when "revoked" then "[OK]"
|
|
168
|
+
when "failed" then "[FAIL]"
|
|
169
|
+
when "skipped" then "[SKIP]"
|
|
170
|
+
else "[?]"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
puts "#{status_icon} #{result.token.token_type}"
|
|
174
|
+
puts " Value: #{result.token.masked_value}"
|
|
175
|
+
puts " Service: #{result.service}"
|
|
176
|
+
puts " Status: #{result.status}"
|
|
177
|
+
puts " Message: #{result.message}"
|
|
178
|
+
puts
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Summary
|
|
182
|
+
revoked = results.count(&:success?)
|
|
183
|
+
failed = results.count(&:failed?)
|
|
184
|
+
skipped = results.count(&:skipped?)
|
|
185
|
+
|
|
186
|
+
puts "-" * 50
|
|
187
|
+
puts "Summary: #{revoked} revoked, #{failed} failed, #{skipped} skipped"
|
|
188
|
+
|
|
189
|
+
if skipped > 0
|
|
190
|
+
puts
|
|
191
|
+
puts "Note: Some tokens require manual revocation."
|
|
192
|
+
puts "Visit the provider dashboards to revoke them."
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
module Secrets
|
|
6
|
+
module Commands
|
|
7
|
+
# CLI command for rewriting Git history to remove tokens
|
|
8
|
+
class RewriteCommand
|
|
9
|
+
# Execute rewrite-history command
|
|
10
|
+
# @param options [Hash] Command options
|
|
11
|
+
# @return [Integer] Exit code (0=success, 1=failure, 2=error)
|
|
12
|
+
def self.execute(options)
|
|
13
|
+
new(options).execute
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(options)
|
|
17
|
+
@options = options
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def execute
|
|
21
|
+
# Ensure gitleaks is available
|
|
22
|
+
Atoms::GitleaksRunner.ensure_available!
|
|
23
|
+
|
|
24
|
+
cleaner = Organisms::HistoryCleaner.new(
|
|
25
|
+
repository_path: ".",
|
|
26
|
+
gitleaks_config: Ace::Git::Secrets.gitleaks_config_path
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Load tokens from scan file if provided
|
|
30
|
+
tokens = load_tokens_from_file if @options[:scan_file]
|
|
31
|
+
return 1 if @options[:scan_file] && tokens.nil?
|
|
32
|
+
|
|
33
|
+
# First pass - get confirmation requirements
|
|
34
|
+
result = cleaner.clean(
|
|
35
|
+
tokens: tokens,
|
|
36
|
+
dry_run: @options[:dry_run],
|
|
37
|
+
force: @options[:force],
|
|
38
|
+
create_backup: @options.fetch(:backup, true)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Handle dry run
|
|
42
|
+
if result[:dry_run]
|
|
43
|
+
puts "DRY RUN - No changes made"
|
|
44
|
+
puts
|
|
45
|
+
puts result[:message]
|
|
46
|
+
|
|
47
|
+
if result[:tokens]
|
|
48
|
+
puts
|
|
49
|
+
puts "Tokens that would be removed:"
|
|
50
|
+
result[:tokens].each do |t|
|
|
51
|
+
puts " - #{t[:type]}: #{t[:masked_value]} (#{t[:file]}:#{t[:commit]})"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
return 0
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Handle confirmation required
|
|
58
|
+
if result[:requires_confirmation]
|
|
59
|
+
puts result[:message]
|
|
60
|
+
print "\nConfirmation: "
|
|
61
|
+
|
|
62
|
+
input = $stdin.gets&.strip
|
|
63
|
+
|
|
64
|
+
unless cleaner.valid_confirmation?(input)
|
|
65
|
+
puts "\nConfirmation failed. No changes made."
|
|
66
|
+
return 1
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Re-run with force after confirmation
|
|
70
|
+
result = cleaner.clean(
|
|
71
|
+
tokens: tokens,
|
|
72
|
+
force: true,
|
|
73
|
+
create_backup: @options.fetch(:backup, true)
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Handle result
|
|
78
|
+
if result[:success]
|
|
79
|
+
puts result[:message]
|
|
80
|
+
puts result[:next_steps] if result[:next_steps]
|
|
81
|
+
0
|
|
82
|
+
else
|
|
83
|
+
puts "Error: #{result[:message]}"
|
|
84
|
+
1
|
|
85
|
+
end
|
|
86
|
+
rescue => e
|
|
87
|
+
puts "Error: #{e.message}"
|
|
88
|
+
puts e.backtrace.first(5).join("\n") if ENV["DEBUG"]
|
|
89
|
+
2
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def load_tokens_from_file
|
|
95
|
+
return nil unless @options[:scan_file]
|
|
96
|
+
|
|
97
|
+
require "json"
|
|
98
|
+
file_path = @options[:scan_file]
|
|
99
|
+
|
|
100
|
+
unless File.exist?(file_path)
|
|
101
|
+
warn "Scan file not found: #{file_path}"
|
|
102
|
+
return nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
data = JSON.parse(File.read(file_path))
|
|
106
|
+
|
|
107
|
+
unless data.is_a?(Hash) && data["tokens"].is_a?(Array)
|
|
108
|
+
warn "Invalid scan file format: expected {\"tokens\": [...]}"
|
|
109
|
+
return nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Validate that raw_value is present - required for history rewriting
|
|
113
|
+
tokens_without_raw = data["tokens"].select { |t| t["raw_value"].nil? || t["raw_value"].empty? }
|
|
114
|
+
unless tokens_without_raw.empty?
|
|
115
|
+
warn "Error: Scan file missing raw_value for #{tokens_without_raw.size} token(s)."
|
|
116
|
+
warn "The scan file was likely saved without raw token values."
|
|
117
|
+
warn "Re-run: ace-git-secrets scan (saves with raw values by default)"
|
|
118
|
+
return nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
data["tokens"].map do |t|
|
|
122
|
+
Models::DetectedToken.new(
|
|
123
|
+
token_type: t["token_type"],
|
|
124
|
+
pattern_name: t["pattern_name"],
|
|
125
|
+
confidence: t["confidence"],
|
|
126
|
+
commit_hash: t["commit_hash"],
|
|
127
|
+
file_path: t["file_path"],
|
|
128
|
+
line_number: t["line_number"],
|
|
129
|
+
raw_value: t["raw_value"],
|
|
130
|
+
detected_by: t["detected_by"] || "scan_file"
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
rescue JSON::ParserError => e
|
|
134
|
+
warn "Invalid JSON in scan file: #{e.message}"
|
|
135
|
+
nil
|
|
136
|
+
rescue Errno::EACCES
|
|
137
|
+
warn "Permission denied reading scan file: #{file_path}"
|
|
138
|
+
nil
|
|
139
|
+
rescue => e
|
|
140
|
+
warn "Error loading scan file: #{e.class.name}: #{e.message}"
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
module Secrets
|
|
6
|
+
module Commands
|
|
7
|
+
# CLI command for scanning repository for tokens
|
|
8
|
+
# Requires gitleaks to be installed (brew install gitleaks)
|
|
9
|
+
class ScanCommand
|
|
10
|
+
# Execute scan command
|
|
11
|
+
# @param options [Hash] Command options
|
|
12
|
+
# @return [Integer] Exit code (0=clean, 1=tokens found, 2=error)
|
|
13
|
+
def self.execute(options)
|
|
14
|
+
new(options).execute
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(options)
|
|
18
|
+
@options = options
|
|
19
|
+
@config = Ace::Git::Secrets.config
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def execute
|
|
23
|
+
# Check gitleaks availability early with clear error
|
|
24
|
+
Atoms::GitleaksRunner.ensure_available!
|
|
25
|
+
|
|
26
|
+
auditor = Organisms::SecurityAuditor.new(
|
|
27
|
+
repository_path: ".",
|
|
28
|
+
gitleaks_config: Ace::Git::Secrets.gitleaks_config_path,
|
|
29
|
+
output_format: output_format,
|
|
30
|
+
whitelist: load_whitelist,
|
|
31
|
+
exclusions: load_exclusions
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Quiet mode suppresses verbose and uses minimal output
|
|
35
|
+
quiet = @options[:quiet]
|
|
36
|
+
verbose = !quiet && @options[:verbose]
|
|
37
|
+
|
|
38
|
+
report = auditor.audit(
|
|
39
|
+
since: @options[:since],
|
|
40
|
+
min_confidence: @options[:confidence] || "low",
|
|
41
|
+
verbose: verbose
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Save report to file (new default behavior)
|
|
45
|
+
report_format = @options[:report_format]&.to_sym || :json
|
|
46
|
+
output_directory = @config.dig("output", "directory")
|
|
47
|
+
report_path = report.save_to_file(format: report_format, directory: output_directory, quiet: quiet)
|
|
48
|
+
|
|
49
|
+
# Print output based on mode
|
|
50
|
+
if quiet
|
|
51
|
+
# In quiet mode, only print summary for CI
|
|
52
|
+
puts report.clean? ? "clean" : "found:#{report.token_count}"
|
|
53
|
+
elsif verbose
|
|
54
|
+
# In verbose mode, print full table report
|
|
55
|
+
puts auditor.format_report(report)
|
|
56
|
+
puts
|
|
57
|
+
# Show whitelisted count if any
|
|
58
|
+
if auditor.whitelisted_count > 0
|
|
59
|
+
puts "Whitelisted: #{auditor.whitelisted_count} token(s) excluded by whitelist rules"
|
|
60
|
+
puts
|
|
61
|
+
end
|
|
62
|
+
puts auditor.next_steps(report)
|
|
63
|
+
puts
|
|
64
|
+
puts "Report saved: #{report_path}"
|
|
65
|
+
else
|
|
66
|
+
# Default: summary to stdout, full report to file
|
|
67
|
+
puts report.to_summary(report_path: report_path)
|
|
68
|
+
# Show whitelisted count if any
|
|
69
|
+
if auditor.whitelisted_count > 0
|
|
70
|
+
puts "Whitelisted: #{auditor.whitelisted_count} token(s) excluded by whitelist rules"
|
|
71
|
+
end
|
|
72
|
+
unless report.clean?
|
|
73
|
+
puts
|
|
74
|
+
puts auditor.next_steps(report)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Return appropriate exit code
|
|
79
|
+
report.clean? ? 0 : 1
|
|
80
|
+
rescue Atoms::GitleaksRunner::GitleaksNotFoundError => e
|
|
81
|
+
puts "Error: #{e.message}"
|
|
82
|
+
2
|
|
83
|
+
rescue => e
|
|
84
|
+
puts "Error: #{e.message}"
|
|
85
|
+
puts e.backtrace.first(5).join("\n") if ENV["DEBUG"]
|
|
86
|
+
2
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# Load whitelist from config
|
|
92
|
+
def load_whitelist
|
|
93
|
+
@config["whitelist"] || []
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Load exclusions from config
|
|
97
|
+
# ADR-022: Config already contains defaults merged with user overrides
|
|
98
|
+
def load_exclusions
|
|
99
|
+
@config["exclusions"]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Determine output format
|
|
103
|
+
# CLI option takes precedence, then config, then default (table)
|
|
104
|
+
def output_format
|
|
105
|
+
@options[:format] ||
|
|
106
|
+
@config.dig("output", "format") ||
|
|
107
|
+
"table"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
module Secrets
|
|
6
|
+
module Models
|
|
7
|
+
# Represents a detected token in Git history
|
|
8
|
+
# Immutable value object containing token metadata
|
|
9
|
+
class DetectedToken
|
|
10
|
+
attr_reader :token_type, :pattern_name, :confidence, :commit_hash,
|
|
11
|
+
:file_path, :line_number, :raw_value, :detected_by
|
|
12
|
+
|
|
13
|
+
# Confidence levels for token detection
|
|
14
|
+
CONFIDENCE_LEVELS = %w[high medium low].freeze
|
|
15
|
+
|
|
16
|
+
# @param token_type [String] Type of token (github_pat, anthropic_api_key, etc.)
|
|
17
|
+
# @param pattern_name [String] Name of pattern that matched
|
|
18
|
+
# @param confidence [String] Confidence level (high, medium, low)
|
|
19
|
+
# @param commit_hash [String] Git commit SHA where token was found
|
|
20
|
+
# @param file_path [String] Path to file containing token
|
|
21
|
+
# @param line_number [Integer, nil] Line number in file
|
|
22
|
+
# @param raw_value [String] The actual token value (stored for revocation)
|
|
23
|
+
# @param detected_by [String] Detection method (gitleaks, ruby_patterns)
|
|
24
|
+
def initialize(token_type:, pattern_name:, confidence:, commit_hash:,
|
|
25
|
+
file_path:, raw_value:, line_number: nil, detected_by: "ruby_patterns")
|
|
26
|
+
@token_type = token_type
|
|
27
|
+
@pattern_name = pattern_name
|
|
28
|
+
@confidence = validate_confidence(confidence)
|
|
29
|
+
@commit_hash = commit_hash
|
|
30
|
+
@file_path = file_path
|
|
31
|
+
@line_number = line_number
|
|
32
|
+
@raw_value = raw_value
|
|
33
|
+
@detected_by = detected_by
|
|
34
|
+
|
|
35
|
+
freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns masked version of token for display
|
|
39
|
+
# Shows first 4 and last 4 characters with asterisks in between
|
|
40
|
+
# @return [String] Masked token value
|
|
41
|
+
def masked_value
|
|
42
|
+
return "****" if raw_value.nil? || raw_value.length < 12
|
|
43
|
+
|
|
44
|
+
prefix = raw_value[0, 4]
|
|
45
|
+
suffix = raw_value[-4, 4]
|
|
46
|
+
"#{prefix}#{"*" * [raw_value.length - 8, 4].max}#{suffix}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns short commit hash (7 characters)
|
|
50
|
+
# @return [String] Short commit hash
|
|
51
|
+
def short_commit
|
|
52
|
+
commit_hash[0, 7]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Check if this is a high confidence match
|
|
56
|
+
# @return [Boolean]
|
|
57
|
+
def high_confidence?
|
|
58
|
+
confidence == "high"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Returns service name for revocation
|
|
62
|
+
# @return [String, nil] Service name or nil if not revocable
|
|
63
|
+
def revocation_service
|
|
64
|
+
case token_type
|
|
65
|
+
when /^github_/
|
|
66
|
+
"github"
|
|
67
|
+
when "anthropic_api_key"
|
|
68
|
+
"anthropic"
|
|
69
|
+
when "openai_api_key"
|
|
70
|
+
"openai"
|
|
71
|
+
when /^aws_/
|
|
72
|
+
"aws"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Check if token can be revoked via API
|
|
77
|
+
# @return [Boolean]
|
|
78
|
+
def revocable?
|
|
79
|
+
!revocation_service.nil?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Human-readable provider name for grouping in reports
|
|
83
|
+
# @return [String] Provider display name
|
|
84
|
+
def provider_name
|
|
85
|
+
case revocation_service
|
|
86
|
+
when "github"
|
|
87
|
+
"GitHub"
|
|
88
|
+
when "anthropic"
|
|
89
|
+
"Anthropic"
|
|
90
|
+
when "openai"
|
|
91
|
+
"OpenAI"
|
|
92
|
+
when "aws"
|
|
93
|
+
"AWS"
|
|
94
|
+
else
|
|
95
|
+
"Manual Revocation Required"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Convert to hash for serialization
|
|
100
|
+
# @param include_raw [Boolean] Whether to include raw token value
|
|
101
|
+
# @return [Hash]
|
|
102
|
+
def to_h(include_raw: false)
|
|
103
|
+
h = {
|
|
104
|
+
token_type: token_type,
|
|
105
|
+
pattern_name: pattern_name,
|
|
106
|
+
confidence: confidence,
|
|
107
|
+
commit_hash: commit_hash,
|
|
108
|
+
file_path: file_path,
|
|
109
|
+
line_number: line_number,
|
|
110
|
+
masked_value: masked_value,
|
|
111
|
+
detected_by: detected_by,
|
|
112
|
+
revocable: revocable?
|
|
113
|
+
}
|
|
114
|
+
h[:raw_value] = raw_value if include_raw
|
|
115
|
+
h
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def validate_confidence(level)
|
|
121
|
+
return level if CONFIDENCE_LEVELS.include?(level)
|
|
122
|
+
|
|
123
|
+
raise ArgumentError, "Invalid confidence level: #{level}. Must be one of: #{CONFIDENCE_LEVELS.join(", ")}"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|