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,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Secrets
6
+ module Organisms
7
+ # Pre-release security gate
8
+ # Blocks releases if tokens are detected in history
9
+ #
10
+ # Requires gitleaks to be installed: brew install gitleaks
11
+ class ReleaseGate
12
+ attr_reader :scanner, :strict_mode
13
+
14
+ # @param repository_path [String] Path to git repository
15
+ # @param gitleaks_config [String, nil] Path to gitleaks config file
16
+ # @param strict [Boolean] Fail on medium confidence matches too
17
+ # @param exclusions [Array<String>, nil] Glob patterns for files to exclude
18
+ def initialize(repository_path: ".", gitleaks_config: nil, strict: false, exclusions: nil)
19
+ @scanner = Molecules::HistoryScanner.new(
20
+ repository_path: repository_path,
21
+ gitleaks_config: gitleaks_config,
22
+ exclusions: exclusions
23
+ )
24
+ @strict_mode = strict
25
+ end
26
+
27
+ # Run pre-release security check
28
+ # @return [Hash] Result with :passed, :message, :report keys
29
+ def check
30
+ min_confidence = strict_mode ? "medium" : "high"
31
+
32
+ report = scanner.scan(min_confidence: min_confidence)
33
+
34
+ if report.clean?
35
+ {
36
+ passed: true,
37
+ exit_code: 0,
38
+ message: "Pre-release security check: PASSED",
39
+ summary: "No authentication tokens detected in Git history.",
40
+ report: report
41
+ }
42
+ else
43
+ {
44
+ passed: false,
45
+ exit_code: 1,
46
+ message: "Pre-release security check: FAILED",
47
+ summary: failure_summary(report),
48
+ report: report,
49
+ remediation: remediation_steps(report)
50
+ }
51
+ end
52
+ end
53
+
54
+ # Format result for CI output
55
+ # @param result [Hash] Check result
56
+ # @param format [String] Output format (table, json)
57
+ # @return [String]
58
+ def format_result(result, format: "table")
59
+ case format
60
+ when "json"
61
+ require "json"
62
+ JSON.pretty_generate({
63
+ passed: result[:passed],
64
+ message: result[:message],
65
+ token_count: result[:report].token_count,
66
+ tokens: result[:report].tokens.map { |t| t.to_h }
67
+ })
68
+ else
69
+ format_table_result(result)
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def failure_summary(report)
76
+ lines = []
77
+ lines << "#{report.token_count} authentication token(s) detected in Git history."
78
+ lines << ""
79
+ lines << "Summary by type:"
80
+
81
+ report.token_types.each do |type|
82
+ count = report.tokens.count { |t| t.token_type == type }
83
+ lines << " - #{type}: #{count}"
84
+ end
85
+
86
+ lines << ""
87
+ lines << "Release blocked until tokens are removed."
88
+
89
+ lines.join("\n")
90
+ end
91
+
92
+ def remediation_steps(report)
93
+ <<~STEPS
94
+ To fix this issue:
95
+
96
+ 1. Review detected tokens:
97
+ ace-git-secrets scan
98
+
99
+ 2. Revoke compromised tokens immediately:
100
+ ace-git-secrets revoke
101
+
102
+ 3. Remove tokens from Git history:
103
+ ace-git-secrets rewrite-history
104
+
105
+ 4. Force push cleaned history:
106
+ git push --force-with-lease
107
+
108
+ 5. Re-run this check:
109
+ ace-git-secrets check-release
110
+ STEPS
111
+ end
112
+
113
+ def format_table_result(result)
114
+ lines = []
115
+ lines << "=" * 60
116
+ lines << result[:message]
117
+ lines << "=" * 60
118
+ lines << ""
119
+ lines << result[:summary]
120
+
121
+ unless result[:passed]
122
+ lines << ""
123
+ lines << "-" * 60
124
+ lines << result[:remediation]
125
+ end
126
+
127
+ lines.join("\n")
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Secrets
6
+ module Organisms
7
+ # Orchestrates security scanning and reporting
8
+ # High-level workflow for detecting tokens in repositories
9
+ #
10
+ # Uses gitleaks for token detection with whitelist filtering,
11
+ # formatted reporting, and actionable next steps.
12
+ #
13
+ # Requires gitleaks to be installed: brew install gitleaks
14
+ class SecurityAuditor
15
+ attr_reader :scanner, :output_format, :whitelist, :whitelisted_count, :whitelist_audit_log
16
+
17
+ # @param repository_path [String] Path to git repository
18
+ # @param gitleaks_config [String, nil] Path to gitleaks config file
19
+ # @param output_format [String] Output format (table, json, yaml)
20
+ # @param whitelist [Array<Hash>] Patterns/files to whitelist
21
+ # @param exclusions [Array<String>, nil] Glob patterns for files to exclude
22
+ def initialize(repository_path: ".", gitleaks_config: nil, output_format: "table",
23
+ whitelist: [], exclusions: nil)
24
+ @scanner = Molecules::HistoryScanner.new(
25
+ repository_path: repository_path,
26
+ gitleaks_config: gitleaks_config,
27
+ exclusions: exclusions
28
+ )
29
+ @output_format = output_format
30
+ @whitelist = whitelist || []
31
+ @whitelisted_count = 0
32
+ @whitelist_audit_log = []
33
+ end
34
+
35
+ # Run full security audit on repository
36
+ # @param since [String, nil] Start commit or date
37
+ # @param min_confidence [String] Minimum confidence level
38
+ # @param output_path [String, nil] Path to save report
39
+ # @param verbose [Boolean] Enable verbose output
40
+ # @return [Models::ScanReport]
41
+ def audit(since: nil, min_confidence: "low", output_path: nil, verbose: false)
42
+ puts "Scanning Git history for authentication tokens..." if verbose
43
+
44
+ start_time = Time.now
45
+
46
+ report = scanner.scan(
47
+ since: since,
48
+ min_confidence: min_confidence
49
+ )
50
+
51
+ scan_duration = Time.now - start_time
52
+
53
+ # Apply whitelist filtering
54
+ report = apply_whitelist(report) if whitelist.any?
55
+
56
+ # Add timing metadata to report
57
+ report = add_timing_metadata(report, scan_duration)
58
+
59
+ # Output results
60
+ output_report(report, output_path)
61
+
62
+ report
63
+ end
64
+
65
+ # Audit only current files (no history)
66
+ # @param min_confidence [String] Minimum confidence level
67
+ # @param output_path [String, nil] Path to save report
68
+ # @return [Models::ScanReport]
69
+ def audit_files(min_confidence: "low", output_path: nil)
70
+ report = scanner.scan_files(min_confidence: min_confidence)
71
+ output_report(report, output_path)
72
+ report
73
+ end
74
+
75
+ # Get formatted output
76
+ # @param report [Models::ScanReport]
77
+ # @return [String]
78
+ def format_report(report)
79
+ case output_format
80
+ when "json"
81
+ report.to_json
82
+ when "yaml"
83
+ report.to_yaml
84
+ else
85
+ report.to_table
86
+ end
87
+ end
88
+
89
+ # Print actionable next steps
90
+ # @param report [Models::ScanReport]
91
+ # @return [String]
92
+ def next_steps(report)
93
+ return "No tokens detected. Repository is clean." if report.clean?
94
+
95
+ steps = []
96
+ steps << "SECURITY ALERT: #{report.token_count} token(s) detected in repository"
97
+ steps << ""
98
+ steps << "Recommended next steps:"
99
+ steps << "1. Review detected tokens to confirm they are real (not false positives)"
100
+ steps << "2. Revoke tokens immediately: ace-git-secrets revoke"
101
+ steps << "3. Remove tokens from history: ace-git-secrets rewrite-history"
102
+ steps << "4. Force push the cleaned history: git push --force-with-lease"
103
+ steps << "5. Notify affected team members to re-clone"
104
+ steps << ""
105
+
106
+ if report.revocable_tokens.any?
107
+ steps << "Tokens that can be revoked via API: #{report.revocable_tokens.size}"
108
+ end
109
+
110
+ manual = report.tokens.reject(&:revocable?)
111
+ if manual.any?
112
+ steps << "Tokens requiring manual revocation: #{manual.size}"
113
+ steps << " Visit provider dashboards to revoke these manually."
114
+ end
115
+
116
+ steps.join("\n")
117
+ end
118
+
119
+ private
120
+
121
+ # Output report to file or stdout
122
+ def output_report(report, output_path)
123
+ formatted = format_report(report)
124
+
125
+ if output_path
126
+ File.write(output_path, formatted)
127
+ puts "Report saved to: #{output_path}"
128
+ end
129
+
130
+ formatted
131
+ end
132
+
133
+ # Apply whitelist filtering to scan report
134
+ # @param report [Models::ScanReport] Original report
135
+ # @return [Models::ScanReport] Filtered report
136
+ def apply_whitelist(report)
137
+ whitelisted_tokens = []
138
+ filtered_tokens = report.tokens.reject do |token|
139
+ is_whitelisted = whitelisted?(token)
140
+ whitelisted_tokens << token if is_whitelisted
141
+ is_whitelisted
142
+ end
143
+
144
+ # Track whitelisted count for display
145
+ @whitelisted_count = whitelisted_tokens.size
146
+
147
+ # Return new report with filtered tokens
148
+ Models::ScanReport.new(
149
+ tokens: filtered_tokens,
150
+ repository_path: report.repository_path,
151
+ scanned_at: report.scanned_at,
152
+ scan_options: report.scan_options,
153
+ commits_scanned: report.commits_scanned,
154
+ detection_method: report.detection_method
155
+ )
156
+ end
157
+
158
+ # Check if a token matches any whitelist entry
159
+ # @param token [Models::DetectedToken] Token to check
160
+ # @return [Boolean] true if whitelisted
161
+ def whitelisted?(token)
162
+ whitelist.any? do |entry|
163
+ if matches_whitelist_entry?(token, entry)
164
+ # Audit log for security review: track what was whitelisted and why
165
+ @whitelist_audit_log << {
166
+ token_masked: token.masked_value,
167
+ token_type: token.token_type,
168
+ file_path: token.file_path,
169
+ matched_entry: entry,
170
+ reason: entry["reason"]
171
+ }
172
+ true
173
+ else
174
+ false
175
+ end
176
+ end
177
+ end
178
+
179
+ # Check if token matches a specific whitelist entry
180
+ # @param token [Models::DetectedToken]
181
+ # @param entry [Hash] Whitelist entry with :pattern or :file key
182
+ # @return [Boolean]
183
+ def matches_whitelist_entry?(token, entry)
184
+ # Match by pattern (exact token value match)
185
+ if entry["pattern"]
186
+ return true if token.raw_value == entry["pattern"]
187
+ end
188
+
189
+ # Match by file path (glob pattern)
190
+ # FNM_EXTGLOB enables ** to match across directories
191
+ # FNM_DOTMATCH enables matching dotfiles like .env, .claude*
192
+ if entry["file"]
193
+ pattern = entry["file"]
194
+ flags = File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH
195
+ return true if File.fnmatch?(pattern, token.file_path, flags)
196
+ end
197
+
198
+ false
199
+ end
200
+
201
+ # Add timing metadata to report
202
+ # @param report [Models::ScanReport] Original report
203
+ # @param scan_duration [Float] Scan duration in seconds
204
+ # @return [Models::ScanReport] Report with timing metadata
205
+ def add_timing_metadata(report, scan_duration)
206
+ Models::ScanReport.new(
207
+ tokens: report.tokens,
208
+ repository_path: report.repository_path,
209
+ scanned_at: report.scanned_at,
210
+ scan_options: report.scan_options,
211
+ commits_scanned: report.commits_scanned,
212
+ detection_method: report.detection_method,
213
+ scan_duration: scan_duration
214
+ )
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Secrets
6
+ VERSION = "0.13.0"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "secrets/version"
4
+
5
+ # Load ace-config for configuration cascade management
6
+ require "ace/support/config"
7
+
8
+ # Models
9
+ require_relative "secrets/models/detected_token"
10
+ require_relative "secrets/models/revocation_result"
11
+ require_relative "secrets/models/scan_report"
12
+
13
+ # Atoms
14
+ require_relative "secrets/atoms/gitleaks_runner"
15
+ require_relative "secrets/atoms/service_api_client"
16
+
17
+ # Molecules
18
+ require_relative "secrets/molecules/history_scanner"
19
+ require_relative "secrets/molecules/git_rewriter"
20
+ require_relative "secrets/molecules/token_revoker"
21
+
22
+ # Organisms
23
+ require_relative "secrets/organisms/security_auditor"
24
+ require_relative "secrets/organisms/history_cleaner"
25
+ require_relative "secrets/organisms/release_gate"
26
+
27
+ # Commands
28
+ require_relative "secrets/commands/scan_command"
29
+ require_relative "secrets/commands/rewrite_command"
30
+ require_relative "secrets/commands/revoke_command"
31
+ require_relative "secrets/commands/check_release_command"
32
+
33
+ # CLI
34
+ require_relative "secrets/cli"
35
+
36
+ module Ace
37
+ module Git
38
+ module Secrets
39
+ class Error < StandardError; end
40
+ class GitRewriteError < Error; end
41
+ class RevocationError < Error; end
42
+
43
+ # Mutex for thread-safe config loading
44
+ @config_mutex = Mutex.new
45
+
46
+ # Load ace-git-secrets configuration using ace-config cascade
47
+ # Follows ADR-022: Load defaults from .ace-defaults/, merge user overrides from .ace/
48
+ # Uses Ace::Support::Config.create() for configuration cascade resolution
49
+ #
50
+ # @note Thread Safety: This method is thread-safe via Mutex synchronization.
51
+ # The config is loaded once and cached for subsequent calls.
52
+ # IMPORTANT: Config MUST be preloaded via CLI.start before parallel operations
53
+ # begin. When using ace-git-secrets as a library (not via CLI), call
54
+ # Ace::Git::Secrets.config explicitly before spawning any threads that
55
+ # perform scanning or revocation. Failure to preload may result in race
56
+ # conditions during config initialization under concurrent load.
57
+ #
58
+ # @return [Hash] Configuration hash
59
+ def self.config
60
+ @config_mutex.synchronize do
61
+ @config ||= begin
62
+ gem_root = Gem.loaded_specs["ace-git-secrets"]&.gem_dir ||
63
+ File.expand_path("../../..", __dir__)
64
+
65
+ resolver = Ace::Support::Config.create(
66
+ config_dir: ".ace",
67
+ defaults_dir: ".ace-defaults",
68
+ gem_path: gem_root
69
+ )
70
+
71
+ # Resolve config for git-secrets namespace
72
+ config = resolver.resolve_namespace("git-secrets")
73
+
74
+ # Extract git-secrets section if present
75
+ config.data["git-secrets"] || config.data
76
+ rescue => e
77
+ warn "Warning: Could not load ace-git-secrets config: #{e.message}"
78
+ fallback_defaults
79
+ end
80
+ end
81
+ end
82
+
83
+ # Fallback defaults when config loading fails
84
+ # Note: Should rarely be used - .ace-defaults/ should always be present
85
+ # @return [Hash] Minimal fallback configuration
86
+ def self.fallback_defaults
87
+ {
88
+ "exclusions" => [],
89
+ "whitelist" => [],
90
+ "output" => {
91
+ "format" => "table",
92
+ "mask_tokens" => true
93
+ }
94
+ }
95
+ end
96
+
97
+ # Get file exclusions from config
98
+ # ADR-022: Exclusions come from .ace-defaults/, merged with user config
99
+ # @return [Array<String>] Glob patterns for files to exclude
100
+ def self.exclusions
101
+ config["exclusions"] || []
102
+ end
103
+
104
+ # Resolve gitleaks config path with cascade
105
+ # Checks: .ace/git-secrets/gitleaks.toml -> .ace-defaults/git-secrets/gitleaks.toml
106
+ #
107
+ # @note Thread Safety: This method uses the same mutex as config to ensure
108
+ # thread-safe initialization. Like config, it should be preloaded before
109
+ # spawning threads (the CLI does this automatically via CLI.start).
110
+ # @note Environment Variable: Set ACE_GITLEAKS_CONFIG_PATH to override
111
+ # automatic config discovery (useful for testing).
112
+ # @return [String, nil] Path to gitleaks config, or nil if not found
113
+ def self.gitleaks_config_path
114
+ @config_mutex.synchronize do
115
+ @gitleaks_config_path ||= begin
116
+ # Check environment variable override first (useful for testing)
117
+ env_path = ENV["ACE_GITLEAKS_CONFIG_PATH"]
118
+ if env_path && File.exist?(env_path)
119
+ env_path
120
+ else
121
+ # Check user config first (project .ace/)
122
+ user_path = find_user_gitleaks_config
123
+ if user_path && File.exist?(user_path)
124
+ user_path
125
+ else
126
+ # Fall back to gem defaults
127
+ gem_root = File.expand_path("../../..", __dir__)
128
+ example_path = File.join(gem_root, ".ace-defaults", "git-secrets", "gitleaks.toml")
129
+ File.exist?(example_path) ? example_path : nil
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+
136
+ # Find user gitleaks config in project .ace/ directory
137
+ # @return [String, nil] Path to user gitleaks config
138
+ def self.find_user_gitleaks_config
139
+ # Search from current dir upward for .ace/git-secrets/gitleaks.toml
140
+ dir = Dir.pwd
141
+ while dir != "/"
142
+ config_path = File.join(dir, ".ace", "git-secrets", "gitleaks.toml")
143
+ return config_path if File.exist?(config_path)
144
+ dir = File.dirname(dir)
145
+ end
146
+ nil
147
+ end
148
+
149
+ # Check if gitleaks is available in PATH
150
+ # @return [Boolean] true if gitleaks is available
151
+ def self.gitleaks_available?
152
+ @gitleaks_available ||= system("which gitleaks > /dev/null 2>&1")
153
+ end
154
+
155
+ # Reset config cache
156
+ # Useful for testing to ensure clean state between tests.
157
+ # Thread-safe - uses mutex to reset all cached values atomically.
158
+ # @return [void]
159
+ def self.reset_config!
160
+ @config_mutex.synchronize do
161
+ @config = nil
162
+ @gitleaks_config_path = nil
163
+ end
164
+ @gitleaks_available = nil
165
+ end
166
+ end
167
+ end
168
+ end