diff --git a/ansible/roles/gem-patch-report/README.md b/ansible/roles/gem-patch-report/README.md new file mode 100644 index 0000000..2ddd7e2 --- /dev/null +++ b/ansible/roles/gem-patch-report/README.md @@ -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. diff --git a/ansible/roles/gem-patch-report/defaults/main.yml b/ansible/roles/gem-patch-report/defaults/main.yml new file mode 100644 index 0000000..9cb5bd9 --- /dev/null +++ b/ansible/roles/gem-patch-report/defaults/main.yml @@ -0,0 +1,3 @@ +--- +gem_patch_report_enabled: false +gem_patch_report_app_path: "/var/www/{{ project }}/current" diff --git a/ansible/roles/gem-patch-report/tasks/main.yml b/ansible/roles/gem-patch-report/tasks/main.yml new file mode 100644 index 0000000..38db638 --- /dev/null +++ b/ansible/roles/gem-patch-report/tasks/main.yml @@ -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 diff --git a/ansible/roles/gem-patch-report/templates/gem-patch-report.sh.j2 b/ansible/roles/gem-patch-report/templates/gem-patch-report.sh.j2 new file mode 100644 index 0000000..0790bcd --- /dev/null +++ b/ansible/roles/gem-patch-report/templates/gem-patch-report.sh.j2 @@ -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"