Skip to content

Commit 0085174

Browse files
Close bounded path and format review gaps
Reject symlinked parents, scan raw Git path bytes safely, and cover PGP headers plus serialized Windows home paths without expanding the audit architecture.
1 parent 3dcd7b7 commit 0085174

3 files changed

Lines changed: 91 additions & 11 deletions

File tree

public-source-release-audit/SKILL.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ Stage all intended release changes before relying on the candidate result.
3333
Ignored files and submodule contents are outside this check. The checker does
3434
not inspect Git history. Git LFS pointers fail the check because their external
3535
objects require separate review. Missing index blobs, including unavailable
36-
partial-clone objects, fail without network fetching.
36+
partial-clone objects, fail without network fetching. A symlink's own target
37+
text is scanned, while a symlinked parent component fails without reading
38+
outside the repository.
3739

3840
## Semantic Review
3941

public-source-release-audit/scripts/check_current_source.rb

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
RULES = [
88
[
99
"machine-local home path",
10-
%r{(?:\A|[\s"'`=:(,])(?:/(?:Users|home)/[A-Za-z0-9._-]+(?:/|\b)|/root(?:/|(?![A-Za-z0-9._-])))|\b[A-Za-z]:[\\/]Users[\\/][^\\/\r\n]+(?:[\\/]|$)}
10+
%r{(?:\A|[\s"'`=:(,])(?:/(?:Users|home)/[A-Za-z0-9._-]+(?:/|\b)|/root(?:/|(?![A-Za-z0-9._-])))|\b[A-Za-z]:[\\/]{1,2}Users[\\/]{1,2}[^\\/\r\n]+(?:[\\/]{1,2}|$)}
1111
],
1212
["AWS access key", /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
1313
["bearer credential", /Authorization\s*:\s*Bearer\s+[A-Za-z0-9._~+\/=:-]{16,}/i],
@@ -18,7 +18,7 @@
1818
["OpenAI API key", /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/],
1919
[
2020
"private key",
21-
/-----BEGIN (?:[A-Z0-9][A-Z0-9 ]* )?PRIVATE KEY-----/
21+
/-----BEGIN (?:[A-Z0-9][A-Z0-9 ]* )?PRIVATE KEY(?: BLOCK)?-----/
2222
]
2323
].freeze
2424

@@ -70,6 +70,14 @@ def display_path(relative_path, sensitive_paths)
7070
sensitive_paths.include?(relative_path) ? "<redacted path>" : relative_path.dump
7171
end
7272

73+
def symlinked_parent?(repo, relative_path)
74+
current = repo.b
75+
relative_path.b.split("/".b)[0...-1].any? do |component|
76+
current = File.join(current, component)
77+
File.symlink?(current)
78+
end
79+
end
80+
7381
usage("Too many arguments") if ARGV.length > 1
7482
repo = File.expand_path(ARGV.first || ".")
7583
usage("Repository must be a directory") unless File.directory?(repo)
@@ -86,9 +94,9 @@ def display_path(relative_path, sensitive_paths)
8694
findings = Set.new
8795
sensitive_paths = Set.new
8896
blob_result_cache = {}
89-
index_entries = index_output.split("\0").reject(&:empty?).filter_map do |record|
90-
metadata, relative_path = record.split("\t", 2)
91-
mode, object_id, stage = metadata&.split(" ", 3)
97+
index_entries = index_output.b.split("\0".b).reject(&:empty?).filter_map do |record|
98+
metadata, relative_path = record.split("\t".b, 2)
99+
mode, object_id, stage = metadata&.split(" ".b, 3)
92100
unless mode && object_id && stage && relative_path
93101
errors.add([relative_path || "<unknown>", "unreadable Git index entry"])
94102
next
@@ -146,11 +154,15 @@ def display_path(relative_path, sensitive_paths)
146154
)
147155
usage("Unable to enumerate repository worktree source") unless worktree_status.success?
148156

149-
worktree_paths = worktree_output.split("\0").reject(&:empty?).uniq
157+
worktree_paths = worktree_output.b.split("\0".b).reject(&:empty?).uniq
150158
worktree_paths.each do |relative_path|
151-
absolute_path = File.join(repo, relative_path)
152159
scan_source(relative_path, relative_path, findings, sensitive_paths)
153160
begin
161+
if symlinked_parent?(repo, relative_path)
162+
errors.add([relative_path, "symlinked parent component"])
163+
next
164+
end
165+
absolute_path = File.join(repo.b, relative_path)
154166
content = if File.symlink?(absolute_path)
155167
File.readlink(absolute_path)
156168
elsif File.file?(absolute_path)
@@ -164,10 +176,10 @@ def display_path(relative_path, sensitive_paths)
164176

165177
unless errors.empty? && findings.empty?
166178
warn "current public-source check failed:"
167-
errors.to_a.sort.each do |relative_path, label|
179+
errors.to_a.sort_by { |relative_path, label| [relative_path.b, label] }.each do |relative_path, label|
168180
warn "- #{display_path(relative_path, sensitive_paths)}: #{label}"
169181
end
170-
findings.to_a.sort.each do |relative_path, label|
182+
findings.to_a.sort_by { |relative_path, label| [relative_path.b, label] }.each do |relative_path, label|
171183
warn "- #{display_path(relative_path, sensitive_paths)}: #{label}"
172184
end
173185
warn "Matched content is intentionally omitted." unless findings.empty?

public-source-release-audit/tests/test_check_current_source.rb

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,19 @@ def test_supported_high_confidence_formats_are_detected_and_redacted
122122
"fine-grained.txt" => "github_" + "pat_" + ("C" * 24),
123123
"openai.txt" => "s" + "k-proj-" + ("D" * 24),
124124
"private-key.txt" => "-----BEGIN " + "OPENSSH PRIVATE KEY-----",
125+
"pgp-private-key.txt" => "-----BEGIN " + "PGP PRIVATE KEY BLOCK-----",
125126
"posix-home.txt" => "/" + "Users/example/private.txt",
126127
"root-home.txt" => "/" + "root/.ssh/id_ed25519",
127-
"windows-home.txt" => "C:" + "\\Users\\example\\private.txt"
128+
"windows-home.txt" => "C:" + "\\Users\\example\\private.txt",
129+
"escaped-windows-home.txt" => [
130+
"C:",
131+
"\\" * 2,
132+
"Users",
133+
"\\" * 2,
134+
"example",
135+
"\\" * 2,
136+
"private.txt"
137+
].join
128138
}
129139
fixtures.each { |path, content| stage(repo, path, content) }
130140

@@ -161,6 +171,27 @@ def test_symlink_target_is_scanned_without_following_it
161171
end
162172
end
163173

174+
def test_symlinked_parent_is_rejected_without_reading_outside_repo
175+
with_repo do |repo|
176+
stage(repo, "dir/file.txt", "safe candidate\n")
177+
FileUtils.mv(File.join(repo, "dir"), File.join(repo, "original-dir"))
178+
179+
Dir.mktmpdir("public-source-check-outside-") do |outside|
180+
token = github_token
181+
write(outside, "file.txt", token)
182+
File.symlink(outside, File.join(repo, "dir"))
183+
184+
_stdout, stderr, status = run_checker(repo)
185+
186+
refute status.success?
187+
assert_includes stderr, "dir/file.txt"
188+
assert_includes stderr, "symlinked parent component"
189+
refute_includes stderr, "GitHub token"
190+
refute_includes stderr, token
191+
end
192+
end
193+
end
194+
164195
def test_ignored_local_files_are_outside_the_release_candidate
165196
with_repo do |repo|
166197
stage(repo, ".gitignore", "ignored.txt\n")
@@ -235,6 +266,41 @@ def test_git_commands_disable_lazy_object_fetching
235266
end
236267
end
237268

269+
def test_non_utf8_git_paths_are_scanned_without_crashing
270+
with_repo do |repo|
271+
token = github_token
272+
Dir.mktmpdir("public-source-check-bin-") do |bin_dir|
273+
fake_git = File.join(bin_dir, "git")
274+
File.write(fake_git, <<~'RUBY')
275+
#!/usr/bin/env ruby
276+
STDOUT.binmode
277+
path = "raw-\xFF.txt".b
278+
if ARGV.include?("--stage")
279+
STDOUT.write("100644 #{'a' * 40} 0\t".b + path + "\0".b)
280+
elsif ARGV.include?("cat-file")
281+
STDOUT.write("gh" + "p_" + ("A" * 24))
282+
elsif ARGV.include?("-co")
283+
STDOUT.write(path + "\0".b)
284+
else
285+
abort "unexpected git invocation: #{ARGV.join(' ')}"
286+
end
287+
RUBY
288+
FileUtils.chmod(0o755, fake_git)
289+
290+
_stdout, stderr, status = run_checker(
291+
repo,
292+
{ "PATH" => [bin_dir, ENV.fetch("PATH")].join(File::PATH_SEPARATOR) }
293+
)
294+
295+
refute status.success?
296+
assert_includes stderr, "GitHub token"
297+
assert_includes stderr, "\\xFF"
298+
refute_includes stderr, "invalid byte sequence"
299+
refute_includes stderr, token
300+
end
301+
end
302+
end
303+
238304
def test_git_lfs_pointer_requires_separate_review
239305
with_repo do |repo|
240306
pointer = [

0 commit comments

Comments
 (0)