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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/git-secrets/config.yml +63 -0
  3. data/.ace-defaults/git-secrets/gitleaks.toml +14 -0
  4. data/.ace-defaults/nav/protocols/guide-sources/ace-git-secrets.yml +10 -0
  5. data/.ace-defaults/nav/protocols/wfi-sources/ace-git-secrets.yml +19 -0
  6. data/CHANGELOG.md +298 -0
  7. data/LICENSE +21 -0
  8. data/README.md +40 -0
  9. data/Rakefile +16 -0
  10. data/docs/demo/ace-git-secrets-getting-started.gif +0 -0
  11. data/docs/demo/ace-git-secrets-getting-started.tape.yml +38 -0
  12. data/docs/demo/fixtures/README.md +3 -0
  13. data/docs/demo/fixtures/sample.txt +1 -0
  14. data/docs/getting-started.md +109 -0
  15. data/docs/handbook.md +43 -0
  16. data/docs/usage.md +301 -0
  17. data/exe/ace-git-secrets +19 -0
  18. data/handbook/agents/security-audit.ag.md +237 -0
  19. data/handbook/guides/security/ruby.md +27 -0
  20. data/handbook/guides/security/rust.md +51 -0
  21. data/handbook/guides/security/typescript.md +33 -0
  22. data/handbook/guides/security.g.md +155 -0
  23. data/handbook/skills/as-git-security-audit/SKILL.md +29 -0
  24. data/handbook/skills/as-git-token-remediation/SKILL.md +21 -0
  25. data/handbook/workflow-instructions/git/security-audit.wf.md +247 -0
  26. data/handbook/workflow-instructions/git/token-remediation.wf.md +294 -0
  27. data/lib/ace/git/secrets/atoms/gitleaks_runner.rb +244 -0
  28. data/lib/ace/git/secrets/atoms/service_api_client.rb +188 -0
  29. data/lib/ace/git/secrets/cli/commands/check_release.rb +41 -0
  30. data/lib/ace/git/secrets/cli/commands/revoke.rb +44 -0
  31. data/lib/ace/git/secrets/cli/commands/rewrite.rb +46 -0
  32. data/lib/ace/git/secrets/cli/commands/scan.rb +51 -0
  33. data/lib/ace/git/secrets/cli.rb +75 -0
  34. data/lib/ace/git/secrets/commands/check_release_command.rb +48 -0
  35. data/lib/ace/git/secrets/commands/revoke_command.rb +199 -0
  36. data/lib/ace/git/secrets/commands/rewrite_command.rb +147 -0
  37. data/lib/ace/git/secrets/commands/scan_command.rb +113 -0
  38. data/lib/ace/git/secrets/models/detected_token.rb +129 -0
  39. data/lib/ace/git/secrets/models/revocation_result.rb +119 -0
  40. data/lib/ace/git/secrets/models/scan_report.rb +402 -0
  41. data/lib/ace/git/secrets/molecules/git_rewriter.rb +199 -0
  42. data/lib/ace/git/secrets/molecules/history_scanner.rb +155 -0
  43. data/lib/ace/git/secrets/molecules/token_revoker.rb +100 -0
  44. data/lib/ace/git/secrets/organisms/history_cleaner.rb +201 -0
  45. data/lib/ace/git/secrets/organisms/release_gate.rb +133 -0
  46. data/lib/ace/git/secrets/organisms/security_auditor.rb +220 -0
  47. data/lib/ace/git/secrets/version.rb +9 -0
  48. data/lib/ace/git/secrets.rb +168 -0
  49. 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