bundler-age_gate 0.3.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 581cb6ea25c5954f0415555577c5507f39d898b03a47bb88e0421c2ebd9a6e38
4
+ data.tar.gz: 90aad38a46d9fa7beb86f1e314b27a88dbf4e64c23bf1ea363150d744fa87fb6
5
+ SHA512:
6
+ metadata.gz: 76100acf78b815815971e534a024375ab7c97c733a82a151187a4112361027314570886beff6d9c2c5c66409e6b9fd27325ca54de5f64722ae7eafe35ec19a2f
7
+ data.tar.gz: ec2a532a7fbc208c02712efc505d3978b012beb921f282c4c734fd6c2912d38c412eee901810780cce3ef99fd6c4cb707eff907921c1818ae9e9f2a919ff4ec3
data/CHANGELOG.md ADDED
@@ -0,0 +1,59 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.3.0] - 2026-01-22
9
+
10
+ ### Added
11
+ - **Multi-source support**: Configure different age requirements per gem source
12
+ - Public RubyGems (strict) vs private gems (permissive)
13
+ - Per-source API endpoint configuration
14
+ - GitHub Packages, Artifactory, and custom registry support
15
+ - **Authentication support**: Bearer tokens for private gem servers
16
+ - Environment variable substitution (e.g., `${GITHUB_TOKEN}`)
17
+ - **Source detection**: Automatically determines gem sources from Gemfile.lock
18
+ - **Per-source minimum age**: Different requirements for public vs internal gems
19
+ - **CLI override**: Command-line days argument overrides all source configurations
20
+
21
+ ### Changed
22
+ - Violation output now includes source name and required age
23
+ - Configuration structure expanded to support multiple sources
24
+ - Default configuration maintains backwards compatibility (RubyGems only)
25
+
26
+ ## [0.2.0] - 2026-01-22
27
+
28
+ ### Added
29
+ - Configuration file support (`.bundler-age-gate.yml`)
30
+ - Exception handling mechanism with approval workflow
31
+ - Audit logging for compliance (JSON format)
32
+ - CI/CD integration examples:
33
+ - GitHub Actions with PR comments
34
+ - GitLab CI
35
+ - CircleCI
36
+ - Pre-commit hooks (shell and framework)
37
+ - Configuration examples and templates
38
+ - Comprehensive README documentation for enterprise deployment
39
+
40
+ ### Changed
41
+ - Command now reads from config file for default minimum age
42
+ - Exit messages now include helpful hints for exceptions
43
+
44
+ ### Fixed
45
+ - Plugin compatibility with Bundler 4.x
46
+
47
+ ## [0.1.0] - 2026-01-22
48
+
49
+ ### Added
50
+ - Initial release
51
+ - `bundle age_check [DAYS]` command to verify gem ages
52
+ - RubyGems API integration for release date checking
53
+ - In-memory caching to avoid duplicate API calls
54
+ - Progress indicator with dot-per-gem output
55
+ - Clear violation reporting with release dates
56
+ - Graceful error handling for API failures
57
+ - Exit code 0 for pass, 1 for violations
58
+
59
+ [0.1.0]: https://github.com/NikoRoberts/bundler-age_gate/releases/tag/v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Niko Roberts
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,291 @@
1
+ # Bundler::AgeGate
2
+
3
+ A Bundler plugin that enforces minimum gem age requirements by checking your `Gemfile.lock` against the RubyGems API.
4
+
5
+ ## Why?
6
+
7
+ Freshly released gems may contain bugs or security vulnerabilities that haven't been discovered yet. This plugin helps you implement a "waiting period" policy before adopting new gem versions in production environments.
8
+
9
+ ## Installation
10
+
11
+ Install the plugin:
12
+
13
+ ```bash
14
+ bundle plugin install bundler-age_gate
15
+ ```
16
+
17
+ Or add it to your project's plugin list:
18
+
19
+ ```bash
20
+ bundle plugin install bundler-age_gate --local_git=/path/to/bundler-age_gate
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ Check that all gems in your `Gemfile.lock` are at least 7 days old:
26
+
27
+ ```bash
28
+ bundle age_check
29
+ ```
30
+
31
+ Specify a custom minimum age (in days):
32
+
33
+ ```bash
34
+ bundle age_check 14
35
+ ```
36
+
37
+ Check for 30-day minimum:
38
+
39
+ ```bash
40
+ bundle age_check 30
41
+ ```
42
+
43
+ ## Configuration
44
+
45
+ Create a `.bundler-age-gate.yml` file in your project root to customise behaviour:
46
+
47
+ ```yaml
48
+ # Minimum age in days (default: 7)
49
+ minimum_age_days: 7
50
+
51
+ # Audit log path (default: .bundler-age-gate.log)
52
+ audit_log_path: .bundler-age-gate.log
53
+
54
+ # Approved exceptions
55
+ exceptions:
56
+ - gem: rails
57
+ version: 7.1.3.1
58
+ reason: "Critical security patch for CVE-2024-12345"
59
+ approved_by: security-team
60
+ expires: 2026-02-15
61
+ ```
62
+
63
+ ### Exception Workflow
64
+
65
+ 1. Developer encounters age gate violation
66
+ 2. Request exception approval (security team, staff engineer, etc.)
67
+ 3. Add approved exception to `.bundler-age-gate.yml`
68
+ 4. Include reason, approver, and expiry date
69
+ 5. Commit configuration with approval documented
70
+
71
+ **Exception fields:**
72
+ - `gem` (required): Gem name
73
+ - `version` (optional): Specific version, omit for all versions
74
+ - `reason` (required): Explanation for exception
75
+ - `approved_by` (required): Who approved this
76
+ - `expires` (optional): Expiry date (YYYY-MM-DD)
77
+
78
+ ### Audit Logging
79
+
80
+ All checks are logged to `.bundler-age-gate.log` in JSON format:
81
+
82
+ ```json
83
+ {
84
+ "timestamp": "2026-01-22T13:45:00Z",
85
+ "result": "pass",
86
+ "violations_count": 0,
87
+ "checked_gems_count": 80,
88
+ "exceptions_used": 1,
89
+ "violations": []
90
+ }
91
+ ```
92
+
93
+ **Compliance benefits:**
94
+ - Track all security checks
95
+ - Audit exception usage
96
+ - Demonstrate policy compliance
97
+ - Investigate historical violations
98
+
99
+ ### Multi-Source Support
100
+
101
+ Configure different age requirements for different gem sources (public vs private):
102
+
103
+ ```yaml
104
+ minimum_age_days: 7 # Global default
105
+
106
+ sources:
107
+ # Public RubyGems - strict
108
+ - name: rubygems
109
+ url: https://rubygems.org
110
+ api_endpoint: https://rubygems.org/api/v1/versions/%s.json
111
+ minimum_age_days: 7
112
+
113
+ # Internal GitHub Packages - less strict
114
+ - name: github-internal
115
+ url: https://rubygems.pkg.github.com/your-org
116
+ api_endpoint: https://rubygems.pkg.github.com/your-org/api/v1/versions/%s.json
117
+ minimum_age_days: 3
118
+ auth_token: ${GITHUB_TOKEN} # Environment variable
119
+
120
+ # Internal Artifactory - very permissive
121
+ - name: artifactory
122
+ url: https://artifactory.company.com/api/gems
123
+ api_endpoint: https://artifactory.company.com/api/gems/api/v1/versions/%s.json
124
+ minimum_age_days: 1
125
+ auth_token: ${ARTIFACTORY_API_KEY}
126
+ ```
127
+
128
+ **Benefits:**
129
+ - Stricter requirements for public gems (supply chain risk)
130
+ - Permissive for internal gems (trusted sources)
131
+ - Per-source API endpoints for private registries
132
+ - Authentication support via environment variables
133
+ - CLI override still applies globally: `bundle age_check 14` enforces 14 days for ALL sources
134
+
135
+ **How it works:**
136
+ 1. Plugin reads `Gemfile.lock` to determine each gem's source
137
+ 2. Applies per-source minimum age requirements
138
+ 3. Queries appropriate API endpoint with authentication
139
+ 4. Reports violations with source context
140
+
141
+ ## How It Works
142
+
143
+ 1. Parses your `Gemfile.lock` using Bundler's built-in parser
144
+ 2. Queries the RubyGems API for each gem's release date
145
+ 3. Compares the release date against your specified minimum age
146
+ 4. Reports any violations and exits with status code 1 if violations are found
147
+
148
+ **Note:** Currently only supports rubygems.org. Private gem servers or GitHub packages are not yet supported. See [Roadmap](#roadmap) for planned features.
149
+
150
+ ## Features
151
+
152
+ - **Efficient API usage**: Caches API responses to avoid duplicate requests
153
+ - **Progress indicator**: Prints a dot for each gem checked
154
+ - **Clear output**: Shows violating gems with release dates and age
155
+ - **Graceful error handling**: Skips gems that can't be checked (API errors, network issues)
156
+ - **CI-friendly**: Returns appropriate exit codes (0 for pass, 1 for fail)
157
+ - **Configuration file**: Customise settings via `.bundler-age-gate.yml`
158
+ - **Exception handling**: Approve specific gems with documented reasons
159
+ - **Audit logging**: Compliance-ready logs for all checks
160
+ - **Enterprise-ready**: Designed for organisation-wide rollout
161
+
162
+ ## Example Output
163
+
164
+ ```
165
+ 🔍 Checking gem ages (minimum: 7 days)...
166
+ 📅 Cutoff date: 2026-01-15
167
+
168
+ Checking 143 gems...
169
+ Progress: ...............................................
170
+
171
+ ⚠️ Found 2 gem(s) younger than 7 days:
172
+
173
+ ❌ rails (7.1.3)
174
+ Released: 2026-01-20 (2 days ago)
175
+
176
+ ❌ activerecord (7.1.3)
177
+ Released: 2026-01-20 (2 days ago)
178
+
179
+ ⛔ Age gate check FAILED
180
+ ```
181
+
182
+ ## Use Cases
183
+
184
+ - **Production deployments**: Ensure stability by waiting for community feedback
185
+ - **Security compliance**: Enforce policies requiring "battle-tested" versions
186
+ - **CI pipelines**: Add as a check in your continuous integration workflow
187
+ - **Risk management**: Reduce exposure to zero-day vulnerabilities in new releases
188
+
189
+ ## CI/CD Integration
190
+
191
+ ### GitHub Actions
192
+
193
+ ```yaml
194
+ # .github/workflows/gem-security-check.yml
195
+ - name: Install bundler-age_gate
196
+ run: |
197
+ gem install bundler-age_gate
198
+ bundle plugin install bundler-age_gate
199
+
200
+ - name: Check gem ages
201
+ run: bundle age_check 7
202
+ ```
203
+
204
+ **Full example:** See [`examples/github-actions.yml`](examples/github-actions.yml) for complete workflow with PR comments and artefact upload.
205
+
206
+ ### GitLab CI
207
+
208
+ ```yaml
209
+ # .gitlab-ci.yml
210
+ gem-age-gate-check:
211
+ script:
212
+ - bundle plugin install bundler-age_gate
213
+ - bundle age_check 7
214
+ ```
215
+
216
+ **Full example:** See [`examples/gitlab-ci.yml`](examples/gitlab-ci.yml)
217
+
218
+ ### CircleCI
219
+
220
+ ```yaml
221
+ # .circleci/config.yml
222
+ - run:
223
+ name: Check gem ages
224
+ command: bundle age_check 7
225
+ ```
226
+
227
+ **Full example:** See [`examples/circleci-config.yml`](examples/circleci-config.yml)
228
+
229
+ ### Pre-commit Hooks
230
+
231
+ #### Shell Script
232
+
233
+ ```bash
234
+ # .git/hooks/pre-commit
235
+ #!/bin/bash
236
+ if git diff --cached --name-only | grep -q "^Gemfile.lock$"; then
237
+ bundle age_check 7
238
+ fi
239
+ ```
240
+
241
+ **Installation:** See [`examples/pre-commit`](examples/pre-commit)
242
+
243
+ #### Pre-commit Framework
244
+
245
+ ```yaml
246
+ # .pre-commit-config.yaml
247
+ repos:
248
+ - repo: local
249
+ hooks:
250
+ - id: bundler-age-gate
251
+ name: Check gem ages
252
+ entry: bundle age_check 7
253
+ language: system
254
+ files: ^Gemfile\.lock$
255
+ ```
256
+
257
+ **Full example:** See [`examples/.pre-commit-config.yaml`](examples/.pre-commit-config.yaml)
258
+
259
+ ## Development
260
+
261
+ After checking out the repo:
262
+
263
+ ```bash
264
+ bundle install
265
+ ```
266
+
267
+ To test locally:
268
+
269
+ ```bash
270
+ bundle exec rake
271
+ ```
272
+
273
+ ## Roadmap
274
+
275
+ Future enhancements planned:
276
+
277
+ - [x] **Private gem server support**: ✅ Implemented in v0.3.0
278
+ - [x] **Multi-source detection**: ✅ Implemented in v0.3.0
279
+ - [ ] **Webhook notifications**: Slack/Teams alerts for violations
280
+ - [ ] **Policy-as-code**: YAML policy files with team-specific rules
281
+ - [ ] **Exemption templates**: Pre-approved exception categories (security patches, internal gems)
282
+ - [ ] **Metrics dashboard**: Web dashboard for organisation-wide compliance
283
+ - [ ] **Dependency graph analysis**: Check transitive dependency ages
284
+
285
+ ## Contributing
286
+
287
+ Bug reports and pull requests are welcome on GitHub at https://github.com/NikoRoberts/bundler-age_gate.
288
+
289
+ ## License
290
+
291
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/bundler/age_gate/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "bundler-age_gate"
7
+ spec.version = Bundler::AgeGate::VERSION
8
+ spec.authors = ["Niko Roberts"]
9
+ spec.email = ["niko@example.com"]
10
+
11
+ spec.summary = "A Bundler plugin to enforce minimum gem age requirements"
12
+ spec.description = "Checks your Gemfile.lock against the RubyGems API to ensure no gems are younger than a specified number of days"
13
+ spec.homepage = "https://github.com/NikoRoberts/bundler-age_gate"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 2.7.0"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = spec.homepage
19
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
20
+
21
+ spec.files = Dir.glob("lib/**/*") + %w[
22
+ plugins.rb
23
+ bundler-age_gate.gemspec
24
+ README.md
25
+ LICENSE
26
+ CHANGELOG.md
27
+ ].select { |f| File.exist?(f) }
28
+
29
+ spec.require_paths = ["lib"]
30
+
31
+ spec.add_dependency "bundler", ">= 2.0"
32
+
33
+ spec.add_development_dependency "rake", "~> 13.0"
34
+ spec.add_development_dependency "rspec", "~> 3.0"
35
+ spec.add_development_dependency "rubocop", "~> 1.21"
36
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Bundler
7
+ module AgeGate
8
+ class AuditLogger
9
+ def initialize(log_path)
10
+ @log_path = log_path
11
+ end
12
+
13
+ def log_check(result:, violations:, exceptions_used:, checked_gems_count:)
14
+ entry = {
15
+ timestamp: Time.now.iso8601,
16
+ result: result, # "pass" or "fail"
17
+ violations_count: violations.size,
18
+ checked_gems_count: checked_gems_count,
19
+ exceptions_used: exceptions_used,
20
+ violations: violations.map do |v|
21
+ {
22
+ gem: v[:name],
23
+ version: v[:version],
24
+ release_date: v[:release_date].iso8601,
25
+ age_days: v[:age_days],
26
+ excepted: v[:excepted] || false,
27
+ exception_reason: v[:exception_reason]
28
+ }
29
+ end
30
+ }
31
+
32
+ append_log(entry)
33
+ end
34
+
35
+ private
36
+
37
+ def append_log(entry)
38
+ File.open(@log_path, "a") do |f|
39
+ f.puts(JSON.generate(entry))
40
+ end
41
+ rescue StandardError => e
42
+ # Silently fail if logging doesn't work - don't block the main flow
43
+ warn "⚠️ Failed to write audit log: #{e.message}"
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler"
4
+ require "net/http"
5
+ require "json"
6
+ require "time"
7
+ require_relative "config"
8
+ require_relative "audit_logger"
9
+
10
+ module Bundler
11
+ module AgeGate
12
+ class Command
13
+ def initialize(days = nil)
14
+ @config = Config.new
15
+ @cli_override_days = days&.to_i
16
+ @cache = {}
17
+ @violations = []
18
+ @excepted_violations = []
19
+ @audit_logger = AuditLogger.new(@config.audit_log_path)
20
+ @checked_gems_count = 0
21
+ @source_map = {} # gem_name => source_url
22
+ end
23
+
24
+ def execute
25
+ lockfile_path = File.join(Dir.pwd, "Gemfile.lock")
26
+
27
+ unless File.exist?(lockfile_path)
28
+ puts "❌ Gemfile.lock not found in current directory"
29
+ exit 1
30
+ end
31
+
32
+ lockfile = Bundler::LockfileParser.new(Bundler.read_file(lockfile_path))
33
+ gems = lockfile.specs
34
+
35
+ # Build source map from lockfile
36
+ build_source_map(lockfile)
37
+
38
+ # Display configuration
39
+ if @cli_override_days
40
+ puts "🔍 Checking gem ages (CLI override: #{@cli_override_days} days for all sources)..."
41
+ puts "📅 Cutoff date: #{Time.now - (@cli_override_days * 24 * 60 * 60)}"
42
+ else
43
+ puts "🔍 Checking gem ages (per-source configuration)..."
44
+ @config.sources.each do |source|
45
+ puts " 📦 #{source.name}: #{source.minimum_age_days} days"
46
+ end
47
+ end
48
+ puts ""
49
+
50
+ puts "Checking #{gems.size} gems..."
51
+ print "Progress: "
52
+
53
+ gems.each do |spec|
54
+ check_gem(spec)
55
+ print "."
56
+ end
57
+
58
+ puts "\n\n"
59
+ display_results
60
+ end
61
+
62
+ private
63
+
64
+ def build_source_map(lockfile)
65
+ # Parse REMOTE sections from lockfile to map gems to sources
66
+ lockfile_content = File.read(File.join(Dir.pwd, "Gemfile.lock"))
67
+ current_source = nil
68
+
69
+ lockfile_content.each_line do |line|
70
+ if line.match(/^\s*remote: (.+)$/)
71
+ current_source = line.match(/^\s*remote: (.+)$/)[1].strip
72
+ elsif line.match(/^\s{4}(\S+)/) && current_source
73
+ gem_name_with_version = line.strip
74
+ gem_name = gem_name_with_version.split(/\s+/).first
75
+ @source_map[gem_name] = current_source
76
+ end
77
+ end
78
+ end
79
+
80
+ def check_gem(spec)
81
+ gem_name = spec.name
82
+ gem_version = spec.version.to_s
83
+
84
+ # Skip if already checked
85
+ cache_key = "#{gem_name}@#{gem_version}"
86
+ return if @cache.key?(cache_key)
87
+
88
+ @checked_gems_count += 1
89
+
90
+ # Determine source and minimum age for this gem
91
+ gem_source_url = @source_map[gem_name] || "https://rubygems.org"
92
+ source_config = @config.source_for_url(gem_source_url)
93
+ min_age_days = @cli_override_days || source_config.minimum_age_days
94
+ cutoff_date = Time.now - (min_age_days * 24 * 60 * 60)
95
+
96
+ release_date = fetch_gem_release_date(gem_name, gem_version, source_config)
97
+
98
+ if release_date.nil?
99
+ # Couldn't determine date, skip silently
100
+ @cache[cache_key] = :unknown
101
+ return
102
+ end
103
+
104
+ @cache[cache_key] = release_date
105
+
106
+ if release_date > cutoff_date
107
+ age_days = ((Time.now - release_date) / (24 * 60 * 60)).round
108
+ violation = {
109
+ name: gem_name,
110
+ version: gem_version,
111
+ release_date: release_date,
112
+ age_days: age_days,
113
+ source: source_config.name,
114
+ required_age: min_age_days
115
+ }
116
+
117
+ # Check if this gem has an exception
118
+ if @config.gem_excepted?(gem_name, gem_version)
119
+ violation[:excepted] = true
120
+ violation[:exception_reason] = @config.exception_reason(gem_name, gem_version)
121
+ @excepted_violations << violation
122
+ else
123
+ @violations << violation
124
+ end
125
+ end
126
+ rescue StandardError => e
127
+ # Silently handle errors for individual gems
128
+ @cache[cache_key] = :error
129
+ end
130
+
131
+ def fetch_gem_release_date(gem_name, gem_version, source_config)
132
+ uri = URI(format(source_config.api_endpoint, gem_name))
133
+
134
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: 10) do |http|
135
+ request = Net::HTTP::Get.new(uri.path)
136
+
137
+ # Add authentication header if configured
138
+ if source_config.auth_token
139
+ request["Authorization"] = "Bearer #{source_config.auth_token}"
140
+ end
141
+
142
+ http.request(request)
143
+ end
144
+
145
+ unless response.is_a?(Net::HTTPSuccess)
146
+ return nil
147
+ end
148
+
149
+ versions_data = JSON.parse(response.body)
150
+ version_info = versions_data.find { |v| v["number"] == gem_version }
151
+
152
+ return nil unless version_info && version_info["created_at"]
153
+
154
+ Time.parse(version_info["created_at"])
155
+ rescue StandardError
156
+ nil
157
+ end
158
+
159
+ def display_results
160
+ # Show excepted violations first (if any)
161
+ unless @excepted_violations.empty?
162
+ puts "ℹ️ #{@excepted_violations.size} gem(s) have approved exceptions:\n\n"
163
+
164
+ @excepted_violations.sort_by { |v| v[:age_days] }.each do |violation|
165
+ puts " ⚠️ #{violation[:name]} (#{violation[:version]}) [#{violation[:source]}]"
166
+ puts " Released: #{violation[:release_date].strftime('%Y-%m-%d')} (#{violation[:age_days]} days ago, requires #{violation[:required_age]} days)"
167
+ puts " Exception: #{violation[:exception_reason]}"
168
+ puts ""
169
+ end
170
+ end
171
+
172
+ # Log audit entry
173
+ result = @violations.empty? ? "pass" : "fail"
174
+ all_violations = @violations + @excepted_violations
175
+ @audit_logger.log_check(
176
+ result: result,
177
+ violations: all_violations,
178
+ exceptions_used: @excepted_violations.size,
179
+ checked_gems_count: @checked_gems_count
180
+ )
181
+
182
+ # Display final result
183
+ if @violations.empty?
184
+ puts "✅ All gems meet their source-specific age requirements"
185
+ puts "🎉 Safe to proceed!"
186
+ exit 0
187
+ else
188
+ puts "⚠️ Found #{@violations.size} gem(s) that don't meet age requirements:\n\n"
189
+
190
+ @violations.sort_by { |v| v[:age_days] }.each do |violation|
191
+ puts " ❌ #{violation[:name]} (#{violation[:version]}) [#{violation[:source]}]"
192
+ puts " Released: #{violation[:release_date].strftime('%Y-%m-%d')} (#{violation[:age_days]} days ago, requires #{violation[:required_age]} days)"
193
+ puts ""
194
+ end
195
+
196
+ puts "⛔ Age gate check FAILED"
197
+ puts "\n💡 To add an exception, create .bundler-age-gate.yml with approved exceptions"
198
+ exit 1
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Bundler
6
+ module AgeGate
7
+ class Config
8
+ attr_reader :minimum_age_days, :exceptions, :audit_log_path, :sources
9
+
10
+ DEFAULT_RUBYGEMS_SOURCE = {
11
+ "name" => "rubygems",
12
+ "url" => "https://rubygems.org",
13
+ "api_endpoint" => "https://rubygems.org/api/v1/versions/%s.json",
14
+ "minimum_age_days" => nil # Uses global default
15
+ }.freeze
16
+
17
+ DEFAULT_CONFIG = {
18
+ "minimum_age_days" => 7,
19
+ "exceptions" => [],
20
+ "audit_log_path" => ".bundler-age-gate.log",
21
+ "sources" => [DEFAULT_RUBYGEMS_SOURCE]
22
+ }.freeze
23
+
24
+ def initialize(config_path = ".bundler-age-gate.yml")
25
+ @config_path = config_path
26
+ @config = load_config
27
+ @minimum_age_days = @config["minimum_age_days"]
28
+ @exceptions = @config["exceptions"] || []
29
+ @audit_log_path = @config["audit_log_path"]
30
+ @sources = (@config["sources"] || [DEFAULT_RUBYGEMS_SOURCE]).map { |s| SourceConfig.new(s, @minimum_age_days) }
31
+ end
32
+
33
+ def source_for_url(source_url)
34
+ # Normalise URLs for comparison
35
+ normalised_url = normalise_source_url(source_url)
36
+
37
+ @sources.find do |source|
38
+ normalise_source_url(source.url) == normalised_url
39
+ end || @sources.first # Default to first source (usually rubygems)
40
+ end
41
+
42
+ def gem_excepted?(gem_name, gem_version)
43
+ @exceptions.any? do |exception|
44
+ matches_gem?(exception, gem_name, gem_version) && !expired?(exception)
45
+ end
46
+ end
47
+
48
+ def exception_reason(gem_name, gem_version)
49
+ exception = @exceptions.find do |ex|
50
+ matches_gem?(ex, gem_name, gem_version) && !expired?(ex)
51
+ end
52
+ exception&.dig("reason")
53
+ end
54
+
55
+ private
56
+
57
+ def load_config
58
+ if File.exist?(@config_path)
59
+ user_config = YAML.safe_load_file(@config_path, permitted_classes: [Date, Time])
60
+ DEFAULT_CONFIG.merge(user_config || {})
61
+ else
62
+ DEFAULT_CONFIG
63
+ end
64
+ rescue StandardError => e
65
+ warn "⚠️ Failed to load config from #{@config_path}: #{e.message}"
66
+ warn "Using default configuration"
67
+ DEFAULT_CONFIG
68
+ end
69
+
70
+ def matches_gem?(exception, gem_name, gem_version)
71
+ exception["gem"] == gem_name &&
72
+ (exception["version"].nil? || exception["version"] == gem_version)
73
+ end
74
+
75
+ def expired?(exception)
76
+ return false unless exception["expires"]
77
+
78
+ expiry_date = parse_date(exception["expires"])
79
+ expiry_date && Time.now > expiry_date
80
+ end
81
+
82
+ def parse_date(date_string)
83
+ Time.parse(date_string.to_s)
84
+ rescue ArgumentError
85
+ nil
86
+ end
87
+
88
+ def normalise_source_url(url)
89
+ # Remove trailing slashes and convert to lowercase for comparison
90
+ url.to_s.downcase.chomp("/")
91
+ end
92
+ end
93
+
94
+ # Represents a gem source configuration
95
+ class SourceConfig
96
+ attr_reader :name, :url, :api_endpoint, :minimum_age_days, :auth_token
97
+
98
+ def initialize(config, global_minimum_age_days)
99
+ @name = config["name"] || "unknown"
100
+ @url = config["url"]
101
+ @api_endpoint = config["api_endpoint"]
102
+ @minimum_age_days = config["minimum_age_days"] || global_minimum_age_days
103
+ @auth_token = resolve_auth_token(config["auth_token"])
104
+ end
105
+
106
+ private
107
+
108
+ def resolve_auth_token(token_config)
109
+ return nil unless token_config
110
+
111
+ # Support environment variable substitution: ${VAR_NAME}
112
+ if token_config.match?(/\$\{(.+)\}/)
113
+ var_name = token_config.match(/\$\{(.+)\}/)[1]
114
+ ENV[var_name]
115
+ else
116
+ token_config
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bundler
4
+ module AgeGate
5
+ VERSION = "0.3.0"
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "age_gate/version"
4
+
5
+ module Bundler
6
+ module AgeGate
7
+ class Error < StandardError; end
8
+ end
9
+ end
data/plugins.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/bundler/age_gate/command"
4
+
5
+ class AgeCheck < Bundler::Plugin::API
6
+ command "age_check"
7
+
8
+ def exec(_command, args)
9
+ days = args.first || "7"
10
+
11
+ unless days.match?(/^\d+$/)
12
+ puts "❌ Invalid argument: '#{days}' is not a valid number of days"
13
+ puts "Usage: bundle age_check [DAYS]"
14
+ exit 1
15
+ end
16
+
17
+ Bundler::AgeGate::Command.new(days).execute
18
+ end
19
+ end
metadata ADDED
@@ -0,0 +1,113 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bundler-age_gate
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Niko Roberts
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-01-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.21'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.21'
69
+ description: Checks your Gemfile.lock against the RubyGems API to ensure no gems are
70
+ younger than a specified number of days
71
+ email:
72
+ - niko@example.com
73
+ executables: []
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - CHANGELOG.md
78
+ - LICENSE
79
+ - README.md
80
+ - bundler-age_gate.gemspec
81
+ - lib/bundler/age_gate.rb
82
+ - lib/bundler/age_gate/audit_logger.rb
83
+ - lib/bundler/age_gate/command.rb
84
+ - lib/bundler/age_gate/config.rb
85
+ - lib/bundler/age_gate/version.rb
86
+ - plugins.rb
87
+ homepage: https://github.com/NikoRoberts/bundler-age_gate
88
+ licenses:
89
+ - MIT
90
+ metadata:
91
+ homepage_uri: https://github.com/NikoRoberts/bundler-age_gate
92
+ source_code_uri: https://github.com/NikoRoberts/bundler-age_gate
93
+ changelog_uri: https://github.com/NikoRoberts/bundler-age_gate/blob/main/CHANGELOG.md
94
+ post_install_message:
95
+ rdoc_options: []
96
+ require_paths:
97
+ - lib
98
+ required_ruby_version: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: 2.7.0
103
+ required_rubygems_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: '0'
108
+ requirements: []
109
+ rubygems_version: 3.5.22
110
+ signing_key:
111
+ specification_version: 4
112
+ summary: A Bundler plugin to enforce minimum gem age requirements
113
+ test_files: []