Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions ansible/roles/gem-patch-report/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Gem Patch Report Role

This Ansible role generates reports on Ruby gems that currently have security vulnerabilities. It runs bundle-audit on your application's Gemfile.lock and reports the vulnerable gems along with their current installed versions, advisory IDs, and criticality levels.

## Requirements

- Ruby application with Gemfile and Gemfile.lock
- bundler-audit gem (automatically installed if missing)
- Stats server configuration (same as other subspace roles)

## Role Variables

Available variables with their default values:

```yaml
# Whether to enable gem patch reporting
gem_patch_report_enabled: false

# Path to the Rails/Ruby application
gem_patch_report_app_path: "/var/www/{{ project }}/current"

# Required for stats reporting (inherited from other roles)
send_stats: false
stats_url: ""
stats_api_key: ""
hostname: ""
```

## Usage

1. Enable the role in your playbook:
```yaml
roles:
- gem-patch-report
```

2. Configure the required variables:
```yaml
gem_patch_report_enabled: true
send_stats: true
stats_url: "https://your-stats-server.com/api/stats"
stats_api_key: "your-api-key"
```

## Bundle-Audit Input Format

The role processes JSON output from `bundle-audit check --format json`. Here's the simplified structure:

```json
{
"version": "0.9.2",
"created_at": "2026-03-06 12:16:58 -0600",
"results": [
{
"type": "unpatched_gem",
"gem": {
"name": "nokogiri",
"version": "1.18.9"
},
"advisory": {
"id": "GHSA-wx95-c6cv-8532",
"title": "Nokogiri does not check the return value from xmlC14NExecute",
"criticality": "medium",
"cve": null,
"patched_versions": [">= 1.19.1"]
}
}
]
}
```

### Key Fields Used

The role extracts data from these fields:
- `results[].type` - Filters for `"unpatched_gem"`
- `results[].gem.name` - Gem name
- `results[].gem.version` - Vulnerable version
- `results[].advisory.id` - Advisory ID (CVE, GHSA, etc.)
- `results[].advisory.criticality` - Severity level

## Report Format

The role generates a JSON array of currently vulnerable gems with detailed advisory information:

```json
[
{
"name": "faraday",
"version": "2.13.2",
"current_version": "2.13.2",
"advisory_id": "CVE-2026-25765",
"criticality": "medium"
},
{
"name": "nokogiri",
"version": "1.18.9",
"current_version": "1.18.9",
"advisory_id": "GHSA-wx95-c6cv-8532",
"criticality": "medium"
},
{
"name": "rack",
"version": "3.2.3",
"current_version": "3.2.3",
"advisory_id": "CVE-2026-22860",
"criticality": "high"
}
]
```

### Gem Objects

Each gem object contains:
- `name`: The gem name
- `version`: The vulnerable version detected by bundle-audit
- `current_version`: The currently installed version (detected via bundle/gem commands)
- `advisory_id`: The security advisory ID (CVE, GHSA, etc.)
- `criticality`: The severity level (low, medium, high, critical)

## Tags

This role supports the following tags:
- `maintenance`
- `stats`
- `gem-patch-report`

## Error Handling

The role includes error handling for common scenarios:
- Missing Gemfile.lock
- bundle-audit execution failures

Errors are logged but don't fail the entire playbook execution.
3 changes: 3 additions & 0 deletions ansible/roles/gem-patch-report/defaults/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
---
gem_patch_report_enabled: false
gem_patch_report_app_path: "/var/www/{{ project }}/current"
69 changes: 69 additions & 0 deletions ansible/roles/gem-patch-report/tasks/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
- name: Check if gem patch reporting is enabled and required variables are set
debug:
msg: "Gem patch reporting is enabled"
when: gem_patch_report_enabled == true and send_stats == true and stats_url is defined and stats_api_key is defined

- name: Install bundler-audit gem globally
gem:
name: bundler-audit
state: present
user_install: false
become: true
when: gem_patch_report_enabled == true and send_stats == true and stats_url is defined and stats_api_key is defined
tags:
- maintenance
- stats
- gem-patch-report

- name: Generate gem patch report script
template:
src: gem-patch-report.sh.j2
dest: /tmp/gem-patch-report.sh
mode: '0755'
when: gem_patch_report_enabled == true and send_stats == true and stats_url is defined and stats_api_key is defined
tags:
- maintenance
- stats
- gem-patch-report

- name: Run gem patch report script
shell: /tmp/gem-patch-report.sh
register: gem_patch_report_result
when: gem_patch_report_enabled == true and send_stats == true and stats_url is defined and stats_api_key is defined
tags:
- maintenance
- stats
- gem-patch-report
ignore_errors: yes

- name: Send gem patch report to stats server
uri:
url: "{{ stats_url }}"
method: POST
headers:
X-API-Version: 1
X-Client-Api-key: "{{ stats_api_key }}"
body_format: json
body:
client_stat:
stat_type: gem_patch_report
value: "{{ gem_patch_report_result.stdout | from_json }}"
hostname: "{{ hostname }}"
when: gem_patch_report_enabled == true and send_stats == true and stats_url is defined and stats_api_key is defined and gem_patch_report_result.stdout is defined and gem_patch_report_result.stdout != ""
tags:
- maintenance
- stats
- gem-patch-report
ignore_errors: yes

- name: Clean up temporary gem patch report script
file:
path: /tmp/gem-patch-report.sh
state: absent
when: gem_patch_report_enabled == true and send_stats == true and stats_url is defined and stats_api_key is defined
tags:
- maintenance
- stats
- gem-patch-report
ignore_errors: yes
95 changes: 95 additions & 0 deletions ansible/roles/gem-patch-report/templates/gem-patch-report.sh.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/bin/bash
set -e

APP_PATH="{{ gem_patch_report_app_path }}"
TEMP_DIR="/tmp/gem-patch-report-$$"
REPORT_FILE="$TEMP_DIR/report.json"
CURRENT_AUDIT_FILE="$TEMP_DIR/audit_current.json"

# Create temp directory
mkdir -p "$TEMP_DIR"

# Change to app directory
cd "$APP_PATH"

# Check if Gemfile.lock exists
if [ ! -f "Gemfile.lock" ]; then
echo '{"error": "No Gemfile.lock found in '"$APP_PATH"'"}' > "$REPORT_FILE"
cat "$REPORT_FILE"
rm -rf "$TEMP_DIR"
exit 1
fi

# Install bundle-audit if not present
if ! command -v bundle-audit &> /dev/null; then
gem install bundler-audit
fi

# Run bundle-audit on current Gemfile.lock
bundle-audit check --format json > "$CURRENT_AUDIT_FILE" 2>/dev/null || echo '{"results":[]}' > "$CURRENT_AUDIT_FILE"

# Set environment variable for Ruby script
export CURRENT_AUDIT_FILE="$CURRENT_AUDIT_FILE"

# Generate patch report using Ruby
ruby << 'RUBY' > "$REPORT_FILE"
require 'json'

begin
current_audit = JSON.parse(File.read(ENV['CURRENT_AUDIT_FILE']))
current_results = current_audit['results'] || []

# Helper function to get current gem version
def get_current_version(gem_name)
begin
# Try bundle show first (works in bundler context)
output = `bundle show #{gem_name} 2>/dev/null`
if $?.success? && output =~ /#{gem_name} \(([^)]+)\)/
return $1
end

# Fallback to bundle info
output = `bundle info #{gem_name} --version 2>/dev/null`.strip
if $?.success? && !output.empty?
return output
end

# Last resort: gem list
output = `gem list "^#{gem_name}$" --local 2>/dev/null`
if $?.success? && output =~ /#{gem_name} \(([^)]+)\)/
return $1.split(',').first.strip
end

return 'unknown'
rescue
return 'unknown'
end
end

# Create report in one pass
vulnerable_gems = current_results
.select { |result| result['type'] == 'unpatched_gem' && result['gem'] && result['advisory'] }
.map { |result|
{
'name' => result['gem']['name'],
'version' => result['gem']['version'],
'current_version' => get_current_version(result['gem']['name']),
'advisory_id' => result['advisory']['id'],
'criticality' => result['advisory']['criticality']
}
}
.uniq { |gem| gem['name'] }
.sort_by { |gem| gem['name'] }

puts JSON.pretty_generate(vulnerable_gems)

rescue JSON::ParserError => e
puts '{"error": "Failed to parse bundle-audit JSON output"}'
rescue => e
puts '{"error": "' + e.message.gsub('"', '\"') + '"}'
end
RUBY

# Output the report and clean up
cat "$REPORT_FILE"
rm -rf "$TEMP_DIR"