#!/usr/bin/perl -w
use utf8;

use strict;
my $dn = `dirname $0`;chomp($dn);
my $pwd = `pwd`;chomp($pwd);
if ($dn !~ /^\//) { $dn = $pwd . "/" . $dn; }
push @INC,$dn;
my $cverepobase=`dirname $dn`;
chomp($cverepobase);

use POSIX qw/strftime setlocale LC_TIME/;
use List::MoreUtils qw/uniq/;
use DateTime;
use JSON;
use Data::Dumper;

my $jsoncoder = JSON->new->allow_nonref;
$jsoncoder->canonical(1);

require Sign;
require SMASHData;
require CanDBReader;
require UpdateInfoReader;
UpdateInfoReader->import_product_updates();
# FIXME UpdateInfoReader->import_images();
require CVEListReader;

my $mode = pop @ARGV;

if (!defined($mode)) { $mode = "fast"; }

my $xpath = "/mounts/mirror/SuSE/ftp.suse.com/pub/projects/security/csaf/";
# my $xpath = "$cverepobase/csaf/";
chdir($xpath)||die "chdir $xpath:$!";

sub
filter_score($) {
	my ($score) = @_;

	$score =~ s/^(\d*\.\d)\d*$/$1/;
	return eval($score);	# string to number
}

sub
map_cvss_severity($) {
	my ($score) = @_;

	return "CRITICAL"	if ($score >= 9.0);
	return "HIGH"		if ($score >= 7.0);
	return "MEDIUM"		if ($score >= 4.0);
	return "LOW"		if ($score >= 0.1);
	return "NONE";
}

my %lastmodified = ();

sub generate_csaf() {
# products for which we want to dump csaf
	my @products = ();
	my %xx;

	my %allpatches;

	my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) = localtime();

	$year += 1900;

# collect all patches we want to do by product (SLED/SLES, various SPs)

	foreach my $product (keys %UpdateInfoReader::patches) {
		my %patches = %{$UpdateInfoReader::patches{$product}};

		next if ($product =~ /SUSE:SLE-/);
		next if ($product =~ /openSUSE 1/);
		next if ($product =~ /openSUSE Ever/);

		push @products, $product;

		print STDERR "scanning patches for $product\n" if (-t STDERR);

		foreach my $patch (keys %patches) {
			if ($UpdateInfoReader::patchinqa{$patch}) {
				print STDERR "$patch is in QA\n" if (-t STDERR);
				next;
			}
			$allpatches{$patch} = 1;
		}
	}
	my @notices = keys %CanDBReader::susenotice2patches;
	foreach my $notice (sort @notices) {
		my @patches = sort keys %{$CanDBReader::susenotice2patches{$notice}};

		my $patch;
		my $codestreampatch;

		foreach my $xpatch (@patches) {
			if (($xpatch =~ /^SUSE-\d*-\d*$/)  && (!defined($codestreampatch))) { # codestream
				$codestreampatch = $xpatch;
			}
# skip removed patches. this has happened as we released patches during development and later deleted them as they were included in GA.
			$patch = $xpatch if (defined($UpdateInfoReader::patchtype{$xpatch}));
		}
		if (!defined($patch)) {
			print STDERR "ERROR: notice $notice has no valid patches out of: " . join(",",@patches) . "\n";
			next;
		}

# codestreampatch is only on SLE, but here we process also openSUSE
		if (defined($codestreampatch) && ($codestreampatch =~ /SUSE(-\d*-\d*)$/)) {
			my $patchid = $1;
# add all images and containers here ... wild hack
			push @patches, grep (/^Image.*$patchid$/,keys %allpatches);
			push @patches, grep (/^Container.*$patchid$/,keys %allpatches);
		}

		unless (defined($UpdateInfoReader::patchtype{$patch})) {
			print STDERR "SKIP $notice as $patch is not known.\n";
			print STDERR "patches: " . join(",",@patches) . "\n";
			next;
		}
		if ($UpdateInfoReader::patchtype{$patch} ne "security") {
# check if we have CVEs in references.
			unless (grep (/CVE/,keys %{$UpdateInfoReader::patchreferences{$patch}})) {
				print STDERR "SKIP $notice as $patch is not security or has no CVEs.\n";
				print STDERR "patches: " . join(",",@patches) . "\n";
				next;
			}
		}

		@patches = sort @patches;

		print STDERR "$notice ...\n" if (-t STDERR);
		if (!defined($UpdateInfoReader::patchpackages{$patch})) {
			print STDERR "SKIP $notice: deleted patch $patch?\n";
			next;
		}
		my @affectedproducts = ();


#print "scanning patch $patch\n";

# strict filenames rules: https://docs.oasis-open.org/csaf/csaf/v2.0/csaf-v2.0.html#51-filename
# the "id" is convered to the filename. We use the notice as ID.
# done: lowercasing and : replaced by _
		my $fn = "\L$notice.json";
		$fn =~ s/:/_/;

		unless ($fn =~ /^[+\-a-z0-9_]+.json$/) {
			warn "invalid filename $fn\n";
			next;
		}


# read old JSON, if present.
# we need this for the index files

		my $json = "";
		my $oldcsafmap;
		my $lastmodified;

		if (open(OLDJSON,"<$fn")) {
			$json = join("",<OLDJSON>);
			close(OLDJSON);

			eval {
				$oldcsafmap = decode_json($json);
				$lastmodified{$fn} = $oldcsafmap->{'document'}->{'tracking'}->{'current_release_date'};
			} or do {
				warn "json invalid: $json\n";
				unlink($fn);
			};
		}

		# in fast mode, skip all but the current year.
		if ($mode ne "all") {
			next unless ($notice =~ /SU-$year/);
		}

		my $dt = DateTime->from_epoch (epoch  => $UpdateInfoReader::patchissued{$patch});

		my %csafrev1 = (
			"date"			=>	$dt->iso8601()."Z",
			"number"		=>	"1",
			"summary"		=>	"Current version",
		);

		my @csafrevisionhistory = (
			\%csafrev1
		);


		# translate suse notice id to weblink
		# SUSE-SU-2017:0367-1 to https://www.suse.com/support/update/announcement/2017/suse-su-20170367-1/
		my $urlnotice = "\L$notice";
		$urlnotice =~ /su-(\d*):/;
		my $noticeyear = $1;
		$urlnotice =~ s/://;
		$urlnotice = "https://www.suse.com/support/update/announcement/$noticeyear/$urlnotice/";

		if ($notice !~ /^SUSE/) {	# only SUSE notices are uploaded to suse.com/support/update, otherwise use imported one
			$urlnotice = $CanDBReader::advisoryid2url{$notice};
		}

		my @csafreferences = (
			{	 "category"	=> "external",
				"summary"	=> "SUSE ratings",
				"url"		=> "https://www.suse.com/support/security/rating/",
			},
			{	"category"	=> "self",
				"summary"	=> "URL of this CSAF notice",
				"url"		=> "https://ftp.suse.com/pub/projects/security/csaf/$fn",
			},
		);
		if (defined($urlnotice)) {
			my %csafref = (
				"category"	=> "self",
				"summary"	=> "URL for $notice",
				"url"		=> $urlnotice,
			);
			push @csafreferences, \%csafref;
		}
		if (defined($CanDBReader::advisoryid2url{$notice})) {
			my %csafref = (
				"category"	=> "self",
				"summary"	=> "E-Mail link for $notice",
				"url"		=> $CanDBReader::advisoryid2url{$notice},
			);
			push @csafreferences, \%csafref;
		}

		my %references = ();
		if (defined($UpdateInfoReader::patchreferences{$patch})) {
			%references = %{$UpdateInfoReader::patchreferences{$patch}};
		}
		foreach my $reference (sort keys %references) {
			my %csafref = (
				"category" => "self"
			);
			if ($reference =~ /CVE/) {
				&SMASHData::read_smash_issue($reference,0);
				$csafref{"url"} = "https://www.suse.com/security/cve/$reference/";
				$csafref{"summary"} = "SUSE CVE $reference page";
			} else {	#assume bug
				$csafref{"url"} = "https://bugzilla.suse.com/$reference";
				$csafref{"summary"} = "SUSE Bug $reference";
			}
			push @csafreferences, \%csafref;
		}

		my @csafproductversions = ();
		my @csafproductfamilies = ();

		my @csafproductsfixed = ();

		# for which products do we have this patch
		foreach my $xpatch (@patches) {
			foreach my $product (sort @products) {
				my $patches = $UpdateInfoReader::patches{$product};
				if (defined($patches->{$xpatch})) {
					push @affectedproducts,$product;
				}
			}
		}

		# list of products fixed by the patches of this notice.

		foreach my $product (@affectedproducts) {
			my %csafproduct = (
				"name"		=> $product,
				"product_id"	=> $product,
			);

			if (defined(&SMASHData::get_cpe($product))) {
				$csafproduct{"product_identification_helper"} = {
					"cpe"	=> &SMASHData::get_cpe($product)
				};
			}
			my %csafproductfamily = (
				"category"	=> "product_name",
				"name"		=> $product,
				"product"	=> \%csafproduct,
			);
			push @csafproductfamilies, \%csafproductfamily;
		}

		my %havepackagever = ();

		my %tmpproducts = ();	# "arch" -> (producentries)
		foreach my $xpatch (@patches) {
			if (!defined($UpdateInfoReader::patchpackages{$xpatch})) {
				print STDERR "no patch packages for $xpatch?\n";
				next;
			}
			my %packages = %{$UpdateInfoReader::patchpackages{$xpatch}};
			my %packagearchs = ();
			if (defined($UpdateInfoReader::patchpackagearchs{$xpatch})) {
				%packagearchs = %{$UpdateInfoReader::patchpackagearchs{$xpatch}};
			}

			foreach my $package (sort keys %packages) {
				foreach my $arch (sort keys %{$packagearchs{$package}}) {
					my $pkgver = "$package-$packages{$package}.$arch";

					next if ($package =~ /-debuginfo/);
					next if ($package =~ /-debugsource/);

					if (!defined($havepackagever{$pkgver})) {
						$havepackagever{$pkgver} = 1;

						my %csafproductversionentry = (
							"category"	=> "product_version",
							"name"		=> $pkgver,
							"product"	=> {
								"name"		=> $pkgver,
								"product_id"	=> $pkgver,
							},
						);
						push @{$tmpproducts{$arch}}, \%csafproductversionentry;
					}
				}
			}

		}

		foreach my $arch (sort keys %tmpproducts) {
			my %csafsubarch = (
				"category"	=> "architecture",
				"name"		=> $arch,
				"branches"	=> $tmpproducts{$arch},
			);
			push @csafproductversions, \%csafsubarch;
		}
		my %csaffamilybranch = (
			"category"	=> "product_family",
			"name"		=> "SUSE Linux Enterprise",
			"branches" 	=> \@csafproductfamilies,
		);

		push @csafproductversions, \%csaffamilybranch;

		my @csafproductrelationships = ();

		# Foreach patch in the notice, dump the packages and the product:packages relation
		foreach my $product (@affectedproducts) {
			my $patches = $UpdateInfoReader::patches{$product};

			foreach my $xpatch (@patches) {
				next unless (defined($patches->{$xpatch}));
				next unless (defined($UpdateInfoReader::patchpackages{$xpatch}));

				# only print the ones for this product.
				my %packages = %{$UpdateInfoReader::patchpackages{$xpatch}};

				my %packagearchs = ();
				if (defined($UpdateInfoReader::patchpackagearchs{$xpatch})) {
					%packagearchs = %{$UpdateInfoReader::patchpackagearchs{$xpatch}};
				}
				foreach my $package (sort keys %packages) {
					foreach my $arch (sort keys %{$packagearchs{$package}}) {
						my $pkgver = "$package-$packages{$package}.$arch";

						next if ($package =~ /-debuginfo/);
						next if ($package =~ /-debugsource/);

						my $prodpkgid = "$product:$pkgver";
						push @csafproductsfixed,$prodpkgid;
						my %csafrelationship = (
							"category"		=> "default_component_of",
							"product_reference"	=> $pkgver,
							"relates_to_product_reference" => $product,
							"full_product_name"	=> {
								"name"			=> "$pkgver as component of $product",
								"product_id"		=> $prodpkgid,
							},
						);
						push @csafproductrelationships,\%csafrelationship;
					}
				}
			}
		}
		if ($#csafproductrelationships == -1) {
			print STDERR "relationships in $notice are empty? Skipping.\n";
			next;
		}

		# in some cases not unique
		my %tempproducts = map { $_ => 1 } @csafproductsfixed;
		@csafproductsfixed = sort keys %tempproducts;

		my %csafproductree = (
			"branches"	=> [		# toplevel is just just the 1 vendor entry, rest is generated above
				{
					"branches"	=> \@csafproductversions,
					"category"	=> "vendor",
					"name"		=> "SUSE",
				},
			],
			"relationships"	=> \@csafproductrelationships,
		);

		my @csafvulnerabilities = ();

# assumed to be the same for all patches of the same notice
		%references = %{$UpdateInfoReader::patchreferences{$patch}};
		foreach my $reference (sort keys %references) {
			next unless ($reference =~ /^CVE-/);

			my @csafvulreferences = (
				{
					"category"	=> "external",
					"summary"	=> $reference,
					"url"		=> "https://www.suse.com/security/cve/$reference",
				},
			);

			if (defined($CanDBReader::bugzillas{$reference})) {
				my %bugs = map { $_ => 1 } split (/,/,$CanDBReader::bugzillas{$reference});
				foreach my $bug (sort keys %bugs) {
					my %csafbugreference = (
							"category"	=> "external",
							"summary"	=> "SUSE Bug $bug for $reference",
							"url"		=> "https://bugzilla.suse.com/$bug",
							);
					push @csafvulreferences,\%csafbugreference;
				}
			}

			my $severity = $SMASHData::severity{$reference};
			if (!defined($severity)) {
				$severity = $UpdateInfoReader::patchseverity{$patch};
			}
			if (!defined($severity)) {
				print STDERR "$notice / $patch - no severity?\n" if (-t STDERR);
				$severity = "moderate";
			}

			my @csafvulscores = ();

			my $basescore;
			my $basevector;

			my $cvss3 = &SMASHData::get_cvssv3_issue($reference);
			if (defined($cvss3)) {
				if (defined($cvss3->{'SUSE'})) {
					$basescore = filter_score($cvss3->{'SUSE'}->{base_score});
					$basevector = $cvss3->{'SUSE'}->{base_vector};
				} else {
					if (defined($cvss3->{'National Vulnerability Database'})) {
						$basescore = filter_score($cvss3->{'National Vulnerability Database'}->{base_score});
						$basevector = $cvss3->{'National Vulnerability Database'}->{base_vector};
					}
				}
				if (defined($basescore)) {
					$basevector =~ /^CVSS:(\d.\d)\//;
					my $basever = $1;

					my %csafvulscore = (
						"products"	=> \@csafproductsfixed,		# FIXME check if we really need it here
						"cvss_v3"	=> {
							"version"	=> "$basever",
							"vectorString"	=> $basevector,
							"baseScore"	=> $basescore,
							"baseSeverity"	=> map_cvss_severity($basescore),
						},
					);
					push @csafvulscores,\%csafvulscore;
				}
			}

			my %csafvulnerability = (
				"cve"	=> $reference,
				# FIXME "cwe" ... not yet handled by SUSE

				"ids"		=> [
					{
						"system_name" => "SUSE CVE Page",
						"text" => "https://www.suse.com/security/cve/$reference",
					}
				],
				"notes"		=> [
					{
						"category"	=> "general",
						"title"		=> "CVE description",
						"text"		=> get_description($reference) || "unknown",
					}
				],
				"references"	=> \@csafvulreferences,
				"threats"	=> [
					{
						"category"	=> "impact",
						"date"		=> $dt->iso8601()."Z",
						"details"	=> $severity
					}
				],
				"remediations"	=> [
					{
						"category"	=> "vendor_fix",
						"details"	=> "To install this SUSE Security Update use the SUSE recommended installation methods like YaST online_update or \"zypper patch\".\n",
						"product_ids"	=> \@csafproductsfixed
					},
				],
				"product_status"=> {
					"recommended" => \@csafproductsfixed,
				},

				# discovery_date ... check?
				# release_date		TODO

				"title"		=> "$reference",	#FIXME better?
			);
			if (@csafvulscores) {	# empty score array is not allowed.
				$csafvulnerability{"scores"} = \@csafvulscores;
			}
			push @csafvulnerabilities, \%csafvulnerability;
		}

		#
		# toplevel json ... collect all the lower levels
		#

		my $severity = "moderate";
		if (defined( $UpdateInfoReader::patchseverity{$patch})) {
			$severity = $UpdateInfoReader::patchseverity{$patch};
		} else {
			print STDERR "no severity found for $patch\n";
		}

		my %csafdocument = (
			"category" 		=> "csaf_security_advisory",
			"csaf_version"		=> "2.0",
			"title"			=> $UpdateInfoReader::patchtitle{$patch},	# FIXME: CHECK
			"lang"			=> "en",
			"aggregate_severity"	=> {
				"namespace"	=> "https://www.suse.com/support/security/rating/",
				"text"		=> $severity,
			},
			"distribution"		=> {
				"text"	=> "Copyright $year SUSE LLC. All rights reserved.",
				"tlp"	=> {
					"label" 	=> "WHITE",
					"url"		=> "https://www.first.org/tlp/"
				},
			},
			"notes"			=> [
				{	"category"	=> "summary",
					"title"		=> "Title of the patch",
					"text"		=> $UpdateInfoReader::patchtitle{$patch},
				},
				{	"category"	=> "description",
					"title"		=> "Description of the patch",
					"text"		=> $UpdateInfoReader::patchdescription{$patch},
				},
				{	"category"	=> "details",
					"title"		=> "Patchnames",
					"text"		=> join(",",@patches),
				},
				{	"category"	=> "legal_disclaimer",
					"title"		=> "Terms of use",
					"text"		=> "CSAF 2.0 data is provided by SUSE under the Creative Commons License 4.0 with Attribution (CC-BY-4.0)."
				}
			],
			"references"		=> \@csafreferences,
			"publisher"		=> {
				"category"		=> "vendor",
				"contact_details"	=> "https://www.suse.com/support/security/contact/",
				"name"			=> "SUSE Product Security Team",
				"name"			=> "SUSE Product Security Team",
				"namespace"		=> "https://www.suse.com/",
			},
			"tracking"		=> {
				"current_release_date"	=> $dt->iso8601()."Z",
				"id" 			=> $notice,
				"initial_release_date"	=> $dt->iso8601()."Z",
				"generator"		=> {
					"date" => $dt->iso8601()."Z",	# FIXME check what it means
					"engine" => {
						"name"			=> "cve-database.git:bin/generate-csaf.pl",
						"version"		=> "1",
					}
				},
				"revision_history"	=> \@csafrevisionhistory,
				"status"		=> "final",
				"version"		=> "1",
			},
		);

		# This is really the toplevel CSAF
		my %csaf = (
			"document"		=> \%csafdocument,
			"product_tree"		=> \%csafproductree,
		);
		if (@csafvulnerabilities) {
			$csaf{"vulnerabilities"} = \@csafvulnerabilities;
		}

	        $lastmodified{$fn} = $csafdocument{'tracking'}->{'current_release_date'};

		open(CSAF,">$fn.new");
		print CSAF $jsoncoder->encode(\%csaf);
		close(CSAF);

		my $newcsaf;
		my $oldcsaf = "<empty>";
		if (open(CSAF,"<$fn.new")) {
			$newcsaf = join("",<CSAF>);
			close(CSAF);
		}
		if (open(CSAF,"<$fn")) {
			$oldcsaf = join("",<CSAF>);
			close(CSAF);
		}
		if ($oldcsaf ne $newcsaf) {
			if (system("jq . <$fn >$fn.formatted ; jq . <$fn.new >$fn.new.formatted ; diff -uN $fn.formatted $fn.new.formatted")) {
				rename("$fn.new",$fn);

				hash($fn);
				sign($fn);

				unlink("$fn.new.formatted");
				unlink("$fn.formatted");
			} else {
				print STDERR "diff for $fn not detected by reader?\n";
				unlink("$fn.new");
			}
		} else {
			unlink("$fn.new");
		}
	}
}

umask 022;

generate_csaf();
chdir("/mounts/mirror/SuSE/ftp.suse.com/pub/projects/security/");
system("tar cjf csaf.tar.bz2 csaf/");
#sign("csaf.tar.bz2");

open(INDEXTXT,">csaf/index.txt")||die "csaf/index.txt: $!";
print INDEXTXT join("\n",sort keys %lastmodified) . "\n";
close(INDEXTXT);
open(CHANGESCSV,">csaf/changes.csv")||die "csaf/changes.csv: $!";
foreach my $entry (sort keys %lastmodified) {
	print CHANGESCSV "\"$entry\",\"$lastmodified{$entry}\"\n";
}
close(CHANGESCSV);

print "SUCCESS\n";
