#!/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 ommit_columns_by_indices(opts = {}) csv = opts[:csv] columns_index_arr = opts[:columns_index_arr] csv.each do |line_arr| line_arr.delete_if.with_index do |_, index| columns_index_arr.include?(index) end end 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 end if csv2.length >= csv1.length larger_csv = csv2 larger_csv_orig = CSV.read(c2_path) smaller_csv = csv1 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 |cn| column_name = cn.strip.chomp column_index = smaller_csv.first.find_index(column_name) columns_index_arr.push(column_index) end if columns_index_arr.any? larger_csv = ommit_columns_by_indices( csv: larger_csv, columns_index_arr: columns_index_arr ) smaller_csv = ommit_columns_by_indices( csv: smaller_csv, columns_index_arr: columns_index_arr ) end # Write diff with redacted columns (to find differences we care about) CSV.open(diff_path, 'w', col_sep: ',', quote_char: '"', force_quotes: true) do |csv| larger_csv.each do |line_arr| line = line_arr.join(',') csv.puts line_arr if smaller_csv.select { |sc| sc.join(',') == line }.empty? end end diff_csv = CSV.read(diff_path) # Write diff again with all columns. csv_headers_orig = larger_csv_orig.first CSV.open(diff_path, 'w', col_sep: ',', quote_char: '"', force_quotes: true) do |csv| if no_headers larger_csv_orig.each_with_index do |line_arr, index| line = larger_csv[index].join(',') csv.puts line_arr if diff_csv.select { |dc| dc.join(',') == line }.any? end else csv.puts csv_headers_orig larger_csv_orig[1..-1].each_with_index do |line_arr, index| line = larger_csv[index].join(',') csv.puts line_arr if diff_csv.select { |dc| dc.join(',') == line }.any? 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 )