purl 0.1.0 → 1.1.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.
data/Rakefile CHANGED
@@ -6,3 +6,541 @@ require "minitest/test_task"
6
6
  Minitest::TestTask.create
7
7
 
8
8
  task default: :test
9
+
10
+ namespace :spec do
11
+ desc "Show available PURL specification tasks"
12
+ task :help do
13
+ puts "šŸ”§ PURL Specification Tasks"
14
+ puts "=" * 30
15
+ puts "rake spec:update - Fetch latest test cases from official PURL spec repository"
16
+ puts "rake spec:stats - Show statistics about current test suite data"
17
+ puts "rake spec:compliance - Run all compliance tests against the official test suite"
18
+ puts "rake spec:debug - Show detailed info about failing test cases"
19
+ puts "rake spec:types - Show information about all PURL types and their support"
20
+ puts "rake spec:verify_types - Verify our types list against the official specification"
21
+ puts "rake spec:validate_schemas - Validate JSON files against their schemas"
22
+ puts "rake spec:validate_examples - Validate all PURL examples in purl-types.json"
23
+ puts "rake spec:help - Show this help message"
24
+ puts
25
+ puts "Example workflow:"
26
+ puts " 1. rake spec:update # Get latest test cases"
27
+ puts " 2. rake spec:stats # Review test suite composition"
28
+ puts " 3. rake spec:compliance # Run compliance tests"
29
+ puts " 4. rake spec:debug # Debug any failures"
30
+ puts
31
+ puts "The test suite data is stored in test-suite-data.json at the project root."
32
+ end
33
+
34
+ desc "Import/update official PURL specification test cases"
35
+ task :update do
36
+ require "net/http"
37
+ require "uri"
38
+ require "json"
39
+ require "fileutils"
40
+
41
+ puts "Fetching official PURL specification test cases..."
42
+
43
+ # URL for the official test suite data
44
+ url = "https://raw.githubusercontent.com/package-url/purl-spec/master/test-suite-data.json"
45
+ test_file_path = File.join(__dir__, "test-suite-data.json")
46
+ backup_path = "#{test_file_path}.backup"
47
+
48
+ begin
49
+ # Create backup of existing file if it exists
50
+ if File.exist?(test_file_path)
51
+ puts "Creating backup of existing test file..."
52
+ FileUtils.cp(test_file_path, backup_path)
53
+ end
54
+
55
+ # Fetch the latest test data
56
+ uri = URI(url)
57
+ response = Net::HTTP.get_response(uri)
58
+
59
+ if response.code == "200"
60
+ # Validate that we got valid JSON
61
+ test_data = JSON.parse(response.body)
62
+
63
+ # Write the new test data
64
+ File.write(test_file_path, response.body)
65
+
66
+ puts "āœ… Successfully updated test suite data!"
67
+ puts " - Test cases: #{test_data.length}"
68
+ puts " - File: #{test_file_path}"
69
+
70
+ # Remove backup if update was successful
71
+ File.delete(backup_path) if File.exist?(backup_path)
72
+
73
+ # Show summary of test case types
74
+ types = test_data.group_by { |tc| tc["type"] || "unknown" }.transform_values(&:count)
75
+ puts "\nšŸ“Š Test case distribution by package type:"
76
+ types.sort_by { |type, _| type.to_s }.each do |type, count|
77
+ puts " #{type}: #{count} cases"
78
+ end
79
+
80
+ # Show invalid vs valid cases
81
+ invalid_count = test_data.count { |tc| tc["is_invalid"] }
82
+ valid_count = test_data.count { |tc| !tc["is_invalid"] }
83
+ puts "\nšŸ“‹ Test case categories:"
84
+ puts " Valid cases: #{valid_count}"
85
+ puts " Invalid cases: #{invalid_count}"
86
+
87
+ else
88
+ raise "HTTP request failed with status #{response.code}: #{response.message}"
89
+ end
90
+
91
+ rescue => e
92
+ puts "āŒ Failed to update test suite data: #{e.message}"
93
+
94
+ # Restore backup if update failed
95
+ if File.exist?(backup_path)
96
+ puts "Restoring backup..."
97
+ FileUtils.mv(backup_path, test_file_path)
98
+ end
99
+
100
+ exit 1
101
+ end
102
+ end
103
+
104
+ desc "Show current test suite statistics"
105
+ task :stats do
106
+ require "json"
107
+
108
+ test_file_path = File.join(__dir__, "test-suite-data.json")
109
+
110
+ unless File.exist?(test_file_path)
111
+ puts "āŒ Test suite data file not found. Run 'rake spec:update' first."
112
+ exit 1
113
+ end
114
+
115
+ begin
116
+ test_data = JSON.parse(File.read(test_file_path))
117
+
118
+ puts "šŸ“Š PURL Test Suite Statistics"
119
+ puts "=" * 40
120
+ puts "Total test cases: #{test_data.length}"
121
+ puts "File location: #{test_file_path}"
122
+ puts "File size: #{File.size(test_file_path)} bytes"
123
+ puts "Last modified: #{File.mtime(test_file_path)}"
124
+
125
+ # Distribution by package type
126
+ puts "\nšŸ“¦ Distribution by package type:"
127
+ types = test_data.group_by { |tc| tc["type"] || "unknown" }.transform_values(&:count)
128
+ types.sort_by { |_, count| -count }.each do |type, count|
129
+ percentage = (count.to_f / test_data.length * 100).round(1)
130
+ puts " #{type.to_s.ljust(12)} #{count.to_s.rjust(3)} cases (#{percentage}%)"
131
+ end
132
+
133
+ # Valid vs invalid cases
134
+ invalid_count = test_data.count { |tc| tc["is_invalid"] }
135
+ valid_count = test_data.count { |tc| !tc["is_invalid"] }
136
+ puts "\nāœ… Test case validity:"
137
+ puts " Valid cases: #{valid_count} (#{(valid_count.to_f / test_data.length * 100).round(1)}%)"
138
+ puts " Invalid cases: #{invalid_count} (#{(invalid_count.to_f / test_data.length * 100).round(1)}%)"
139
+
140
+ # Cases with different components
141
+ has_namespace = test_data.count { |tc| tc["namespace"] }
142
+ has_version = test_data.count { |tc| tc["version"] }
143
+ has_qualifiers = test_data.count { |tc| tc["qualifiers"] && !tc["qualifiers"].empty? }
144
+ has_subpath = test_data.count { |tc| tc["subpath"] }
145
+
146
+ puts "\nšŸ”§ Component usage:"
147
+ puts " With namespace: #{has_namespace} cases"
148
+ puts " With version: #{has_version} cases"
149
+ puts " With qualifiers: #{has_qualifiers} cases"
150
+ puts " With subpath: #{has_subpath} cases"
151
+
152
+ rescue JSON::ParserError => e
153
+ puts "āŒ Failed to parse test suite data: #{e.message}"
154
+ exit 1
155
+ rescue => e
156
+ puts "āŒ Error reading test suite data: #{e.message}"
157
+ exit 1
158
+ end
159
+ end
160
+
161
+ desc "Run compliance tests against the official test suite"
162
+ task :compliance do
163
+ puts "Running PURL specification compliance tests..."
164
+ system("ruby test/test_purl_spec_compliance.rb")
165
+ end
166
+
167
+ desc "Verify our PURL types against the official specification"
168
+ task :verify_types do
169
+ require "net/http"
170
+ require "uri"
171
+ require_relative "lib/purl"
172
+
173
+ puts "šŸ” Verifying PURL Types Against Official Specification"
174
+ puts "=" * 60
175
+
176
+ begin
177
+ # Fetch the official PURL-TYPES.rst file
178
+ url = "https://raw.githubusercontent.com/package-url/purl-spec/main/PURL-TYPES.rst"
179
+ uri = URI(url)
180
+ response = Net::HTTP.get_response(uri)
181
+
182
+ if response.code != "200"
183
+ puts "āŒ Failed to fetch official specification: HTTP #{response.code}"
184
+ exit 1
185
+ end
186
+
187
+ content = response.body
188
+
189
+ # Extract type names from the specification
190
+ # Look for lines like "**alpm**" or lines that start type definitions
191
+ official_types = []
192
+ content.scan(/^\*\*(\w+)\*\*/) do |match|
193
+ official_types << match[0].downcase
194
+ end
195
+
196
+ # Also look for types in different format patterns (but be more restrictive)
197
+ content.scan(/^(\w+)\s*$/) do |match|
198
+ type = match[0].downcase
199
+ # Filter out common words that aren't types and document sections
200
+ unless %w[types purl package url specification license abstract].include?(type)
201
+ official_types << type if type.length > 2 && type != "license"
202
+ end
203
+ end
204
+
205
+ official_types = official_types.uniq.sort
206
+ our_types = Purl.known_types.sort
207
+
208
+ puts "šŸ“Š Comparison Results:"
209
+ puts " Official specification: #{official_types.length} types"
210
+ puts " Our implementation: #{our_types.length} types"
211
+
212
+ # Find missing types
213
+ missing_from_ours = official_types - our_types
214
+ extra_in_ours = our_types - official_types
215
+
216
+ if missing_from_ours.empty? && extra_in_ours.empty?
217
+ puts "\nāœ… Perfect match! All types are synchronized."
218
+ else
219
+ if missing_from_ours.any?
220
+ puts "\nāŒ Types in specification but missing from our list:"
221
+ missing_from_ours.each { |type| puts " - #{type}" }
222
+ end
223
+
224
+ if extra_in_ours.any?
225
+ puts "\nāš ļø Types in our list but not found in specification:"
226
+ extra_in_ours.each { |type| puts " + #{type}" }
227
+ end
228
+ end
229
+
230
+ puts "\nšŸ“‹ All Official Types Found:"
231
+ official_types.each_with_index do |type, index|
232
+ status = our_types.include?(type) ? "āœ“" : "āœ—"
233
+ puts " #{status} #{(index + 1).to_s.rjust(2)}. #{type}"
234
+ end
235
+
236
+ rescue => e
237
+ puts "āŒ Error verifying types: #{e.message}"
238
+ exit 1
239
+ end
240
+ end
241
+
242
+ desc "Show information about PURL types"
243
+ task :types do
244
+ require_relative "lib/purl"
245
+
246
+ puts "šŸ” PURL Type Information"
247
+ puts "=" * 40
248
+
249
+ puts "\nšŸ“‹ All Known PURL Types (#{Purl.known_types.length}):"
250
+ Purl.known_types.each_slice(4) do |slice|
251
+ puts " #{slice.map { |t| t.ljust(12) }.join(" ")}"
252
+ end
253
+
254
+ puts "\n🌐 Registry URL Generation Support (#{Purl.registry_supported_types.length}):"
255
+ Purl.registry_supported_types.each_slice(4) do |slice|
256
+ puts " #{slice.map { |t| t.ljust(12) }.join(" ")}"
257
+ end
258
+
259
+ puts "\nšŸ”„ Reverse Parsing Support (#{Purl.reverse_parsing_supported_types.length}):"
260
+ Purl.reverse_parsing_supported_types.each_slice(4) do |slice|
261
+ puts " #{slice.map { |t| t.ljust(12) }.join(" ")}"
262
+ end
263
+
264
+ puts "\nšŸ“Š Type Support Matrix:"
265
+ puts " Type Known Registry Reverse"
266
+ puts " " + "-" * 35
267
+
268
+ all_types = (Purl.known_types + Purl.registry_supported_types).uniq.sort
269
+ all_types.each do |type|
270
+ info = Purl.type_info(type)
271
+ known_mark = info[:known] ? "āœ“" : "āœ—"
272
+ registry_mark = info[:registry_url_generation] ? "āœ“" : "āœ—"
273
+ reverse_mark = info[:reverse_parsing] ? "āœ“" : "āœ—"
274
+
275
+ puts " #{type.ljust(12)} #{known_mark.center(5)} #{registry_mark.center(9)} #{reverse_mark.center(7)}"
276
+ end
277
+
278
+ puts "\nšŸ›¤ Route Patterns Examples:"
279
+ ["gem", "npm", "maven"].each do |type|
280
+ patterns = Purl::RegistryURL.route_patterns_for(type)
281
+ if patterns.any?
282
+ puts "\n #{type.upcase}:"
283
+ patterns.each { |pattern| puts " #{pattern}" }
284
+ end
285
+ end
286
+ end
287
+
288
+ desc "Show failing test cases for debugging"
289
+ task :debug do
290
+ require "json"
291
+ require_relative "lib/purl"
292
+
293
+ test_file_path = File.join(__dir__, "test-suite-data.json")
294
+
295
+ unless File.exist?(test_file_path)
296
+ puts "āŒ Test suite data file not found. Run 'rake spec:update' first."
297
+ exit 1
298
+ end
299
+
300
+ test_data = JSON.parse(File.read(test_file_path))
301
+
302
+ puts "šŸ” Debugging failing test cases..."
303
+ puts "=" * 50
304
+
305
+ failed_cases = []
306
+
307
+ test_data.each_with_index do |test_case, index|
308
+ description = test_case["description"]
309
+ purl_string = test_case["purl"]
310
+ is_invalid = test_case["is_invalid"]
311
+
312
+ begin
313
+ if is_invalid
314
+ begin
315
+ Purl::PackageURL.parse(purl_string)
316
+ failed_cases << {
317
+ index: index + 1,
318
+ description: description,
319
+ purl: purl_string,
320
+ error: "Expected parsing to fail but it succeeded",
321
+ type: "validation"
322
+ }
323
+ rescue Purl::Error
324
+ # Correctly failed - this is expected
325
+ end
326
+ else
327
+ purl = Purl::PackageURL.parse(purl_string)
328
+
329
+ # Check if canonical form matches expected
330
+ expected_canonical = test_case["canonical_purl"]
331
+ if expected_canonical && purl.to_s != expected_canonical
332
+ failed_cases << {
333
+ index: index + 1,
334
+ description: description,
335
+ purl: purl_string,
336
+ error: "Canonical mismatch: expected '#{expected_canonical}', got '#{purl.to_s}'",
337
+ type: "canonical"
338
+ }
339
+ end
340
+
341
+ # Check component mismatches
342
+ %w[type namespace name version qualifiers subpath].each do |component|
343
+ expected = test_case[component]
344
+ actual = purl.send(component)
345
+
346
+ if expected != actual
347
+ failed_cases << {
348
+ index: index + 1,
349
+ description: description,
350
+ purl: purl_string,
351
+ error: "#{component.capitalize} mismatch: expected #{expected.inspect}, got #{actual.inspect}",
352
+ type: "component"
353
+ }
354
+ break # Only report first mismatch per test case
355
+ end
356
+ end
357
+ end
358
+ rescue => e
359
+ failed_cases << {
360
+ index: index + 1,
361
+ description: description,
362
+ purl: purl_string,
363
+ error: "#{e.class}: #{e.message}",
364
+ type: "exception"
365
+ }
366
+ end
367
+ end
368
+
369
+ if failed_cases.empty?
370
+ puts "šŸŽ‰ All test cases are passing!"
371
+ else
372
+ puts "āŒ Found #{failed_cases.length} failing test cases:\n"
373
+
374
+ # Group by failure type
375
+ failed_cases.group_by { |fc| fc[:type] }.each do |failure_type, cases|
376
+ puts "#{failure_type.upcase} FAILURES (#{cases.length}):"
377
+ puts "-" * 30
378
+
379
+ cases.first(5).each do |failed_case| # Show first 5 of each type
380
+ puts "Case #{failed_case[:index]}: #{failed_case[:description]}"
381
+ puts " PURL: #{failed_case[:purl]}"
382
+ puts " Error: #{failed_case[:error]}"
383
+ puts
384
+ end
385
+
386
+ if cases.length > 5
387
+ puts " ... and #{cases.length - 5} more #{failure_type} failures\n"
388
+ end
389
+ end
390
+
391
+ success_rate = ((test_data.length - failed_cases.length).to_f / test_data.length * 100).round(1)
392
+ puts "Overall success rate: #{success_rate}% (#{test_data.length - failed_cases.length}/#{test_data.length})"
393
+ end
394
+ end
395
+
396
+ desc "Validate JSON files against their schemas"
397
+ task :validate_schemas do
398
+ require "json"
399
+ require "json-schema"
400
+
401
+ puts "šŸ” Validating JSON files against schemas..."
402
+ puts "=" * 50
403
+
404
+ schemas_dir = File.join(__dir__, "schemas")
405
+
406
+ validations = [
407
+ {
408
+ name: "PURL Types Configuration",
409
+ data_file: "purl-types.json",
410
+ schema_file: File.join(schemas_dir, "purl-types.schema.json")
411
+ },
412
+ {
413
+ name: "Test Suite Data",
414
+ data_file: "test-suite-data.json",
415
+ schema_file: File.join(schemas_dir, "test-suite-data.schema.json")
416
+ }
417
+ ]
418
+
419
+ all_valid = true
420
+
421
+ validations.each do |validation|
422
+ puts "\nšŸ“‹ Validating #{validation[:name]}..."
423
+
424
+ data_path = File.join(__dir__, validation[:data_file])
425
+ schema_path = validation[:schema_file]
426
+
427
+ # Check if files exist
428
+ unless File.exist?(data_path)
429
+ puts " āŒ Data file not found: #{validation[:data_file]}"
430
+ all_valid = false
431
+ next
432
+ end
433
+
434
+ unless File.exist?(schema_path)
435
+ puts " āŒ Schema file not found: #{validation[:schema_file]}"
436
+ all_valid = false
437
+ next
438
+ end
439
+
440
+ begin
441
+ # Load and parse files
442
+ data = JSON.parse(File.read(data_path))
443
+ schema = JSON.parse(File.read(schema_path))
444
+
445
+ # Validate
446
+ errors = JSON::Validator.fully_validate(schema, data)
447
+
448
+ if errors.empty?
449
+ puts " āœ… Valid - conforms to schema"
450
+ else
451
+ puts " āŒ Invalid - found #{errors.length} error(s):"
452
+ errors.first(5).each { |error| puts " • #{error}" }
453
+ if errors.length > 5
454
+ puts " • ... and #{errors.length - 5} more errors"
455
+ end
456
+ all_valid = false
457
+ end
458
+
459
+ rescue JSON::ParserError => e
460
+ puts " āŒ JSON parsing error: #{e.message}"
461
+ all_valid = false
462
+ rescue => e
463
+ puts " āŒ Validation error: #{e.message}"
464
+ all_valid = false
465
+ end
466
+ end
467
+
468
+ puts "\n" + "=" * 50
469
+ if all_valid
470
+ puts "šŸŽ‰ All JSON files are valid according to their schemas!"
471
+ else
472
+ puts "āŒ One or more JSON files failed schema validation"
473
+ exit 1
474
+ end
475
+ end
476
+
477
+ desc "Validate all PURL examples in purl-types.json"
478
+ task :validate_examples do
479
+ require "json"
480
+ require_relative "lib/purl"
481
+
482
+ puts "šŸ” Validating PURL examples in purl-types.json..."
483
+ puts "=" * 60
484
+
485
+ project_root = __dir__
486
+ purl_types_data = JSON.parse(File.read(File.join(project_root, "purl-types.json")))
487
+
488
+ total_examples = 0
489
+ invalid_examples = []
490
+
491
+ purl_types_data["types"].each do |type_name, type_config|
492
+ examples = type_config["examples"]
493
+ next unless examples && examples.is_a?(Array)
494
+
495
+ puts "\nšŸ“¦ #{type_name} (#{examples.length} examples):"
496
+
497
+ examples.each do |example_purl|
498
+ total_examples += 1
499
+
500
+ begin
501
+ # Try to parse the example PURL
502
+ parsed = Purl::PackageURL.parse(example_purl)
503
+
504
+ # Verify the type matches
505
+ if parsed.type == type_name
506
+ puts " āœ… #{example_purl}"
507
+ else
508
+ puts " āŒ #{example_purl} - Type mismatch: expected '#{type_name}', got '#{parsed.type}'"
509
+ invalid_examples << {
510
+ type: type_name,
511
+ example: example_purl,
512
+ error: "Type mismatch: expected '#{type_name}', got '#{parsed.type}'"
513
+ }
514
+ end
515
+
516
+ rescue => e
517
+ puts " āŒ #{example_purl} - #{e.class}: #{e.message}"
518
+ invalid_examples << {
519
+ type: type_name,
520
+ example: example_purl,
521
+ error: "#{e.class}: #{e.message}"
522
+ }
523
+ end
524
+ end
525
+ end
526
+
527
+ puts "\n" + "=" * 60
528
+ puts "šŸ“Š Validation Summary:"
529
+ puts " Total examples: #{total_examples}"
530
+ puts " Valid examples: #{total_examples - invalid_examples.length}"
531
+ puts " Invalid examples: #{invalid_examples.length}"
532
+
533
+ if invalid_examples.empty?
534
+ puts "\nšŸŽ‰ All PURL examples are valid!"
535
+ else
536
+ puts "\nāŒ Found #{invalid_examples.length} invalid examples:"
537
+ invalid_examples.each do |invalid|
538
+ puts " • #{invalid[:type]}: #{invalid[:example]}"
539
+ puts " Error: #{invalid[:error]}"
540
+ end
541
+
542
+ puts "\nšŸ“ These examples should be reported upstream to the PURL specification maintainers."
543
+ exit 1
544
+ end
545
+ end
546
+ end
data/SECURITY.md ADDED
@@ -0,0 +1,164 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ We actively support and provide security updates for the following versions:
6
+
7
+ | Version | Supported |
8
+ | ------- | ------------------ |
9
+ | 1.x.x | :white_check_mark: |
10
+ | < 1.0 | :x: |
11
+
12
+ ## Reporting a Vulnerability
13
+
14
+ The Purl team takes security seriously. If you discover a security vulnerability, please follow these steps:
15
+
16
+ ### 1. Do NOT Create a Public Issue
17
+
18
+ Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.
19
+
20
+ ### 2. Report Privately
21
+
22
+ Send a detailed report to **andrew@ecosyste.ms** with:
23
+
24
+ - **Subject**: `[SECURITY] Purl Ruby - [Brief Description]`
25
+ - **Description** of the vulnerability
26
+ - **Steps to reproduce** the issue
27
+ - **Potential impact** assessment
28
+ - **Suggested fix** (if you have one)
29
+ - **Your contact information** for follow-up
30
+
31
+ ### 3. What to Include
32
+
33
+ Please provide as much information as possible:
34
+
35
+ ```
36
+ - Affected versions
37
+ - Attack vectors
38
+ - Proof of concept (if safe to share)
39
+ - Environmental details (Ruby version, OS, etc.)
40
+ - Any relevant configuration details
41
+ ```
42
+
43
+ ## Response Process
44
+
45
+ ### Initial Response
46
+
47
+ - **24-48 hours**: We will acknowledge receipt of your report
48
+ - **Initial assessment**: Within 1 week of acknowledgment
49
+ - **Status updates**: Weekly until resolution
50
+
51
+ ### Investigation
52
+
53
+ We will:
54
+ 1. **Confirm** the vulnerability exists
55
+ 2. **Assess** the severity and impact
56
+ 3. **Develop** a fix and mitigation strategy
57
+ 4. **Test** the fix thoroughly
58
+ 5. **Coordinate** disclosure timeline
59
+
60
+ ### Resolution
61
+
62
+ - **High/Critical**: Immediate fix and release
63
+ - **Medium**: Fix within 30 days
64
+ - **Low**: Fix in next regular release cycle
65
+
66
+ ## Security Considerations
67
+
68
+ ### Input Validation
69
+
70
+ The Purl library processes Package URL strings and performs:
71
+
72
+ - **Scheme validation**: Ensures proper `pkg:` prefix
73
+ - **Component parsing**: Validates type, namespace, name, version
74
+ - **URI encoding**: Handles percent-encoded characters
75
+ - **Qualifier parsing**: Processes key-value parameters
76
+
77
+ ### Potential Risk Areas
78
+
79
+ Areas that warrant security attention:
80
+
81
+ 1. **URL Parsing**: Malformed URLs could cause parsing errors
82
+ 2. **Regular Expressions**: Complex patterns may be vulnerable to ReDoS
83
+ 3. **JSON Processing**: Configuration files require safe parsing
84
+ 4. **Network Requests**: Registry URL generation involves external URLs
85
+
86
+ ### Safe Usage Practices
87
+
88
+ When using Purl in applications:
89
+
90
+ - **Validate input**: Don't trust user-provided PURL strings
91
+ - **Handle errors**: Properly catch and handle parsing exceptions
92
+ - **Sanitize output**: Be careful when displaying parsed components
93
+ - **Rate limiting**: If parsing many PURLs, implement appropriate limits
94
+
95
+ ## Disclosure Policy
96
+
97
+ ### Coordinated Disclosure
98
+
99
+ We follow coordinated disclosure principles:
100
+
101
+ 1. **Private reporting** allows us to fix issues before public disclosure
102
+ 2. **Reasonable timeline** for fixes (typically 90 days maximum)
103
+ 3. **Credit and recognition** for responsible reporters
104
+ 4. **Public disclosure** after fixes are available
105
+
106
+ ### Public Disclosure
107
+
108
+ After a fix is released:
109
+
110
+ 1. **Security advisory** published on GitHub
111
+ 2. **CVE requested** if applicable
112
+ 3. **Release notes** include security information
113
+ 4. **Community notification** through appropriate channels
114
+
115
+ ## Security Updates
116
+
117
+ ### Notification Channels
118
+
119
+ Security updates are announced through:
120
+
121
+ - **GitHub Security Advisories**
122
+ - **RubyGems security alerts**
123
+ - **Release notes and CHANGELOG**
124
+ - **Project README updates**
125
+
126
+ ### Update Recommendations
127
+
128
+ To stay secure:
129
+
130
+ - **Monitor** our security advisories
131
+ - **Update regularly** to the latest version
132
+ - **Review** release notes for security fixes
133
+ - **Subscribe** to GitHub notifications for this repository
134
+
135
+ ## Bug Bounty
136
+
137
+ Currently, we do not offer a formal bug bounty program. However, we deeply appreciate security researchers who help improve the project's security posture.
138
+
139
+ ### Recognition
140
+
141
+ Contributors who responsibly disclose security issues will be:
142
+
143
+ - **Credited** in security advisories (with permission)
144
+ - **Mentioned** in release notes
145
+ - **Recognized** in project documentation
146
+ - **Thanked** publicly (unless anonymity is requested)
147
+
148
+ ## Contact Information
149
+
150
+ **Security Contact**: andrew@ecosyste.ms
151
+
152
+ **PGP Key**: Available upon request for encrypted communications
153
+
154
+ **Response Time**: We aim to acknowledge security reports within 24-48 hours
155
+
156
+ ## Additional Resources
157
+
158
+ - [PURL Specification Security Considerations](https://github.com/package-url/purl-spec)
159
+ - [Ruby Security Best Practices](https://guides.rubyonrails.org/security.html)
160
+ - [OWASP Secure Coding Practices](https://owasp.org/www-project-secure-coding-practices-quick-reference-guide/)
161
+
162
+ ---
163
+
164
+ Thank you for helping keep Purl and its users safe!