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 +7 -0
- data/CHANGELOG.md +59 -0
- data/LICENSE +21 -0
- data/README.md +291 -0
- data/bundler-age_gate.gemspec +36 -0
- data/lib/bundler/age_gate/audit_logger.rb +47 -0
- data/lib/bundler/age_gate/command.rb +203 -0
- data/lib/bundler/age_gate/config.rb +121 -0
- data/lib/bundler/age_gate/version.rb +7 -0
- data/lib/bundler/age_gate.rb +9 -0
- data/plugins.rb +19 -0
- metadata +113 -0
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
|
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: []
|