11import argparse
22import base64
3+ import datetime
34import json
45import logging
6+ import os
7+ import platform
8+ import tempfile
59from json import JSONDecodeError
610from typing import Dict , List , Optional , Any
711
@@ -30,6 +34,11 @@ def register_command_info(aliases, command_info):
3034
3135report_parser = argparse .ArgumentParser (prog = 'security-audit-report' , description = 'Run a security audit report.' ,
3236 parents = [report_output_parser ])
37+ # Override format choices to include 'siem' for SIEM-ready JSON output
38+ for action in report_parser ._actions :
39+ if hasattr (action , 'dest' ) and action .dest == 'format' :
40+ action .choices = ['table' , 'csv' , 'json' , 'pdf' , 'siem' ]
41+ break
3342report_parser .add_argument ('--syntax-help' , dest = 'syntax_help' , action = 'store_true' , help = 'display help' )
3443node_filter_help = 'name(s) or UID(s) of node(s) to filter results of the report by'
3544report_parser .add_argument ('-n' , '--node' , action = 'append' , help = node_filter_help )
@@ -492,12 +501,17 @@ def decrypt_security_data(sec_data, key_type):
492501 fields = ('email' , 'name' , 'sync_pending' , 'at_risk' , 'passed' , 'ignored' ) if show_breachwatch else \
493502 ('email' , 'name' , 'sync_pending' , 'weak' , 'fair' , 'medium' , 'strong' , 'reused' , 'unique' , 'securityScore' ,
494503 'twoFactorChannel' , 'node' )
495- field_descriptions = fields
496504
505+ report_title = f'Security Audit Report{ " (BreachWatch)" if show_breachwatch else "" } '
506+
507+ # SIEM-ready JSON output
508+ if fmt == 'siem' :
509+ return self ._export_siem (params , rows , fields , show_breachwatch , out , report_title )
510+
511+ field_descriptions = fields
497512 if fmt == 'table' :
498513 field_descriptions = (field_to_title (x ) for x in fields )
499514
500- report_title = f'Security Audit Report{ " (BreachWatch)" if show_breachwatch else "" } '
501515 table = []
502516 for raw in rows :
503517 row = []
@@ -506,6 +520,106 @@ def decrypt_security_data(sec_data, key_type):
506520 table .append (row )
507521 return dump_report_data (table , field_descriptions , fmt = fmt , filename = out , title = report_title )
508522
523+ @staticmethod
524+ def _export_siem (params , rows , fields , show_breachwatch , filename , title ):
525+ """Export security audit report in SIEM-ready JSON format.
526+
527+ Each user becomes an event with metadata for ingestion by
528+ Splunk, Elastic, Datadog, or any JSON-based SIEM.
529+ """
530+ now = datetime .datetime .now (tz = datetime .timezone .utc )
531+ events = []
532+
533+ for raw in rows :
534+ user_data = {f : raw .get (f ) for f in fields }
535+
536+ # Compute severity based on security score and risk factors
537+ severity = 'info'
538+ risk_factors = []
539+ if not show_breachwatch :
540+ score = raw .get ('securityScore' , 0 )
541+ weak = raw .get ('weak' , 0 )
542+ reused = raw .get ('reused' , 0 )
543+ two_fa = raw .get ('twoFactorChannel' , '' )
544+
545+ if weak > 0 :
546+ risk_factors .append ('weak_passwords' )
547+ if reused > 0 :
548+ risk_factors .append ('reused_passwords' )
549+ if not two_fa or two_fa == 'none' :
550+ risk_factors .append ('no_2fa' )
551+
552+ if score < 40 or weak > 5 :
553+ severity = 'critical'
554+ elif score < 60 or weak > 2 :
555+ severity = 'high'
556+ elif score < 80 :
557+ severity = 'medium'
558+ elif risk_factors :
559+ severity = 'low'
560+ else :
561+ at_risk = raw .get ('at_risk' , 0 )
562+ if at_risk > 5 :
563+ severity = 'critical'
564+ risk_factors .append ('high_breach_exposure' )
565+ elif at_risk > 0 :
566+ severity = 'high'
567+ risk_factors .append ('breach_exposure' )
568+
569+ event = {
570+ 'event_type' : 'keeper.security_audit' ,
571+ 'timestamp' : now .isoformat (timespec = 'seconds' ),
572+ 'severity' : severity ,
573+ 'source' : {
574+ 'host' : platform .node (),
575+ 'enterprise' : params .server if hasattr (params , 'server' ) else '' ,
576+ 'commander_version' : 'Commander' ,
577+ },
578+ 'user' : user_data ,
579+ 'risk_factors' : risk_factors ,
580+ }
581+ events .append (event )
582+
583+ # Compute summary
584+ total = len (events )
585+ scores = [r .get ('securityScore' , 0 ) for r in rows if 'securityScore' in r ]
586+ summary = {
587+ 'total_users' : total ,
588+ 'avg_security_score' : round (sum (scores ) / max (len (scores ), 1 ), 1 ),
589+ 'users_critical' : sum (1 for e in events if e ['severity' ] == 'critical' ),
590+ 'users_high' : sum (1 for e in events if e ['severity' ] == 'high' ),
591+ 'users_without_2fa' : sum (1 for e in events if 'no_2fa' in e ['risk_factors' ]),
592+ }
593+
594+ output = {
595+ 'schema_version' : '1.0' ,
596+ 'event_type' : 'keeper.security_audit_report' ,
597+ 'generated' : now .isoformat (timespec = 'seconds' ),
598+ 'title' : title ,
599+ 'summary' : summary ,
600+ 'events' : events ,
601+ }
602+
603+ report = json .dumps (output , indent = 2 , default = str )
604+
605+ if filename :
606+ if not os .path .splitext (filename )[1 ]:
607+ filename += '.json'
608+ logging .info ('SIEM report path: %s' , os .path .abspath (filename ))
609+ fd , tmp_path = tempfile .mkstemp (dir = os .path .dirname (os .path .abspath (filename )), suffix = '.tmp' )
610+ try :
611+ os .write (fd , report .encode ())
612+ os .close (fd )
613+ os .replace (tmp_path , filename )
614+ os .chmod (filename , 0o600 )
615+ except Exception :
616+ os .close (fd )
617+ if os .path .exists (tmp_path ):
618+ os .unlink (tmp_path )
619+ raise
620+ else :
621+ return report
622+
509623 def get_updated_security_report_row (self , sr , rsa_key , ec_key , last_saved_data ):
510624 # type: (APIRequest_pb2.SecurityReport, rsa.RSAPrivateKey, ec.EllipticCurvePrivateKey, Dict[str, int]) -> Dict[str, int]
511625
0 commit comments