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,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Secrets
6
+ module Models
7
+ # Represents the result of a token revocation attempt
8
+ # Immutable value object containing revocation outcome
9
+ class RevocationResult
10
+ attr_reader :token, :service, :status, :message, :revoked_at
11
+
12
+ # Valid revocation statuses
13
+ STATUSES = %w[revoked failed unsupported skipped].freeze
14
+
15
+ # @param token [DetectedToken] The token that was revoked
16
+ # @param service [String] Service name (github, anthropic, openai, aws)
17
+ # @param status [String] Revocation status (revoked, failed, unsupported, skipped)
18
+ # @param message [String, nil] Additional message or error details
19
+ # @param revoked_at [Time, nil] Timestamp of revocation
20
+ def initialize(token:, service:, status:, message: nil, revoked_at: nil)
21
+ @token = token
22
+ @service = service
23
+ @status = validate_status(status)
24
+ @message = message
25
+ @revoked_at = revoked_at || ((status == "revoked") ? Time.now : nil)
26
+
27
+ freeze
28
+ end
29
+
30
+ # Check if revocation was successful
31
+ # @return [Boolean]
32
+ def success?
33
+ status == "revoked"
34
+ end
35
+
36
+ # Check if revocation failed
37
+ # @return [Boolean]
38
+ def failed?
39
+ status == "failed"
40
+ end
41
+
42
+ # Check if token type is unsupported for revocation
43
+ # @return [Boolean]
44
+ def unsupported?
45
+ status == "unsupported"
46
+ end
47
+
48
+ # Check if revocation was skipped
49
+ # @return [Boolean]
50
+ def skipped?
51
+ status == "skipped"
52
+ end
53
+
54
+ # Convert to hash for serialization
55
+ # @return [Hash]
56
+ def to_h
57
+ {
58
+ token_type: token.token_type,
59
+ masked_value: token.masked_value,
60
+ service: service,
61
+ status: status,
62
+ message: message,
63
+ revoked_at: revoked_at&.iso8601
64
+ }
65
+ end
66
+
67
+ # Create a successful revocation result
68
+ # @param token [DetectedToken] The token that was revoked
69
+ # @param service [String] Service name
70
+ # @param message [String, nil] Success message
71
+ # @return [RevocationResult]
72
+ def self.success(token:, service:, message: nil)
73
+ new(
74
+ token: token,
75
+ service: service,
76
+ status: "revoked",
77
+ message: message || "Token successfully revoked"
78
+ )
79
+ end
80
+
81
+ # Create a failed revocation result
82
+ # @param token [DetectedToken] The token that failed to revoke
83
+ # @param service [String] Service name
84
+ # @param message [String] Error message
85
+ # @return [RevocationResult]
86
+ def self.failure(token:, service:, message:)
87
+ new(
88
+ token: token,
89
+ service: service,
90
+ status: "failed",
91
+ message: message
92
+ )
93
+ end
94
+
95
+ # Create an unsupported revocation result
96
+ # @param token [DetectedToken] The token that cannot be revoked
97
+ # @param service [String, nil] Service name
98
+ # @return [RevocationResult]
99
+ def self.unsupported(token:, service: nil)
100
+ new(
101
+ token: token,
102
+ service: service || "unknown",
103
+ status: "unsupported",
104
+ message: "Token type #{token.token_type} does not support automatic revocation"
105
+ )
106
+ end
107
+
108
+ private
109
+
110
+ def validate_status(status)
111
+ return status if STATUSES.include?(status)
112
+
113
+ raise ArgumentError, "Invalid status: #{status}. Must be one of: #{STATUSES.join(", ")}"
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,402 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+ require "fileutils"
6
+ require "ace/b36ts"
7
+
8
+ module Ace
9
+ module Git
10
+ module Secrets
11
+ module Models
12
+ # Represents a complete scan report with detected tokens and metadata
13
+ class ScanReport
14
+ attr_reader :tokens, :repository_path, :scanned_at, :scan_options,
15
+ :commits_scanned, :detection_method, :scan_duration, :thread_count
16
+
17
+ # @param tokens [Array<DetectedToken>] Detected tokens
18
+ # @param repository_path [String] Path to scanned repository
19
+ # @param scanned_at [Time] When scan was performed
20
+ # @param scan_options [Hash] Options used for scanning
21
+ # @param commits_scanned [Integer] Number of commits scanned
22
+ # @param detection_method [String] Primary detection method used
23
+ # @param scan_duration [Float, nil] Scan duration in seconds
24
+ # @param thread_count [Integer, nil] Number of threads used for scanning
25
+ def initialize(tokens: [], repository_path: nil, scanned_at: nil,
26
+ scan_options: {}, commits_scanned: 0, detection_method: "ruby_patterns",
27
+ scan_duration: nil, thread_count: nil)
28
+ @tokens = tokens.freeze
29
+ @repository_path = repository_path
30
+ @scanned_at = scanned_at || Time.now
31
+ @scan_options = scan_options.freeze
32
+ @commits_scanned = commits_scanned
33
+ @detection_method = detection_method
34
+ @scan_duration = scan_duration
35
+ @thread_count = thread_count
36
+ end
37
+
38
+ # Check if any tokens were detected
39
+ # @return [Boolean]
40
+ def clean?
41
+ tokens.empty?
42
+ end
43
+
44
+ # Check if tokens were detected
45
+ # @return [Boolean]
46
+ def tokens_found?
47
+ !clean?
48
+ end
49
+
50
+ # Total number of detected tokens
51
+ # @return [Integer]
52
+ def token_count
53
+ tokens.size
54
+ end
55
+
56
+ # Get tokens by confidence level
57
+ # @param level [String] Confidence level (high, medium, low)
58
+ # @return [Array<DetectedToken>]
59
+ def tokens_by_confidence(level)
60
+ tokens.select { |t| t.confidence == level }
61
+ end
62
+
63
+ # Count of high confidence tokens
64
+ # @return [Integer]
65
+ def high_confidence_count
66
+ tokens_by_confidence("high").size
67
+ end
68
+
69
+ # Count of medium confidence tokens
70
+ # @return [Integer]
71
+ def medium_confidence_count
72
+ tokens_by_confidence("medium").size
73
+ end
74
+
75
+ # Count of low confidence tokens
76
+ # @return [Integer]
77
+ def low_confidence_count
78
+ tokens_by_confidence("low").size
79
+ end
80
+
81
+ # Get unique token types found
82
+ # @return [Array<String>]
83
+ def token_types
84
+ tokens.map(&:token_type).uniq.sort
85
+ end
86
+
87
+ # Get unique files with tokens
88
+ # @return [Array<String>]
89
+ def affected_files
90
+ tokens.map(&:file_path).uniq.sort
91
+ end
92
+
93
+ # Get unique commits with tokens
94
+ # @return [Array<String>]
95
+ def affected_commits
96
+ tokens.map(&:commit_hash).uniq
97
+ end
98
+
99
+ # Get tokens that can be revoked
100
+ # @return [Array<DetectedToken>]
101
+ def revocable_tokens
102
+ tokens.select(&:revocable?)
103
+ end
104
+
105
+ # Summary statistics
106
+ # @return [Hash]
107
+ def summary
108
+ {
109
+ total_tokens: token_count,
110
+ high_confidence: high_confidence_count,
111
+ medium_confidence: medium_confidence_count,
112
+ low_confidence: low_confidence_count,
113
+ token_types: token_types,
114
+ affected_files: affected_files.size,
115
+ affected_commits: affected_commits.size,
116
+ revocable: revocable_tokens.size,
117
+ detection_method: detection_method
118
+ }
119
+ end
120
+
121
+ # Convert to hash for serialization
122
+ # @param include_raw [Boolean] Whether to include raw token values
123
+ # @return [Hash]
124
+ def to_h(include_raw: false)
125
+ result = {
126
+ scan_metadata: {
127
+ repository: repository_path,
128
+ scanned_at: scanned_at.iso8601,
129
+ commits_scanned: commits_scanned,
130
+ scan_duration_seconds: scan_duration&.round(2),
131
+ thread_count: thread_count,
132
+ detection_method: detection_method
133
+ },
134
+ scan_options: scan_options,
135
+ summary: summary,
136
+ tokens: tokens.map { |t| t.to_h(include_raw: include_raw) }
137
+ }
138
+ # Remove nil values from scan_metadata
139
+ result[:scan_metadata].compact!
140
+ result
141
+ end
142
+
143
+ # Serialize to JSON
144
+ # @param include_raw [Boolean] Whether to include raw token values
145
+ # @return [String]
146
+ def to_json(include_raw: false)
147
+ JSON.pretty_generate(to_h(include_raw: include_raw))
148
+ end
149
+
150
+ # Serialize to YAML
151
+ # @param include_raw [Boolean] Whether to include raw token values
152
+ # @return [String]
153
+ def to_yaml(include_raw: false)
154
+ to_h(include_raw: include_raw).to_yaml
155
+ end
156
+
157
+ # Format as table for CLI output
158
+ # @return [String]
159
+ def to_table
160
+ return "No tokens detected. Repository is clean." if clean?
161
+
162
+ lines = []
163
+ lines << "Scan Report: #{repository_path}"
164
+ lines << "=" * 60
165
+ lines << "Scanned at: #{scanned_at}"
166
+ lines << "Commits scanned: #{commits_scanned}"
167
+ lines << "Detection method: #{detection_method}"
168
+ lines << ""
169
+ lines << "Summary:"
170
+ lines << " Total tokens: #{token_count}"
171
+ lines << " High confidence: #{high_confidence_count}"
172
+ lines << " Medium confidence: #{medium_confidence_count}"
173
+ lines << " Low confidence: #{low_confidence_count}"
174
+ lines << ""
175
+ lines << "Detected Tokens:"
176
+ lines << "-" * 60
177
+
178
+ tokens.each_with_index do |token, idx|
179
+ lines << "#{idx + 1}. #{token.token_type} (#{token.confidence})"
180
+ lines << " Value: #{token.masked_value}"
181
+ lines << " Commit: #{token.short_commit}"
182
+ lines << " File: #{token.file_path}#{":#{token.line_number}" if token.line_number}"
183
+ lines << " Detected by: #{token.detected_by}"
184
+ lines << ""
185
+ end
186
+
187
+ lines.join("\n")
188
+ end
189
+
190
+ # Save report to file in cache directory
191
+ # @param format [Symbol] Output format (:json or :markdown)
192
+ # @param directory [String, nil] Custom cache directory (defaults to .ace-local/git-secrets)
193
+ # @param include_raw [Boolean] Include raw token values in JSON (default: true for machine-readable)
194
+ # @param quiet [Boolean] Suppress security warning (default: false)
195
+ # @return [String] Path to saved report file
196
+ def save_to_file(format: :json, directory: nil, include_raw: true, quiet: false)
197
+ cache_dir = directory || File.join(repository_path || ".", ".ace-local", "git-secrets")
198
+ sessions_dir = File.join(cache_dir, "sessions")
199
+ FileUtils.mkdir_p(sessions_dir)
200
+
201
+ session_id = Ace::B36ts.encode(scanned_at)
202
+ ext = (format == :markdown) ? "md" : "json"
203
+ path = File.join(sessions_dir, "#{session_id}-report.#{ext}")
204
+
205
+ # JSON format includes raw values by default for revoke/rewrite-history workflows
206
+ # Markdown format never includes raw values (human-readable)
207
+ content = (format == :markdown) ? to_markdown : to_json(include_raw: include_raw)
208
+ File.write(path, content)
209
+
210
+ # Security warning: remind user that raw secrets are written to disk
211
+ if include_raw && tokens_found? && !quiet
212
+ warn "SECURITY: Report contains raw token values. Delete after remediation: #{path}"
213
+ end
214
+
215
+ # Generate providers report for revocation workflow (only when tokens found)
216
+ save_providers_report(sessions_dir, session_id) if tokens_found?
217
+
218
+ path
219
+ end
220
+
221
+ # Save providers-grouped markdown report for revocation workflow
222
+ # @param sessions_dir [String] Sessions directory to save report
223
+ # @param session_id [String] Session ID prefix for filename
224
+ # @return [String, nil] Path to saved report, or nil if no tokens
225
+ def save_providers_report(sessions_dir, session_id)
226
+ providers_content = to_providers_markdown
227
+ return nil unless providers_content
228
+
229
+ providers_path = File.join(sessions_dir, "#{session_id}-providers.md")
230
+ File.write(providers_path, providers_content)
231
+ providers_path
232
+ rescue => e
233
+ # Log error but don't fail main report save
234
+ warn "Warning: Could not save providers report: #{e.message}"
235
+ nil
236
+ end
237
+
238
+ # Generate concise summary for stdout
239
+ # @param report_path [String, nil] Path to full report file
240
+ # @return [String]
241
+ def to_summary(report_path: nil)
242
+ lines = []
243
+
244
+ # Timing and thread info
245
+ timing = scan_duration ? "in #{format_duration(scan_duration)}" : ""
246
+ threads = thread_count ? " (#{thread_count} threads)" : ""
247
+ lines << "Scan completed#{" " + timing unless timing.empty?}#{threads}"
248
+
249
+ # Token counts
250
+ lines << if clean?
251
+ "No tokens detected. Repository is clean."
252
+ else
253
+ "Tokens found: #{token_count} (high: #{high_confidence_count}, medium: #{medium_confidence_count})"
254
+ end
255
+
256
+ # Report path
257
+ lines << "Report saved: #{report_path}" if report_path
258
+
259
+ lines.join("\n")
260
+ end
261
+
262
+ # Get unique tokens grouped by raw_value with all their locations
263
+ # @return [Hash<String, Hash>] Map of raw_value => { token:, locations: [] }
264
+ def deduplicated_tokens
265
+ result = {}
266
+ tokens.each do |token|
267
+ if result.key?(token.raw_value)
268
+ result[token.raw_value][:locations] << {
269
+ commit: token.short_commit,
270
+ file: token.file_path,
271
+ line: token.line_number
272
+ }
273
+ else
274
+ result[token.raw_value] = {
275
+ token: token,
276
+ locations: [{
277
+ commit: token.short_commit,
278
+ file: token.file_path,
279
+ line: token.line_number
280
+ }]
281
+ }
282
+ end
283
+ end
284
+ result
285
+ end
286
+
287
+ # Format as providers-grouped markdown for revocation workflow
288
+ # @return [String]
289
+ def to_providers_markdown
290
+ return nil if clean?
291
+
292
+ deduped = deduplicated_tokens
293
+
294
+ # Group by provider, with revocable providers first
295
+ by_provider = deduped.values.group_by { |entry| entry[:token].provider_name }
296
+
297
+ # Sort providers: revocable first, manual last
298
+ provider_order = %w[GitHub AWS Anthropic OpenAI]
299
+ sorted_providers = by_provider.keys.sort_by do |name|
300
+ idx = provider_order.index(name)
301
+ idx.nil? ? provider_order.size : idx
302
+ end
303
+
304
+ lines = []
305
+ lines << "# Tokens to Revoke"
306
+ lines << ""
307
+ lines << "**Scan**: #{scanned_at.strftime("%Y-%m-%d %H:%M:%S")} | " \
308
+ "**Unique tokens**: #{deduped.size} | " \
309
+ "**Providers**: #{by_provider.size}"
310
+ lines << ""
311
+
312
+ sorted_providers.each do |provider_name|
313
+ provider_tokens = by_provider[provider_name]
314
+ lines << "## #{provider_name} (#{provider_tokens.size} token#{"s" if provider_tokens.size != 1})"
315
+ lines << ""
316
+
317
+ provider_tokens.each_with_index do |entry, idx|
318
+ token = entry[:token]
319
+ locations = entry[:locations]
320
+
321
+ lines << "### #{idx + 1}. `#{token.masked_value}` (#{token.token_type})"
322
+ lines << ""
323
+ lines << "**Locations:**"
324
+ locations.each do |loc|
325
+ line_suffix = loc[:line] ? ":#{loc[:line]}" : ""
326
+ lines << "- `#{loc[:commit]}` #{loc[:file]}#{line_suffix}"
327
+ end
328
+ lines << ""
329
+ end
330
+ end
331
+
332
+ lines.join("\n")
333
+ end
334
+
335
+ # Format as markdown for file output
336
+ # @return [String]
337
+ def to_markdown
338
+ lines = []
339
+ lines << "# Security Scan Report"
340
+ lines << ""
341
+ lines << "## Scan Metadata"
342
+ lines << ""
343
+ lines << "| Field | Value |"
344
+ lines << "|-------|-------|"
345
+ lines << "| Repository | `#{repository_path}` |"
346
+ lines << "| Scanned at | #{scanned_at.iso8601} |"
347
+ lines << "| Commits scanned | #{commits_scanned} |"
348
+ lines << "| Scan duration | #{scan_duration ? format_duration(scan_duration) : "N/A"} |"
349
+ lines << "| Thread count | #{thread_count || "N/A"} |"
350
+ lines << "| Detection method | #{detection_method} |"
351
+ lines << ""
352
+ lines << "## Summary"
353
+ lines << ""
354
+ lines << "| Metric | Count |"
355
+ lines << "|--------|-------|"
356
+ lines << "| Total tokens | #{token_count} |"
357
+ lines << "| High confidence | #{high_confidence_count} |"
358
+ lines << "| Medium confidence | #{medium_confidence_count} |"
359
+ lines << "| Low confidence | #{low_confidence_count} |"
360
+ lines << ""
361
+
362
+ if tokens.any?
363
+ lines << "## Detected Tokens"
364
+ lines << ""
365
+
366
+ tokens.each_with_index do |token, idx|
367
+ lines << "### #{idx + 1}. #{token.token_type}"
368
+ lines << ""
369
+ lines << "- **Confidence**: #{token.confidence}"
370
+ lines << "- **Value**: `#{token.masked_value}`"
371
+ lines << "- **Commit**: #{token.short_commit}"
372
+ lines << "- **File**: `#{token.file_path}#{":#{token.line_number}" if token.line_number}`"
373
+ lines << "- **Detected by**: #{token.detected_by}"
374
+ lines << ""
375
+ end
376
+ else
377
+ lines << "No tokens detected. Repository is clean."
378
+ lines << ""
379
+ end
380
+
381
+ lines.join("\n")
382
+ end
383
+
384
+ private
385
+
386
+ # Format duration in human-readable form
387
+ # @param seconds [Float]
388
+ # @return [String]
389
+ def format_duration(seconds)
390
+ if seconds < 60
391
+ "#{seconds.round(1)}s"
392
+ else
393
+ minutes = (seconds / 60).floor
394
+ remaining_seconds = (seconds % 60).round
395
+ "#{minutes}m #{remaining_seconds}s"
396
+ end
397
+ end
398
+ end
399
+ end
400
+ end
401
+ end
402
+ end