4
0

check_ssl_validity.pl 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. #! /usr/bin/perl
  2. # nagios: -epn
  3. # Complete (?) check for valid SSL certificate
  4. # Originally by Anders Nordby (anders@fupp.net), 2015-02-16
  5. # Copied with permission on 2019-09-26 (https://github.com/nagios-plugins/nagios-plugins/issues/72)
  6. # and modified to fit the needs of the nagios-plugins project.
  7. # Copyright: GPLv2
  8. # Checks all of the following:
  9. # Fetch SSL certificate from URL (on optional given host)
  10. # Does the certificate contain our hostname?
  11. # Has the certificate expired?
  12. # Download (and cache) CRL
  13. # Has the certificate been revoked?
  14. use Getopt::Std;
  15. use File::Temp qw(tempfile);
  16. use File::Basename;
  17. use Crypt::X509;
  18. use Date::Parse;
  19. use Date::Format qw(ctime);
  20. use POSIX qw(strftime);
  21. use Digest::MD5 qw(md5_hex);
  22. use LWP::Simple;
  23. use Text::Glob qw(match_glob);
  24. use Getopt::Long;
  25. Getopt::Long::Configure('bundling');
  26. GetOptions(
  27. "h" => \$opt_h, "help" => \$opt_h,
  28. "d" => \$opt_d, "debug" => \$opt_d,
  29. "o" => \$opt_o, "ocsp" => \$opt_o,
  30. "ocsp-host=s" => \$opt_ocsp_host,
  31. "C=s" => \$opt_C, "crl-cache-frequency=s" => \$opt_C,
  32. "I=s" => \$opt_I, "ip=s" => \$opt_I,
  33. "p=i" => \$opt_p, "port=i" => \$opt_p,
  34. "H=s" => \$opt_H, "cert-hostname=s" => \$opt_H,
  35. "w=i" => \$opt_w, "warning=i" => \$opt_w,
  36. "c=i" => \$opt_c, "critical=i" => \$opt_c,
  37. "t" => \$opt_t, "timeout" => \$opt_t
  38. );
  39. my $chainfh, $chainfile, $escaped_tempfile, $ocsp_status;
  40. sub usage {
  41. print "check_ssl_validity -H <cert hostname> [-I <IP/host>] [-p <port>]\n[-t <timeout>] [-w <expire warning (days)>] [-c <expire critical (dats)>]\n[-C (CRL update frequency in seconds)] [-d (debug)] [--ocsp] [--ocsp-host]\n";
  42. print "\nWill look for hostname provided with -H in the certificate, but will contact\n";
  43. print "server with host/IP provided by -I (optional)\n";
  44. exit(1);
  45. }
  46. sub updatecrl {
  47. my $url = shift;
  48. my $fn = shift;
  49. my $content = get($url);
  50. if (defined($content)) {
  51. if (open(CACHE, ">$cachefile")) {
  52. print CACHE $content;
  53. } else {
  54. doexit(2, "Could not open file $fn for writing CRL temp file for cert on $host:$port.");
  55. }
  56. close(CACHE);
  57. } else {
  58. doexit(2, "Could not download CRL Distribution Point URL $url for cert on $hosttxt.");
  59. }
  60. }
  61. sub ckserial {
  62. return if ($crserial eq "");
  63. if ($serial eq $crserial) {
  64. if ($crrev ne "") {
  65. $crrevtime = str2time($crrev);
  66. $revtime = $crrevtime-$uxtime;
  67. if ($revtime < 0) {
  68. doexit(2, "Found certificate for $vhost on CRL $crldp revoked already at date $crrev");
  69. } elsif (($revtime/86400) < $crit) {
  70. doexit(2, "Found certificate for $vhost on CRL $crldp revoked at date $crrev, within critical time frame $crit");
  71. } elsif (($revtime/86400) < $warn) {
  72. doexit(1, "Found certificate for $vhost on CRL $crldp revoked at date $crrev, within warning time frame $warn");
  73. }
  74. }
  75. doexit(1, "Found certificate for $vhost on CRL $crldp revoked $crrev. Time to check the revokation date");
  76. }
  77. }
  78. usage unless ($opt_H);
  79. # Defaults
  80. if ($opt_p) {
  81. $port = $opt_p;
  82. } else {
  83. $port = 443;
  84. }
  85. if ($opt_t) {
  86. $tmout = $opt_t;
  87. } else {
  88. $tmout = 10;
  89. }
  90. if ($opt_C) {
  91. $crlupdatefreq = $opt_C;
  92. } else {
  93. $crlupdatefreq = 86400;
  94. }
  95. $vhost = $opt_H;
  96. if ($opt_I) {
  97. $host = $opt_I;
  98. } else {
  99. $host = $vhost;
  100. }
  101. $hosttxt = "$host:$port";
  102. if ($opt_w && $opt_w =~ /^\d+$/) {
  103. $warn = $opt_w;
  104. } else {
  105. $warn = 30;
  106. }
  107. if ($opt_c && $opt_c =~ /^\d+$/) {
  108. $crit = $opt_c;
  109. } else {
  110. $crit = 30;
  111. }
  112. sub doexit {
  113. my $ret = shift;
  114. my $txt = shift;
  115. if ($ret == 0) {
  116. print "OK: ";
  117. }
  118. elsif ($ret == 1) {
  119. print "WARNING: ";
  120. }
  121. elsif ($ret == 2) {
  122. print "CRITICAL: ";
  123. }
  124. else {
  125. print "UNKNOWN: ";
  126. }
  127. print "$txt\n";
  128. exit($ret);
  129. }
  130. $alldata = "";
  131. $cert = "";
  132. $mode = 0;
  133. open(CMD, "echo | openssl s_client -servername $vhost -connect $host:$port 2>&1 |");
  134. while (<CMD>) {
  135. $alldata .= $_;
  136. if ($mode == 0) {
  137. if (/-----BEGIN CERTIFICATE-----/) {
  138. $cert .= $_;
  139. $mode = 1;
  140. }
  141. } elsif ($mode == 1) {
  142. $cert .= $_;
  143. if (/-----END CERTIFICATE-----/) {
  144. $mode = 2;
  145. }
  146. }
  147. }
  148. close(CMD);
  149. $ret = $?;
  150. if ($ret != 0) {
  151. $alldata =~ s@\n@ @g;
  152. $alldata =~ s@\s+$@@;
  153. doexit(2, "Error connecting to $hosttxt: $alldata");
  154. } elsif ($cert eq "") {
  155. doexit(2, "No certificate found on $hosttxt");
  156. } else {
  157. ($tmpfh,$tempfile) = tempfile(DIR=>'/tmp',UNLINK=>0);
  158. doexit(2, "Failed to open temp file: $!") unless (defined($tmpfh));
  159. $tmpfh->print($cert);
  160. $tmpfh->close;
  161. }
  162. $dercert = `openssl x509 -in $tempfile -outform DER 2>&1`;
  163. $ret = $?;
  164. if ($ret != 0) {
  165. $dercert =~ s@\n@ @g;
  166. $dercert =~ s@\s+$@@;
  167. doexit(2, "Could not convert certificate from PEM to DER format: $dercert");
  168. }
  169. $decoded = Crypt::X509->new( cert => $dercert );
  170. if ($decoded->error) {
  171. doexit(2, "Could not parse X509 certificate on $hosttxt: " . $decoded->error);
  172. }
  173. $oktxt = "";
  174. $cn = $decoded->subject_cn;
  175. if ($opt_d) { print "Found CN: $cn\n"; }
  176. if ($vhost eq $decoded->subject_cn) {
  177. $oktxt .= "Host $vhost matches CN $vhost on $hosttxt ";
  178. } elsif ($decoded->subject_cn =~ /^.*\.(.*)$/) {
  179. $wcdomain = $1;
  180. $domain = $vhost;
  181. $domain =~ s@^[\w\-]+\.@@;
  182. if ($domain eq $wcdomain) {
  183. $oktxt .= "Host $vhost matches wildcard CN " . $decoded->subject_cn . " on $hosttxt ";
  184. }
  185. }
  186. if ($oktxt eq "") {
  187. # Cert not yet found
  188. if (defined($decoded->SubjectAltName)) {
  189. # Check altnames
  190. $altfound = 0;
  191. foreach $altnametxt (@{$decoded->SubjectAltName}) {
  192. if ($altnametxt =~ /^dNSName=(.*)/) {
  193. $altname = $1;
  194. if ($opt_d) { print "Found SAN: $altname\n"; }
  195. if (match_glob($altname, $vhost)) {
  196. $altfound = 1;
  197. $oktxt .= "Host $vhost found in SAN on $hosttxt ";
  198. last;
  199. }
  200. }
  201. }
  202. if ($altfound == 0) {
  203. doexit(2, "Host $vhost not found in certificate on $hosttxt, not in CN or in alternative names");
  204. }
  205. } else {
  206. doexit(2, "Host $vhost not found in certificate on $hosttxt, not in CN and no alternative names found");
  207. }
  208. }
  209. # Check expire time
  210. $uxtimegmt = strftime "%s", gmtime;
  211. $uxtime = strftime "%s", localtime;
  212. $certtime = $decoded->not_after;
  213. $certdays = ($certtime-$uxtimegmt)/86400;
  214. $certdaysfmt = sprintf("%.1f", $certdays);
  215. if ($certdays < 0) {
  216. doexit(2, "${oktxt}but it is expired ($certdaysfmt days)");
  217. } elsif ($certdays < $crit) {
  218. doexit(2, "${oktxt}but it is expiring in only $certdaysfmt days, critical limit is $crit.");
  219. } elsif ($certdays < $warn) {
  220. doexit(1, "${oktxt}but it is expiring in only $certdaysfmt days, warning limit is $warn.");
  221. }
  222. $serial = $decoded->serial;
  223. $serial = lc(sprintf("%x", $serial));
  224. if ($opt_d) {
  225. print "Certificate serial: $serial\n";
  226. }
  227. if ($opt_o) {
  228. # Do OCSP instead of CRL checking
  229. $ocsp_uri = `openssl x509 -noout -ocsp_uri -in $tempfile`;
  230. $ocsp_uri =~ s/\s+$//;
  231. ($chainfh,$chainfile) = tempfile(DIR=>'/tmp',UNLINK=>0);
  232. # Get the certificate chain
  233. $chain_raw = `echo "Q" | openssl s_client -servername $vhost -connect $host:$port -showcerts 2>/dev/null`;
  234. $mode = 0;
  235. for(split /^/, $chain_raw) {
  236. if (/-----BEGIN CERTIFICATE-----/) {
  237. $mode += 1;
  238. }
  239. # Skip the first certificate returned
  240. if ($mode > 1) {
  241. $chain_processed .= $_;
  242. }
  243. if (/-----END CERTIFICATE-----/) {
  244. if ($mode > 1) {
  245. $mode -= 1;
  246. }
  247. }
  248. }
  249. $chainfh->print($chain_processed);
  250. $chainfh->close;
  251. $ocsp_cache = md5_hex($chain_processed);
  252. $ocsp_cache_file = dirname(__FILE__) . "/ssl_validity_data_" . $ocsp_cache;
  253. open(OCSP_CACHE, $ocsp_cache_file);
  254. while (my $line = <OCSP_CACHE>) {
  255. chomp $line;
  256. if ($line =~ /[0-9]+/) {
  257. $next_update_time = $line;
  258. }
  259. }
  260. close(OCSP_CACHE);
  261. $current_time = time();
  262. if ($current_time < $next_update_time) {
  263. # Use cached result
  264. $next_update_time_str = ctime($next_update_time);
  265. chomp $next_update_time_str;
  266. $ocsp_status = "good (cached until $next_update_time_str)";
  267. }
  268. else {
  269. # Time to update
  270. $cmd = "openssl ocsp -issuer $chainfile -verify_other $chainfile -cert $tempfile -url $ocsp_uri -text";
  271. if ($opt_ocsp_host) {
  272. $cmd .= " -header \"Host\" \"$opt_ocsp_host\"";
  273. }
  274. open(CMD, $cmd . " 2>/dev/null |");
  275. $escaped_tempfile = $tempfile;
  276. $escaped_tempfile =~ s/([\\\|\(\)\[\]\{\}\^\$\*\+\?\.])/\1/g;
  277. $ocsp_status = "unknown";
  278. while (<CMD>) {
  279. chomp;
  280. if ($_ =~ s/Next Update: (.*)/$1/) {
  281. $next_update_time = str2time($_);
  282. }
  283. if ($_ =~ s/$escaped_tempfile: (.*)/$1/) {
  284. $ocsp_status = $_;
  285. }
  286. }
  287. if ($ocsp_status =~ /good/) {
  288. open(OCSP_CACHE, ">", $ocsp_cache_file);
  289. print OCSP_CACHE $next_update_time;
  290. close(OCSP_CACHE);
  291. }
  292. }
  293. my $exit_code = 2;
  294. if ($ocsp_status =~ /good/) {
  295. $exit_code = 0;
  296. }
  297. doexit($exit_code, "$oktxt; OCSP responder ($ocsp_uri) says certificate is $ocsp_status");
  298. }
  299. else {
  300. # Do CRL-based checking
  301. @crldps = @{$decoded->CRLDistributionPoints};
  302. $crlskip = 0;
  303. foreach $crldp (@crldps) {
  304. if ($opt_d) {
  305. print "Checking CRL DP $crldp.\n";
  306. }
  307. $cachefile = "/tmp/" . md5_hex($crldp) . "_crl.tmp";
  308. if (-f $cachefile) {
  309. $cacheage = $uxtime-(stat($cachefile))[9];
  310. if ($cacheage > $crlupdatefreq) {
  311. if ($opt_d) { print "Download update, more than a day old.\n"; }
  312. updatecrl($crldp, $cachefile);
  313. } else {
  314. if ($opt_d) { print "Reusing cached copy.\n"; }
  315. }
  316. } else {
  317. if ($opt_d) { print "Download initial copy.\n"; }
  318. updatecrl($crldp, $cachefile);
  319. }
  320. $crl = "";
  321. my $format;
  322. open(my $cachefile_io, '<', $cachefile);
  323. $format = <$cachefile_io> =~ /-----BEGIN X509 CRL-----/ ? 'PEM' : 'DER';
  324. close $cachefile_io;
  325. open(CMD, "openssl crl -inform $format -text -in $cachefile -noout 2>&1 |");
  326. while (<CMD>) {
  327. $crl .= $_;
  328. }
  329. close(CMD);
  330. $ret = $?;
  331. if ($ret != 0) {
  332. $crl =~ s@\n@ @g;
  333. $crl =~ s@\s+$@@;
  334. doexit(2, "Could not parse $format from URL $crldp while checking $hosttxt: $crl");
  335. }
  336. # Crude CRL parsing goes here
  337. $mode = 0;
  338. foreach $cline (split(/\n/, $crl)) {
  339. if ($cline =~ /.*Next Update: (.+)/) {
  340. $nextup = $1;
  341. $nextuptime = str2time($nextup);
  342. $crlvalid = $nextuptime-$uxtime;
  343. if ($opt_d) { print "Next CRL update: $nextup\n"; }
  344. if ($crlvalid < 0) {
  345. doexit(2, "Could not use CRL from $crldp, it expired past next update on $nextup");
  346. }
  347. } elsif ($cline =~ /.*Last Update: (.+)/) {
  348. $lastup = $1;
  349. if ($opt_d) { print "Last CRL update: $lastup\n"; }
  350. } elsif ($mode == 0) {
  351. if ($cline =~ /.*Serial Number: (\S+)/i) {
  352. ckserial;
  353. $crserial = lc($1);
  354. $crrev = "";
  355. } elsif ($cline =~ /.*Revocation Date: (.+)/i) {
  356. $crrev = $1;
  357. }
  358. } elsif ($cline =~ /Signature Algorithm/) {
  359. last;
  360. }
  361. }
  362. ckserial;
  363. }
  364. }
  365. if (-f $tempfile) {
  366. unlink ($tempfile);
  367. }
  368. $oktxt =~ s@\s+$@@;
  369. print "$oktxt, still valid for $certdaysfmt days. ";
  370. if ($crlskip == 0) {
  371. print "Serial $serial not found on any Certificate Revokation Lists.\n";
  372. } else {
  373. print "CRL checks skipped, next check in " . ($crlupdatefreq - $cacheage) . " seconds.\n";
  374. }
  375. exit 0;