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,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
|