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
|
+
require "open3"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Git
|
|
8
|
+
module Secrets
|
|
9
|
+
module Molecules
|
|
10
|
+
# Wrapper for git-filter-repo to remove tokens from Git history
|
|
11
|
+
# Handles tool availability, filter building, and execution
|
|
12
|
+
class GitRewriter
|
|
13
|
+
FILTER_REPO_INSTALL_INSTRUCTIONS = <<~MSG
|
|
14
|
+
git-filter-repo is required for history rewriting.
|
|
15
|
+
Install with: brew install git-filter-repo
|
|
16
|
+
See: https://github.com/newren/git-filter-repo
|
|
17
|
+
MSG
|
|
18
|
+
|
|
19
|
+
attr_reader :repository_path
|
|
20
|
+
|
|
21
|
+
# @param repository_path [String] Path to git repository
|
|
22
|
+
# @raise [ArgumentError] If repository_path is not a valid git repository
|
|
23
|
+
def initialize(repository_path: ".")
|
|
24
|
+
@repository_path = File.expand_path(repository_path)
|
|
25
|
+
validate_repository_path!
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Check if git-filter-repo is available
|
|
29
|
+
# @return [Boolean]
|
|
30
|
+
def available?
|
|
31
|
+
system("which git-filter-repo > /dev/null 2>&1")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Check if working directory is clean
|
|
35
|
+
# @return [Boolean]
|
|
36
|
+
def clean_working_directory?
|
|
37
|
+
output, status = Open3.capture2(
|
|
38
|
+
"git", "-C", repository_path, "status", "--porcelain",
|
|
39
|
+
err: File::NULL
|
|
40
|
+
)
|
|
41
|
+
status.success? && output.strip.empty?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Rewrite history to remove specific tokens
|
|
45
|
+
# @param tokens [Array<DetectedToken>] Tokens to remove
|
|
46
|
+
# @param dry_run [Boolean] Whether to preview changes only
|
|
47
|
+
# @return [Hash] Result with :success, :message, :changes keys
|
|
48
|
+
def rewrite(tokens, dry_run: false)
|
|
49
|
+
unless available?
|
|
50
|
+
return {
|
|
51
|
+
success: false,
|
|
52
|
+
message: FILTER_REPO_INSTALL_INSTRUCTIONS,
|
|
53
|
+
changes: []
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
unless clean_working_directory?
|
|
58
|
+
return {
|
|
59
|
+
success: false,
|
|
60
|
+
message: "Working directory has uncommitted changes. Commit or stash before rewriting history.",
|
|
61
|
+
changes: []
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if tokens.empty?
|
|
66
|
+
return {
|
|
67
|
+
success: true,
|
|
68
|
+
message: "No tokens to remove",
|
|
69
|
+
changes: []
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Build replacement expressions
|
|
74
|
+
replacements = build_replacements(tokens)
|
|
75
|
+
|
|
76
|
+
if dry_run
|
|
77
|
+
{
|
|
78
|
+
success: true,
|
|
79
|
+
dry_run: true,
|
|
80
|
+
message: "Dry run: Would remove #{tokens.size} token(s) from history",
|
|
81
|
+
changes: replacements.map { |r| {original: r[:pattern], replacement: r[:replacement]} }
|
|
82
|
+
}
|
|
83
|
+
else
|
|
84
|
+
execute_filter_repo(replacements)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Create a backup of the repository
|
|
89
|
+
# @param backup_path [String] Path for backup
|
|
90
|
+
# @return [Boolean] Success status
|
|
91
|
+
def create_backup(backup_path)
|
|
92
|
+
_, status = Open3.capture2(
|
|
93
|
+
"git", "clone", "--mirror", repository_path, backup_path,
|
|
94
|
+
err: File::NULL
|
|
95
|
+
)
|
|
96
|
+
status.success?
|
|
97
|
+
rescue
|
|
98
|
+
false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# Validate that repository_path is a valid git repository
|
|
104
|
+
# @raise [ArgumentError] If path doesn't exist or isn't a git repo
|
|
105
|
+
def validate_repository_path!
|
|
106
|
+
unless Dir.exist?(repository_path)
|
|
107
|
+
raise ArgumentError, "Repository path does not exist: #{repository_path}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
git_dir = File.join(repository_path, ".git")
|
|
111
|
+
unless Dir.exist?(git_dir) || File.exist?(git_dir)
|
|
112
|
+
raise ArgumentError, "Not a git repository: #{repository_path}"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Build replacement expressions for git-filter-repo
|
|
117
|
+
# @param tokens [Array<DetectedToken>]
|
|
118
|
+
# @return [Array<Hash>]
|
|
119
|
+
def build_replacements(tokens)
|
|
120
|
+
tokens.map do |token|
|
|
121
|
+
# Create a masked replacement value
|
|
122
|
+
masked = "[REDACTED:#{token.token_type}]"
|
|
123
|
+
|
|
124
|
+
{
|
|
125
|
+
pattern: token.raw_value,
|
|
126
|
+
replacement: masked,
|
|
127
|
+
token_type: token.token_type
|
|
128
|
+
}
|
|
129
|
+
end.uniq { |r| r[:pattern] }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Execute git-filter-repo with replacements
|
|
133
|
+
# @param replacements [Array<Hash>]
|
|
134
|
+
# @return [Hash]
|
|
135
|
+
def execute_filter_repo(replacements)
|
|
136
|
+
# Create replacement file for git-filter-repo with secure permissions
|
|
137
|
+
# Mode 0600 ensures only the owner can read/write the file containing tokens
|
|
138
|
+
#
|
|
139
|
+
# Security: Use memory-backed tmpfs if available (Linux /dev/shm).
|
|
140
|
+
# Tempfiles in memory are harder to recover than disk-based temps.
|
|
141
|
+
# macOS doesn't have /dev/shm but has encrypted swap; this is defense in depth.
|
|
142
|
+
temp_dir = Dir.exist?("/dev/shm") ? "/dev/shm" : Dir.tmpdir
|
|
143
|
+
replacement_file = Tempfile.new(
|
|
144
|
+
["git-filter-repo-replacements", ".txt"],
|
|
145
|
+
temp_dir,
|
|
146
|
+
mode: File::RDWR | File::CREAT | File::EXCL,
|
|
147
|
+
perm: 0o600
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
begin
|
|
151
|
+
# Write replacements in git-filter-repo format
|
|
152
|
+
# Format: literal:ORIGINAL==>literal:REPLACEMENT
|
|
153
|
+
replacements.each do |r|
|
|
154
|
+
replacement_file.puts("literal:#{r[:pattern]}==>literal:#{r[:replacement]}")
|
|
155
|
+
end
|
|
156
|
+
replacement_file.close
|
|
157
|
+
|
|
158
|
+
cmd = [
|
|
159
|
+
"git-filter-repo",
|
|
160
|
+
"--replace-text", replacement_file.path,
|
|
161
|
+
"--force"
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
Dir.chdir(repository_path) do
|
|
165
|
+
stdout, stderr, status = Open3.capture3(*cmd)
|
|
166
|
+
|
|
167
|
+
if status.success?
|
|
168
|
+
{
|
|
169
|
+
success: true,
|
|
170
|
+
message: "Successfully removed #{replacements.size} token(s) from history",
|
|
171
|
+
changes: replacements.map { |r| {token_type: r[:token_type], status: "removed"} },
|
|
172
|
+
stdout: stdout,
|
|
173
|
+
stderr: stderr
|
|
174
|
+
}
|
|
175
|
+
else
|
|
176
|
+
{
|
|
177
|
+
success: false,
|
|
178
|
+
message: "git-filter-repo failed: #{stderr}",
|
|
179
|
+
changes: [],
|
|
180
|
+
stdout: stdout,
|
|
181
|
+
stderr: stderr
|
|
182
|
+
}
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
ensure
|
|
186
|
+
replacement_file.unlink
|
|
187
|
+
end
|
|
188
|
+
rescue => e
|
|
189
|
+
{
|
|
190
|
+
success: false,
|
|
191
|
+
message: "Error executing git-filter-repo: #{e.message}",
|
|
192
|
+
changes: []
|
|
193
|
+
}
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Git
|
|
7
|
+
module Secrets
|
|
8
|
+
module Molecules
|
|
9
|
+
# Scans Git history for authentication tokens using gitleaks
|
|
10
|
+
#
|
|
11
|
+
# This is a thin wrapper around GitleaksRunner that adds:
|
|
12
|
+
# - File exclusion filtering
|
|
13
|
+
# - Confidence level filtering
|
|
14
|
+
# - ScanReport generation
|
|
15
|
+
#
|
|
16
|
+
# Gitleaks is required - install with: brew install gitleaks
|
|
17
|
+
class HistoryScanner
|
|
18
|
+
attr_reader :repository_path, :gitleaks_runner, :exclusions
|
|
19
|
+
|
|
20
|
+
# @param repository_path [String] Path to git repository
|
|
21
|
+
# @param gitleaks_config [String, nil] Path to gitleaks config
|
|
22
|
+
# @param exclusions [Array<String>, nil] Glob patterns for files to exclude
|
|
23
|
+
def initialize(repository_path: ".", gitleaks_config: nil, exclusions: nil)
|
|
24
|
+
@repository_path = File.expand_path(repository_path)
|
|
25
|
+
@gitleaks_runner = Atoms::GitleaksRunner.new(config_path: gitleaks_config)
|
|
26
|
+
@exclusions = exclusions || Ace::Git::Secrets.exclusions
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Scan repository history for tokens
|
|
30
|
+
# @param since [String, nil] Start commit or date
|
|
31
|
+
# @param min_confidence [String] Minimum confidence level
|
|
32
|
+
# @return [Models::ScanReport]
|
|
33
|
+
def scan(since: nil, min_confidence: "low")
|
|
34
|
+
# Ensure gitleaks is available
|
|
35
|
+
Atoms::GitleaksRunner.ensure_available!
|
|
36
|
+
|
|
37
|
+
result = gitleaks_runner.scan_history(
|
|
38
|
+
path: repository_path,
|
|
39
|
+
since: since
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
detected_tokens = result[:findings].map do |f|
|
|
43
|
+
# Apply exclusions
|
|
44
|
+
next if excluded_file?(f[:file_path])
|
|
45
|
+
|
|
46
|
+
Models::DetectedToken.new(
|
|
47
|
+
token_type: f[:token_type],
|
|
48
|
+
pattern_name: f[:pattern_name],
|
|
49
|
+
confidence: f[:confidence],
|
|
50
|
+
commit_hash: f[:commit_hash],
|
|
51
|
+
file_path: f[:file_path],
|
|
52
|
+
line_number: f[:line_number],
|
|
53
|
+
raw_value: f[:matched_value],
|
|
54
|
+
detected_by: "gitleaks"
|
|
55
|
+
)
|
|
56
|
+
end.compact
|
|
57
|
+
|
|
58
|
+
# Filter by confidence
|
|
59
|
+
detected_tokens = filter_by_confidence(detected_tokens, min_confidence)
|
|
60
|
+
|
|
61
|
+
Models::ScanReport.new(
|
|
62
|
+
tokens: detected_tokens,
|
|
63
|
+
repository_path: repository_path,
|
|
64
|
+
scanned_at: Time.now,
|
|
65
|
+
scan_options: {since: since, min_confidence: min_confidence},
|
|
66
|
+
commits_scanned: count_commits(since: since),
|
|
67
|
+
detection_method: "gitleaks"
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Scan only current files (no history)
|
|
72
|
+
# @param min_confidence [String] Minimum confidence level
|
|
73
|
+
# @return [Models::ScanReport]
|
|
74
|
+
def scan_files(min_confidence: "low")
|
|
75
|
+
# Ensure gitleaks is available
|
|
76
|
+
Atoms::GitleaksRunner.ensure_available!
|
|
77
|
+
|
|
78
|
+
result = gitleaks_runner.scan_files(path: repository_path)
|
|
79
|
+
|
|
80
|
+
detected_tokens = result[:findings].map do |f|
|
|
81
|
+
# Apply exclusions
|
|
82
|
+
next if excluded_file?(f[:file_path])
|
|
83
|
+
|
|
84
|
+
Models::DetectedToken.new(
|
|
85
|
+
token_type: f[:token_type],
|
|
86
|
+
pattern_name: f[:pattern_name],
|
|
87
|
+
confidence: f[:confidence],
|
|
88
|
+
commit_hash: "HEAD",
|
|
89
|
+
file_path: f[:file_path],
|
|
90
|
+
line_number: f[:line_number],
|
|
91
|
+
raw_value: f[:matched_value],
|
|
92
|
+
detected_by: "gitleaks"
|
|
93
|
+
)
|
|
94
|
+
end.compact
|
|
95
|
+
|
|
96
|
+
detected_tokens = filter_by_confidence(detected_tokens, min_confidence)
|
|
97
|
+
|
|
98
|
+
Models::ScanReport.new(
|
|
99
|
+
tokens: detected_tokens,
|
|
100
|
+
repository_path: repository_path,
|
|
101
|
+
scanned_at: Time.now,
|
|
102
|
+
scan_options: {files_only: true, min_confidence: min_confidence},
|
|
103
|
+
commits_scanned: 0,
|
|
104
|
+
detection_method: "gitleaks"
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
# Check if file path matches any exclusion pattern
|
|
111
|
+
# @param path [String] File path to check
|
|
112
|
+
# @return [Boolean] true if file should be excluded
|
|
113
|
+
def excluded_file?(path)
|
|
114
|
+
exclusions.any? do |pattern|
|
|
115
|
+
File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Count commits in repository
|
|
120
|
+
# @param since [String, nil] Start commit or date
|
|
121
|
+
# @return [Integer]
|
|
122
|
+
def count_commits(since: nil)
|
|
123
|
+
cmd = ["git", "-C", repository_path, "rev-list", "--count", "HEAD"]
|
|
124
|
+
cmd.insert(-2, "--since=#{since}") if since
|
|
125
|
+
|
|
126
|
+
output, status = Open3.capture2(*cmd, err: File::NULL)
|
|
127
|
+
status.success? ? output.strip.to_i : 0
|
|
128
|
+
rescue
|
|
129
|
+
0
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Filter tokens by minimum confidence
|
|
133
|
+
# @param tokens [Array<DetectedToken>]
|
|
134
|
+
# @param min_confidence [String]
|
|
135
|
+
# @return [Array<DetectedToken>]
|
|
136
|
+
def filter_by_confidence(tokens, min_confidence)
|
|
137
|
+
confidence_order = {"high" => 3, "medium" => 2, "low" => 1}
|
|
138
|
+
|
|
139
|
+
unless confidence_order.key?(min_confidence)
|
|
140
|
+
warn "Warning: Invalid confidence level '#{min_confidence}'. " \
|
|
141
|
+
"Valid values: high, medium, low. Defaulting to 'low'."
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
min_level = confidence_order[min_confidence] || 1
|
|
145
|
+
|
|
146
|
+
tokens.select do |token|
|
|
147
|
+
level = confidence_order[token.confidence] || 1
|
|
148
|
+
level >= min_level
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
module Secrets
|
|
6
|
+
module Molecules
|
|
7
|
+
# Orchestrates token revocation across multiple services
|
|
8
|
+
# Routes tokens to appropriate service handlers
|
|
9
|
+
class TokenRevoker
|
|
10
|
+
attr_reader :api_client
|
|
11
|
+
|
|
12
|
+
# @param api_client [Atoms::ServiceApiClient, nil] API client for revocation
|
|
13
|
+
def initialize(api_client: nil)
|
|
14
|
+
@api_client = api_client || Atoms::ServiceApiClient.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Revoke multiple tokens
|
|
18
|
+
# @param tokens [Array<DetectedToken>] Tokens to revoke
|
|
19
|
+
# @param services [Array<String>, nil] Filter to specific services
|
|
20
|
+
# @return [Array<Models::RevocationResult>]
|
|
21
|
+
def revoke_all(tokens, services: nil)
|
|
22
|
+
tokens.map do |token|
|
|
23
|
+
next unless token.revocable?
|
|
24
|
+
next if services && !services.include?(token.revocation_service)
|
|
25
|
+
|
|
26
|
+
revoke_token(token)
|
|
27
|
+
end.compact
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Revoke a single token
|
|
31
|
+
# @param token [DetectedToken] Token to revoke
|
|
32
|
+
# @return [Models::RevocationResult]
|
|
33
|
+
def revoke_token(token)
|
|
34
|
+
service = token.revocation_service
|
|
35
|
+
|
|
36
|
+
unless service
|
|
37
|
+
return Models::RevocationResult.unsupported(token: token)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
case service
|
|
41
|
+
when "github"
|
|
42
|
+
revoke_github(token)
|
|
43
|
+
when "anthropic", "openai", "aws"
|
|
44
|
+
# These services don't have public revocation APIs
|
|
45
|
+
manual_revocation_result(token, service)
|
|
46
|
+
else
|
|
47
|
+
Models::RevocationResult.unsupported(token: token, service: service)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Get revocation instructions for a token
|
|
52
|
+
# @param token [DetectedToken] Token to get instructions for
|
|
53
|
+
# @return [Hash] Instructions hash
|
|
54
|
+
def revocation_instructions(token)
|
|
55
|
+
service = token.revocation_service
|
|
56
|
+
api_client.build_revocation_request(service, token.raw_value)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# Revoke GitHub token via API
|
|
62
|
+
# @param token [DetectedToken]
|
|
63
|
+
# @return [Models::RevocationResult]
|
|
64
|
+
def revoke_github(token)
|
|
65
|
+
result = api_client.revoke_github_token(token.raw_value)
|
|
66
|
+
|
|
67
|
+
if result[:success]
|
|
68
|
+
Models::RevocationResult.success(
|
|
69
|
+
token: token,
|
|
70
|
+
service: "github",
|
|
71
|
+
message: result[:message]
|
|
72
|
+
)
|
|
73
|
+
else
|
|
74
|
+
Models::RevocationResult.failure(
|
|
75
|
+
token: token,
|
|
76
|
+
service: "github",
|
|
77
|
+
message: result[:message]
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Create result for services requiring manual revocation
|
|
83
|
+
# @param token [DetectedToken]
|
|
84
|
+
# @param service [String]
|
|
85
|
+
# @return [Models::RevocationResult]
|
|
86
|
+
def manual_revocation_result(token, service)
|
|
87
|
+
instructions = api_client.build_revocation_request(service, token.raw_value)
|
|
88
|
+
|
|
89
|
+
Models::RevocationResult.new(
|
|
90
|
+
token: token,
|
|
91
|
+
service: service,
|
|
92
|
+
status: "skipped",
|
|
93
|
+
message: "Manual revocation required. #{instructions[:notes]} Visit: #{instructions[:url]}"
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/b36ts"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Git
|
|
7
|
+
module Secrets
|
|
8
|
+
module Organisms
|
|
9
|
+
# Orchestrates the history cleaning workflow
|
|
10
|
+
# Combines scanning, confirmation, backup, and rewriting
|
|
11
|
+
#
|
|
12
|
+
# Requires gitleaks to be installed: brew install gitleaks
|
|
13
|
+
class HistoryCleaner
|
|
14
|
+
CONFIRMATION_TEXT = "REWRITE HISTORY"
|
|
15
|
+
|
|
16
|
+
attr_reader :rewriter, :scanner, :repository_path
|
|
17
|
+
|
|
18
|
+
# @param repository_path [String] Path to git repository
|
|
19
|
+
# @param gitleaks_config [String, nil] Path to gitleaks config file
|
|
20
|
+
# @param exclusions [Array<String>, nil] Glob patterns for files to exclude
|
|
21
|
+
def initialize(repository_path: ".", gitleaks_config: nil, exclusions: nil)
|
|
22
|
+
@repository_path = File.expand_path(repository_path)
|
|
23
|
+
@rewriter = Molecules::GitRewriter.new(repository_path: @repository_path)
|
|
24
|
+
@scanner = Molecules::HistoryScanner.new(
|
|
25
|
+
repository_path: @repository_path,
|
|
26
|
+
gitleaks_config: gitleaks_config,
|
|
27
|
+
exclusions: exclusions
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Clean tokens from history
|
|
32
|
+
# @param tokens [Array<DetectedToken>, nil] Tokens to remove (scans if nil)
|
|
33
|
+
# @param dry_run [Boolean] Preview only
|
|
34
|
+
# @param force [Boolean] Skip confirmation
|
|
35
|
+
# @param create_backup [Boolean] Create backup before rewriting
|
|
36
|
+
# @param backup_path [String, nil] Custom backup path
|
|
37
|
+
# @return [Hash] Result with :success, :message, :report keys
|
|
38
|
+
def clean(tokens: nil, dry_run: false, force: false, create_backup: true, backup_path: nil)
|
|
39
|
+
# Scan if tokens not provided (needed for both dry-run and actual run)
|
|
40
|
+
if tokens.nil?
|
|
41
|
+
report = scanner.scan
|
|
42
|
+
tokens = report.tokens
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if tokens.empty?
|
|
46
|
+
return {
|
|
47
|
+
success: true,
|
|
48
|
+
message: "No tokens found to remove. Repository is clean.",
|
|
49
|
+
tokens_removed: 0
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Dry run - just show what would be removed (no rewriter needed)
|
|
54
|
+
if dry_run
|
|
55
|
+
return dry_run_result(tokens)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Check prerequisites (only needed for actual rewrite)
|
|
59
|
+
unless rewriter.available?
|
|
60
|
+
return {
|
|
61
|
+
success: false,
|
|
62
|
+
message: Molecules::GitRewriter::FILTER_REPO_INSTALL_INSTRUCTIONS
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
unless rewriter.clean_working_directory?
|
|
67
|
+
return {
|
|
68
|
+
success: false,
|
|
69
|
+
message: "Working directory has uncommitted changes. Commit or stash first."
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Require confirmation unless forced
|
|
74
|
+
unless force
|
|
75
|
+
return {
|
|
76
|
+
success: false,
|
|
77
|
+
requires_confirmation: true,
|
|
78
|
+
message: confirmation_warning(tokens),
|
|
79
|
+
confirmation_text: CONFIRMATION_TEXT
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Create backup if requested
|
|
84
|
+
if create_backup
|
|
85
|
+
backup_result = create_repository_backup(backup_path)
|
|
86
|
+
unless backup_result[:success]
|
|
87
|
+
return {
|
|
88
|
+
success: false,
|
|
89
|
+
message: "Failed to create backup: #{backup_result[:message]}"
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
puts "Backup created at: #{backup_result[:path]}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Execute rewrite
|
|
96
|
+
result = rewriter.rewrite(tokens)
|
|
97
|
+
|
|
98
|
+
if result[:success]
|
|
99
|
+
{
|
|
100
|
+
success: true,
|
|
101
|
+
message: result[:message],
|
|
102
|
+
tokens_removed: tokens.size,
|
|
103
|
+
next_steps: post_rewrite_instructions
|
|
104
|
+
}
|
|
105
|
+
else
|
|
106
|
+
{
|
|
107
|
+
success: false,
|
|
108
|
+
message: result[:message]
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Validate confirmation text
|
|
114
|
+
# @param input [String] User input
|
|
115
|
+
# @return [Boolean]
|
|
116
|
+
def valid_confirmation?(input)
|
|
117
|
+
input.strip == CONFIRMATION_TEXT
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
# Generate dry run result
|
|
123
|
+
def dry_run_result(tokens)
|
|
124
|
+
{
|
|
125
|
+
success: true,
|
|
126
|
+
dry_run: true,
|
|
127
|
+
message: "Dry run: Would remove #{tokens.size} token(s) from history",
|
|
128
|
+
tokens: tokens.map do |t|
|
|
129
|
+
{
|
|
130
|
+
type: t.token_type,
|
|
131
|
+
masked_value: t.masked_value,
|
|
132
|
+
file: t.file_path,
|
|
133
|
+
commit: t.short_commit
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Generate confirmation warning
|
|
140
|
+
def confirmation_warning(tokens)
|
|
141
|
+
<<~WARNING
|
|
142
|
+
WARNING: This operation will rewrite Git history.
|
|
143
|
+
|
|
144
|
+
This action:
|
|
145
|
+
- Is IRREVERSIBLE (without backup)
|
|
146
|
+
- Will change commit SHAs
|
|
147
|
+
- Requires all collaborators to re-clone after completion
|
|
148
|
+
- Should only be done after revoking the tokens
|
|
149
|
+
|
|
150
|
+
Tokens to be removed: #{tokens.size}
|
|
151
|
+
|
|
152
|
+
To proceed, type exactly: #{CONFIRMATION_TEXT}
|
|
153
|
+
WARNING
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Create repository backup
|
|
157
|
+
def create_repository_backup(custom_path)
|
|
158
|
+
backup_path = custom_path || generate_backup_path
|
|
159
|
+
|
|
160
|
+
if rewriter.create_backup(backup_path)
|
|
161
|
+
{success: true, path: backup_path}
|
|
162
|
+
else
|
|
163
|
+
{success: false, message: "Could not create mirror clone"}
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Generate default backup path
|
|
168
|
+
def generate_backup_path
|
|
169
|
+
session_id = Ace::B36ts.encode(Time.now)
|
|
170
|
+
repo_name = File.basename(repository_path)
|
|
171
|
+
File.join(File.dirname(repository_path), "#{repo_name}-backup-#{session_id}.git")
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Instructions after successful rewrite
|
|
175
|
+
def post_rewrite_instructions
|
|
176
|
+
<<~INSTRUCTIONS
|
|
177
|
+
|
|
178
|
+
History has been rewritten successfully.
|
|
179
|
+
|
|
180
|
+
IMPORTANT: Complete these steps:
|
|
181
|
+
|
|
182
|
+
1. Verify the changes:
|
|
183
|
+
git log --oneline -20
|
|
184
|
+
|
|
185
|
+
2. Force push to remote:
|
|
186
|
+
git push --force-with-lease origin <branch>
|
|
187
|
+
|
|
188
|
+
3. Notify all collaborators to:
|
|
189
|
+
- Delete their local clones
|
|
190
|
+
- Re-clone the repository
|
|
191
|
+
|
|
192
|
+
4. If you created a backup, you can safely delete it after
|
|
193
|
+
confirming everything works correctly.
|
|
194
|
+
|
|
195
|
+
INSTRUCTIONS
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|