subspace 3.0.20 → 3.0.21

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 60535a3d51d04728b59132a9a0db1ca1c4e13fb61a912daa0c507d74af77ba3c
4
- data.tar.gz: ddeb0f458623dd033215b7ee6c72710da921d653d6bbcf43cdee54e7772bfd55
3
+ metadata.gz: df4ca7c7afe682b2ea4aa12fd7ce11ea9fd7d87a380cd9b7adc9b4b3aa554228
4
+ data.tar.gz: 5f14219cb20dcfdb6adf6dde79b49dbdd33f1db12223dee02c8ddc76e5f72138
5
5
  SHA512:
6
- metadata.gz: 5022d270ed1eba11e31d7a61aedb9a8744b40075b0d7b7670be57f6bee4e1f7c3c813bd38b4d86f39fef333701b3f97d47c5e441dc87123cb61aad8d4d5e3884
7
- data.tar.gz: bbefbc1564cac47009312fc388d0786d3de8361f41e21cac4005c3068c62940c021c068cc47512e749a72fbe1b1813b2a2d6f494e5e56550b8ad10a73e548a78
6
+ metadata.gz: 6cccd145a57a66a18dc2224496e250b022801d1f57643bbddd14bc4e7a844d684d9656d919c19fd339455ac209e1ef204702e0ea68a179faf6990181514479d0
7
+ data.tar.gz: b0eeed81c79e8637f78db6be40f32e8d6fff8ad552bf44e1305dad4fe57d0ede687bf3aa32de842ebaaee5cd4bbf1f38330a6ef74224b7412fa7f9e1ff02eb3d
data/CHANGELOG.md CHANGED
@@ -12,6 +12,9 @@ This project attempts to follow [semantic versioning](https://semver.org/).
12
12
 
13
13
  ## Unreleased
14
14
 
15
+ ## 3.0.21
16
+ * Add gem-patch-report role. Sends stats for each vulnerable gem fixed since the start of the month.
17
+
15
18
  ## 3.0.20
16
19
  * Update postgresql-client role to get the actual psql database version instead of the local client version
17
20
 
@@ -0,0 +1,137 @@
1
+ # Gem Patch Report Role
2
+
3
+ This Ansible role generates reports on Ruby gems that had security vulnerabilities at the beginning of the current month and have since been patched. It compares the Gemfile.lock from the start of the month (retrieved from the Capistrano git repo) against the current Gemfile.lock using bundle-audit, and reports only the gems that have been fixed.
4
+
5
+ ## Requirements
6
+
7
+ - Ruby application deployed with Capistrano (requires the `repo/` directory)
8
+ - bundler-audit gem (automatically installed if missing)
9
+ - Stats server configuration (same as other subspace roles)
10
+
11
+ ## Role Variables
12
+
13
+ Available variables with their default values:
14
+
15
+ ```yaml
16
+ # Path to the Rails/Ruby application (Capistrano current symlink)
17
+ gem_patch_report_app_path: "/u/apps/{{project_name}}/current"
18
+
19
+ # Required for stats reporting (inherited from other roles)
20
+ send_stats: false
21
+ stats_url: ""
22
+ stats_api_key: ""
23
+ hostname: ""
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ 1. Enable the role in your playbook:
29
+ ```yaml
30
+ roles:
31
+ - gem-patch-report
32
+ ```
33
+
34
+ 2. Configure the required variables:
35
+ ```yaml
36
+ send_stats: true
37
+ stats_url: "https://your-stats-server.com/api/stats"
38
+ stats_api_key: "your-api-key"
39
+ ```
40
+
41
+ ## How It Works
42
+
43
+ 1. Retrieves the Gemfile.lock from the last commit before the 1st of the current month using the Capistrano bare git repo at `<deploy_to>/repo/`
44
+ 2. Runs `bundle-audit` against both the old and current Gemfile.lock
45
+ 3. Compares the results to identify vulnerabilities that existed at the start of the month but are no longer present (i.e. patched)
46
+ 4. Outputs a report containing only the patched gems
47
+
48
+ ## Bundle-Audit Input Format
49
+
50
+ The role processes JSON output from `bundle-audit check --format json`. Here's the simplified structure:
51
+
52
+ ```json
53
+ {
54
+ "version": "0.9.2",
55
+ "created_at": "2026-03-06 12:16:58 -0600",
56
+ "results": [
57
+ {
58
+ "type": "unpatched_gem",
59
+ "gem": {
60
+ "name": "nokogiri",
61
+ "version": "1.18.9"
62
+ },
63
+ "advisory": {
64
+ "id": "GHSA-wx95-c6cv-8532",
65
+ "title": "Nokogiri does not check the return value from xmlC14NExecute",
66
+ "criticality": "medium",
67
+ "cve": null,
68
+ "patched_versions": [">= 1.19.1"]
69
+ }
70
+ }
71
+ ]
72
+ }
73
+ ```
74
+
75
+ ### Key Fields Used
76
+
77
+ The role extracts data from these fields:
78
+ - `results[].type` - Filters for `"unpatched_gem"`
79
+ - `results[].gem.name` - Gem name
80
+ - `results[].gem.version` - Vulnerable version
81
+ - `results[].advisory.id` - Advisory ID (CVE, GHSA, etc.)
82
+ - `results[].advisory.criticality` - Severity level
83
+
84
+ ## Report Format
85
+
86
+ The role generates a JSON array of gems that were patched during the current month. If a gem had multiple advisories that were all fixed, each advisory appears as its own entry.
87
+
88
+ ```json
89
+ [
90
+ {
91
+ "name": "activestorage",
92
+ "version": "8.0.2.1",
93
+ "current_version": "8.0.4.1",
94
+ "advisory_id": "CVE-2026-33658",
95
+ "criticality": null
96
+ },
97
+ {
98
+ "name": "bcrypt",
99
+ "version": "3.1.20",
100
+ "current_version": "3.1.22",
101
+ "advisory_id": "CVE-2026-33306",
102
+ "criticality": null
103
+ },
104
+ {
105
+ "name": "devise",
106
+ "version": "4.9.4",
107
+ "current_version": "5.0.3",
108
+ "advisory_id": "CVE-2026-32700",
109
+ "criticality": null
110
+ }
111
+ ]
112
+ ```
113
+
114
+ ### Gem Objects
115
+
116
+ Each gem object contains:
117
+ - `name`: The gem name
118
+ - `version`: The vulnerable version from the start of the month
119
+ - `current_version`: The current patched version (parsed from the current Gemfile.lock)
120
+ - `advisory_id`: The security advisory ID (CVE, GHSA, etc.)
121
+ - `criticality`: The severity level (low, medium, high, critical, or null if not specified)
122
+
123
+ ## Tags
124
+
125
+ This role supports the following tags:
126
+ - `maintenance`
127
+ - `stats`
128
+ - `gem-patch-report`
129
+
130
+ ## Error Handling
131
+
132
+ The role includes error handling for common scenarios:
133
+ - Missing Gemfile.lock
134
+ - No git history before the current month
135
+ - bundle-audit execution failures
136
+
137
+ Errors are logged but don't fail the entire playbook execution. If no patched gems are found, an empty array `[]` is reported.
@@ -0,0 +1,2 @@
1
+ ---
2
+ gem_patch_report_app_path: "/u/apps/{{project_name}}/current"
@@ -0,0 +1,89 @@
1
+ ---
2
+ - name: Check if gem patch reporting is enabled and required variables are set
3
+ debug:
4
+ msg: "Gem patch reporting is enabled"
5
+ when: send_stats == true and stats_url is defined and stats_api_key is defined
6
+ tags:
7
+ - maintenance
8
+ - stats
9
+ - gem-patch-report
10
+
11
+ - name: Install bundler-audit gem globally
12
+ gem:
13
+ name: bundler-audit
14
+ state: present
15
+ user_install: false
16
+ become: true
17
+ when: send_stats == true and stats_url is defined and stats_api_key is defined
18
+ tags:
19
+ - maintenance
20
+ - stats
21
+ - gem-patch-report
22
+
23
+ - name: Generate gem patch report script
24
+ template:
25
+ src: gem-patch-report.sh.j2
26
+ dest: /tmp/gem-patch-report.sh
27
+ mode: '0755'
28
+ when: send_stats == true and stats_url is defined and stats_api_key is defined
29
+ tags:
30
+ - maintenance
31
+ - stats
32
+ - gem-patch-report
33
+
34
+ - name: Run gem patch report script
35
+ shell: /tmp/gem-patch-report.sh
36
+ register: gem_patch_report_result
37
+ become: true
38
+ when: send_stats == true and stats_url is defined and stats_api_key is defined
39
+ tags:
40
+ - maintenance
41
+ - stats
42
+ - gem-patch-report
43
+ ignore_errors: yes
44
+
45
+ - name: Read gem patch report file
46
+ slurp:
47
+ src: /tmp/gem-patch-report.json
48
+ register: gem_patch_report_file
49
+ when: send_stats == true and stats_url is defined and stats_api_key is defined
50
+ tags:
51
+ - maintenance
52
+ - stats
53
+ - gem-patch-report
54
+ ignore_errors: yes
55
+
56
+ - name: Send gem patch report to stats server
57
+ uri:
58
+ url: "{{ stats_url }}"
59
+ method: POST
60
+ headers:
61
+ X-API-Version: 1
62
+ X-Client-Api-key: "{{ stats_api_key }}"
63
+ body_format: json
64
+ body:
65
+ client_stat:
66
+ stat_type: gem_patch_report
67
+ value: "{{ (gem_patch_report_file.content | b64decode) | from_json }}"
68
+ hostname: "{{ hostname }}"
69
+ when: send_stats == true and stats_url is defined and stats_api_key is defined and gem_patch_report_file is defined and gem_patch_report_file is not failed
70
+ tags:
71
+ - maintenance
72
+ - stats
73
+ - gem-patch-report
74
+ ignore_errors: yes
75
+
76
+ - name: Clean up temporary gem patch report files
77
+ file:
78
+ path: "{{ item }}"
79
+ state: absent
80
+ become: true
81
+ loop:
82
+ - /tmp/gem-patch-report.sh
83
+ - /tmp/gem-patch-report.json
84
+ when: send_stats == true and stats_url is defined and stats_api_key is defined
85
+ tags:
86
+ - maintenance
87
+ - stats
88
+ - gem-patch-report
89
+ ignore_errors: yes
@@ -0,0 +1,114 @@
1
+ #!/bin/bash
2
+
3
+ APP_PATH="{{ gem_patch_report_app_path }}"
4
+ DEPLOY_DIR=$(cd "$APP_PATH" && cd .. && pwd)
5
+ REPO_DIR="$DEPLOY_DIR/repo"
6
+ TEMP_DIR="/tmp/gem-patch-report-$$"
7
+ REPORT_FILE="$TEMP_DIR/report.json"
8
+ OLD_AUDIT_FILE="$TEMP_DIR/audit_old.json"
9
+ CURRENT_AUDIT_FILE="$TEMP_DIR/audit_current.json"
10
+ OLD_GEMFILE_LOCK="$APP_PATH/Gemfile.lock.old"
11
+
12
+ # Create temp directory
13
+ mkdir -p "$TEMP_DIR"
14
+
15
+ # Check if current Gemfile.lock exists
16
+ if [ ! -f "$APP_PATH/Gemfile.lock" ]; then
17
+ echo '[]' > /tmp/gem-patch-report.json
18
+ rm -rf "$TEMP_DIR"
19
+ exit 0
20
+ fi
21
+
22
+ # Update bundle-audit advisory database
23
+ cd "$APP_PATH"
24
+ bundle exec bundle-audit update 2>/dev/null || true
25
+
26
+ # Get old Gemfile.lock from the beginning of the month
27
+ FIRST_OF_MONTH=$(date +"%Y-%m-01")
28
+ OLD_COMMIT=$(git -c safe.directory="$REPO_DIR" --git-dir="$REPO_DIR" log --all --before="$FIRST_OF_MONTH" -1 --format="%H" -- Gemfile.lock 2>/dev/null)
29
+
30
+ if [ -z "$OLD_COMMIT" ]; then
31
+ # No commit before this month, nothing to compare
32
+ echo '[]' > /tmp/gem-patch-report.json
33
+ rm -rf "$TEMP_DIR"
34
+ exit 0
35
+ fi
36
+
37
+ git -c safe.directory="$REPO_DIR" --git-dir="$REPO_DIR" show "${OLD_COMMIT}:Gemfile.lock" > "$OLD_GEMFILE_LOCK"
38
+
39
+ # Run bundle-audit on the old Gemfile.lock
40
+ # bundle-audit exits non-zero when vulnerabilities are found, so ignore the exit code
41
+ bundle exec bundle-audit check --gemfile-lock Gemfile.lock.old --format json > "$OLD_AUDIT_FILE" 2>/dev/null || true
42
+
43
+ # Run bundle-audit on current Gemfile.lock
44
+ bundle exec bundle-audit check --format json > "$CURRENT_AUDIT_FILE" 2>/dev/null || true
45
+
46
+ # Set environment variables for Ruby script
47
+ export OLD_AUDIT_FILE="$OLD_AUDIT_FILE"
48
+ export CURRENT_AUDIT_FILE="$CURRENT_AUDIT_FILE"
49
+ export CURRENT_GEMFILE_LOCK="$APP_PATH/Gemfile.lock"
50
+
51
+ # Generate patch report using Ruby
52
+ ruby << 'RUBY' > "$REPORT_FILE"
53
+ require 'json'
54
+ require 'set'
55
+
56
+ begin
57
+ old_audit = JSON.parse(File.read(ENV['OLD_AUDIT_FILE']))
58
+ current_audit = JSON.parse(File.read(ENV['CURRENT_AUDIT_FILE']))
59
+
60
+ old_results = old_audit['results'] || []
61
+ current_results = current_audit['results'] || []
62
+
63
+ # Build a set of current vulnerability keys (gem name + advisory id)
64
+ current_vuln_keys = current_results
65
+ .select { |r| r['type'] == 'unpatched_gem' && r['gem'] && r['advisory'] }
66
+ .map { |r| "#{r['gem']['name']}:#{r['advisory']['id']}" }
67
+ .to_set
68
+
69
+ # Parse current Gemfile.lock to get current gem versions
70
+ current_versions = {}
71
+ gemfile_lock = File.read(ENV['CURRENT_GEMFILE_LOCK']) rescue ''
72
+ in_specs = false
73
+ gemfile_lock.each_line do |line|
74
+ if line.strip == 'specs:'
75
+ in_specs = true
76
+ next
77
+ end
78
+ if in_specs
79
+ if line =~ /^\s{4}(\S+)\s+\(([^)]+)\)/
80
+ current_versions[$1] = $2
81
+ elsif line !~ /^\s/
82
+ in_specs = false
83
+ end
84
+ end
85
+ end
86
+
87
+ # Only include gems that were vulnerable at start of month but are now patched
88
+ patched_gems = old_results
89
+ .select { |r| r['type'] == 'unpatched_gem' && r['gem'] && r['advisory'] }
90
+ .reject { |r| current_vuln_keys.include?("#{r['gem']['name']}:#{r['advisory']['id']}") }
91
+ .map { |r|
92
+ {
93
+ 'name' => r['gem']['name'],
94
+ 'version' => r['gem']['version'],
95
+ 'current_version' => current_versions[r['gem']['name']] || 'unknown',
96
+ 'advisory_id' => r['advisory']['id'],
97
+ 'criticality' => r['advisory']['criticality']
98
+ }
99
+ }
100
+ .sort_by { |g| [g['name'], g['advisory_id']] }
101
+
102
+ puts JSON.pretty_generate(patched_gems)
103
+
104
+ rescue JSON::ParserError => e
105
+ puts '[]'
106
+ rescue => e
107
+ puts '[]'
108
+ end
109
+ RUBY
110
+
111
+ # Copy report to known location and clean up
112
+ cp "$REPORT_FILE" /tmp/gem-patch-report.json
113
+ rm -rf "$TEMP_DIR"
114
+ rm -f "$OLD_GEMFILE_LOCK"
@@ -1,3 +1,3 @@
1
1
  module Subspace
2
- VERSION = "3.0.20"
2
+ VERSION = "3.0.21"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: subspace
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.20
4
+ version: 3.0.21
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Samson
@@ -153,6 +153,10 @@ files:
153
153
  - ansible/roles/delayed_job/tasks/main.yml
154
154
  - ansible/roles/delayed_job/templates/delayed-job-monit-rc
155
155
  - ansible/roles/delayed_job/templates/delayed-job-systemd.service
156
+ - ansible/roles/gem-patch-report/README.md
157
+ - ansible/roles/gem-patch-report/defaults/main.yml
158
+ - ansible/roles/gem-patch-report/tasks/main.yml
159
+ - ansible/roles/gem-patch-report/templates/gem-patch-report.sh.j2
156
160
  - ansible/roles/letsencrypt/defaults/main.yml
157
161
  - ansible/roles/letsencrypt/tasks/legacy.yml
158
162
  - ansible/roles/letsencrypt/tasks/main.yml