#!/usr/bin/env ruby # frozen_string_literal: true require 'optparse' require 'csv' opts = {} OptionParser.new do |options| options.banner = "USAGE: #{$PROGRAM_NAME} [opts] EXAMPLE w/ Tenable Nessus Results: #{$PROGRAM_NAME} \\ --csv-a vuln_scan1.csv \\ --csv-b vuln_scan2.csv \\ --csv-diff vuln_scan_diff.csv \\ --exclude-column-names 'Synopsis,Description,Solution,See Also,Plugin Output,Asset UUID,Vulnerability State,IP Address,FQDN,NetBios,OS,MAC Address,Plugin Family,CVSS Base Score,CVSS Temporal Score,CVSS Temporal Vector,CVSS Vector,CVSS3 Base Score,CVSS3 Temporal Score,CVSS3 Temporal Vector,CVSS3 Vector,System Type,Host Start,Host End,Vulnerability Priority Rating (VPR),First Found,Last Found,Host Scan Schedule ID,Host Scan ID,Indexed At,Last Authenticated Results Date,Last Unauthenticated Results Date,Tracked,Risk Factor,Severity,Original Severity,Modification,Plugin Family ID,Plugin Type,Plugin Version,Service,Plugin Modification Date,Plugin Publication Date,Checks for Malware,Exploit Available,Exploited by Malware,Exploited by Nessus,CANVAS,D2 Elliot,Metasploit,Core Exploits,ExploitHub,Default Account,Patch Available,In The News,Unsupported By Vendor,Last Fixed' " options.on('-aCSV', '--csv-a=CSV', '') do |c1| opts[:c1_path] = c1 end options.on('-bCSV', '--csv-b=CSV', '') do |c2| opts[:c2_path] = c2 end options.on('-dDIFF', '--csv-diff=DIFF', '') do |d| opts[:diff_path] = d end options.on('-eNAMES', '--exclude-column-names=NAMES', '') do |n| opts[:column_names_to_exclude] = n end options.on('-n', '--no-headers', '') do |h| opts[:no_headers] = h end end.parse! if opts.empty? puts `#{$PROGRAM_NAME} --help` exit 1 end def csv_diff(opts = {}) c1_path = opts[:c1_path] c2_path = opts[:c2_path] diff_path = opts[:diff_path] no_headers = opts[:no_headers] column_names_to_exclude = opts[:column_names_to_exclude].to_s.split(',') csv1 = CSV.read(c1_path) csv2 = CSV.read(c2_path) if csv1.length >= csv2.length larger_csv = csv1 larger_csv_orig = CSV.read(c1_path) smaller_csv = csv2 smaller_csv_orig = CSV.read(c2_path) end if csv2.length >= csv1.length larger_csv = csv2 larger_csv_orig = CSV.read(c2_path) smaller_csv = csv1 smaller_csv_orig = CSV.read(c1_path) end # Exclude the column values for diff to ensure the same rows # with for example different timestamps aren't included. columns_index_arr = [] column_names_to_exclude&.each do |column_name| column_index = smaller_csv.first.find_index(column_name) columns_index_arr.push(column_index) end if columns_index_arr.any? larger_csv.each do |line_arr| line_arr.delete_if.with_index do |_, index| columns_index_arr.include?(index) end end smaller_csv.each do |line_arr| line_arr.delete_if.with_index do |_, index| columns_index_arr.include?(index) end end end # Write diff with redacted columns (to find differences we care about) File.open(diff_path, 'w') do |f| larger_csv.each do |line_arr| line = line_arr.join(',') f.puts line unless smaller_csv.include?(line_arr) end end diff_csv = CSV.read(diff_path) # Write diff again with all columns. csv_headers_orig = larger_csv_orig.first.join(',') File.open(diff_path, 'w') do |f| if no_headers larger_csv_orig.each do |line_arr| line = line_arr.join(',') f.puts line if diff_csv.include?(line_arr) end else f.puts csv_headers_orig larger_csv_orig(1..-1).each do |line_arr| line = line_arr.join(',') f.puts line if diff_csv.include?(line_arr) end end end end c1_path = opts[:c1_path] c2_path = opts[:c2_path] diff_path = opts[:diff_path] column_names_to_exclude = opts[:column_names_to_exclude] no_headers = true if opts[:no_headers] no_headers ||= false # Compare which two is larger csv_diff( c1_path: c1_path, c2_path: c2_path, diff_path: diff_path, no_headers: no_headers, column_names_to_exclude: column_names_to_exclude )