Skip to content

Commit 0460eea

Browse files
committed
feat: add --format siem option to security-audit report
Adds SIEM-ready JSON output format for security audit posture reports. Designed for ingestion by Splunk, Elastic, Datadog, and other SIEMs. Output includes: - schema_version for parser stability across upgrades - Per-user severity classification (critical/high/medium/low/info) based on security score, weak passwords, reused passwords, 2FA status - Risk factors array per user for correlation rules - Summary with aggregate stats (avg score, users at risk, 2FA gaps) - ISO timestamp with timezone for accurate event ordering - Source metadata (host, enterprise, commander version) - BreachWatch mode support (at_risk/passed/ignored fields) Usage: security-audit report --format siem --output audit.json security-audit report --format siem --breachwatch --output bw.json Existing formats (table, csv, json, pdf) are unchanged. Atomic file writes with 0o600 permissions on output.
1 parent c73360a commit 0460eea

1 file changed

Lines changed: 116 additions & 2 deletions

File tree

keepercommander/commands/security_audit.py

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import argparse
22
import base64
3+
import datetime
34
import json
45
import logging
6+
import os
7+
import platform
8+
import tempfile
59
from json import JSONDecodeError
610
from typing import Dict, List, Optional, Any
711

@@ -30,6 +34,11 @@ def register_command_info(aliases, command_info):
3034

3135
report_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
3342
report_parser.add_argument('--syntax-help', dest='syntax_help', action='store_true', help='display help')
3443
node_filter_help = 'name(s) or UID(s) of node(s) to filter results of the report by'
3544
report_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

Comments
 (0)