#!/usr/bin/env python3
"""
================================================================
Management Report Generator
================================================================
Reads a scan_and_fix.py JSON report and produces a management-
level Word document (.docx) suitable for non-technical stake-
holders. The report contains:

  * Executive summary with DERS scorecard
  * Business impact table
  * Detailed findings, colour-coded by severity
  * NIST 800-53 / CSA Top Threats standards mapping per finding
  * Copy-paste AWS CLI remediation commands
  * Consolidated remediation timeline with priority groups
  * Post-remediation DERS trajectory

USAGE
-----
  python generate_report.py baseline_report.json
  python generate_report.py baseline_report.json --output MyReport.docx

The report is designed so a manager who cannot open a terminal
can read it top to bottom and understand exposure, business
impact, and what needs fixing first.
================================================================
"""

import argparse
import json
import sys
from datetime import datetime

from docx import Document
from docx.shared import Pt, RGBColor, Cm, Inches
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_ALIGN_VERTICAL
from docx.oxml.ns import qn
from docx.oxml import OxmlElement


# ----------------------------------------------------------------
# Business-impact catalogue: per issue type, one line a manager
# can act on. Keeps the technical scanner output separate from
# the management-facing narrative.
# ----------------------------------------------------------------
BUSINESS_IMPACT = {
    's3_public_access': {
        'plain_english': 'Files in this storage bucket are readable by anyone on the internet without a password. Any customer data, credentials, or business information stored here can be downloaded by anyone who guesses or discovers the bucket URL.',
        'if_exploited': 'Direct data breach. Under UK GDPR, a public-storage exposure requires notification to the ICO within 72 hours of discovery. Reputational damage. Potential ICO fine of up to GBP 17.5 million or 4 per cent of global turnover.',
        'priority': 1,
        'window': 'Within 24 hours',
    },
    's3_public_policy': {
        'plain_english': 'The bucket has an explicit permission rule allowing every internet user to read its contents. This is the mechanism actually enabling the public exposure above.',
        'if_exploited': 'See above; this is one of the two settings creating the public exposure.',
        'priority': 1,
        'window': 'Within 24 hours',
    },
    's3_no_encryption': {
        'plain_english': 'Data in this storage bucket is stored in plain text on AWS servers, not encrypted at rest. If AWS storage were ever compromised, or if a backup copy escaped, the contents would be immediately readable.',
        'if_exploited': 'Compliance finding under most regulatory frameworks (PCI DSS, HIPAA, ISO 27001). Amplifies the impact of any storage breach.',
        'priority': 2,
        'window': 'Within 24 hours',
    },
    's3_no_versioning': {
        'plain_english': 'The storage bucket does not keep old versions of files. An accidentally overwritten or maliciously deleted file cannot be recovered from within AWS.',
        'if_exploited': 'Data loss (not data leak). Relevant in ransomware scenarios where an attacker with write access destroys data to force ransom payment.',
        'priority': 4,
        'window': 'Within 4 weeks',
    },
    'ec2_open_ssh': {
        'plain_english': 'A network rule permits remote administrative access (SSH, port 22) from every IP address on the internet. Automated attack tools scan the entire internet for open SSH ports continuously.',
        'if_exploited': 'Full server takeover once an attacker guesses or brute-forces a valid password. One of the top three initial-access vectors in recent industry breach reports.',
        'priority': 2,
        'window': 'Within 1 week',
    },
    'ec2_open_rdp': {
        'plain_english': 'A network rule permits Windows Remote Desktop (port 3389) from every IP address on the internet. RDP has been the entry point in a majority of publicly documented ransomware incidents over the past three years.',
        'if_exploited': 'Full server takeover, typically followed by ransomware deployment or data exfiltration.',
        'priority': 2,
        'window': 'Within 1 week',
    },
    'ec2_open_all_traffic': {
        'plain_english': 'A network rule permits every kind of network traffic from every IP address on the internet. This is the widest possible exposure of any server behind this rule.',
        'if_exploited': 'Any vulnerable service running on the server is directly exposed to the internet. Compounds the risk of every other software vulnerability on the machine.',
        'priority': 2,
        'window': 'Within 1 week',
    },
    'ec2_open_port': {
        'plain_english': 'A non-standard network port is open to the entire internet, which usually means a specific service (a database, a management interface, an application) is directly exposed.',
        'if_exploited': 'Depends on the service behind the port. Databases and management interfaces are the highest-impact common cases.',
        'priority': 2,
        'window': 'Within 1 week',
    },
    'ec2_instance_exposed': {
        'plain_english': 'A live server is running behind one of the internet-open network rules above, which turns a theoretical exposure into an actual reachable target.',
        'if_exploited': 'The specific server is immediately available for attack. Priority increases sharply if the server holds production data or credentials.',
        'priority': 2,
        'window': 'Within 1 week',
    },
    'iam_excessive_permissions': {
        'plain_english': 'This AWS user account has full administrative privilege over every service in the account. If the user\'s credentials are stolen, the attacker inherits total control.',
        'if_exploited': 'Complete AWS account takeover: attacker can create, destroy, exfiltrate, or ransom every resource in the account. There is no service they cannot reach.',
        'priority': 3,
        'window': 'Within 2 weeks',
    },
    'iam_no_mfa': {
        'plain_english': 'This AWS user account can be signed into using only a password. Multi-factor authentication (requiring a phone or hardware key in addition to the password) is not enforced.',
        'if_exploited': 'A stolen or phished password is sufficient for full account sign-in. Recent industry reporting attributes 22 per cent of confirmed breaches to credential abuse; MFA is the single control that breaks that chain.',
        'priority': 3,
        'window': 'Within 2 weeks',
    },
    'cloudtrail_disabled': {
        'plain_english': 'AWS is not currently recording any activity in this account. If a breach occurred, there would be no log of what the attacker did, when, or from which IP address.',
        'if_exploited': 'Absent logging does not cause a breach. It substantially extends detection time and undermines the ability to prove what happened during an incident, which affects legal, regulatory, and insurance outcomes.',
        'priority': 4,
        'window': 'Within 4 weeks',
    },
}


# Colours (RGB)
NAVY = RGBColor(0x1F, 0x3A, 0x5F)
CRIMSON = RGBColor(0xB2, 0x22, 0x22)  # HIGH
AMBER = RGBColor(0xB8, 0x86, 0x0B)    # MEDIUM
GREEN = RGBColor(0x2E, 0x7D, 0x32)    # LOW
DARK_GREY = RGBColor(0x33, 0x33, 0x33)
LIGHT_GREY_BG = 'F0F3F7'

SEVERITY_COLOUR = {'HIGH': CRIMSON, 'MEDIUM': AMBER, 'LOW': GREEN}
PRIORITY_LABEL = {
    1: 'Priority 1 - Actively Leaking Data (fix immediately)',
    2: 'Priority 2 - Attack Surface Exposure (fix within 1 week)',
    3: 'Priority 3 - Identity & Governance (fix within 2 weeks)',
    4: 'Priority 4 - Visibility & Resilience (fix within 4 weeks)',
}


def shade_cell(cell, hex_colour):
    """Apply a background colour to a table cell (docx has no direct API for this)."""
    tc_pr = cell._tc.get_or_add_tcPr()
    shd = OxmlElement('w:shd')
    shd.set(qn('w:val'), 'clear')
    shd.set(qn('w:color'), 'auto')
    shd.set(qn('w:fill'), hex_colour)
    tc_pr.append(shd)


def set_cell_borders(cell, colour='808080'):
    tc_pr = cell._tc.get_or_add_tcPr()
    tc_borders = OxmlElement('w:tcBorders')
    for edge in ('top', 'left', 'bottom', 'right'):
        border = OxmlElement(f'w:{edge}')
        border.set(qn('w:val'), 'single')
        border.set(qn('w:sz'), '4')
        border.set(qn('w:color'), colour)
        tc_borders.append(border)
    tc_pr.append(tc_borders)


def add_heading(doc, text, level, colour=NAVY):
    """Add a heading paragraph with consistent styling."""
    p = doc.add_paragraph()
    run = p.add_run(text)
    run.bold = True
    run.font.color.rgb = colour
    if level == 1:
        run.font.size = Pt(18)
        p.alignment = WD_ALIGN_PARAGRAPH.CENTER
        p.paragraph_format.space_after = Pt(12)
    elif level == 2:
        run.font.size = Pt(14)
        p.paragraph_format.space_before = Pt(18)
        p.paragraph_format.space_after = Pt(8)
    elif level == 3:
        run.font.size = Pt(12)
        p.paragraph_format.space_before = Pt(14)
        p.paragraph_format.space_after = Pt(6)
    return p


def add_para(doc, text, bold=False, italic=False, colour=None, size=11):
    p = doc.add_paragraph()
    run = p.add_run(text)
    run.font.size = Pt(size)
    run.bold = bold
    run.italic = italic
    if colour:
        run.font.color.rgb = colour
    p.paragraph_format.space_after = Pt(6)
    return p


def add_labelled_para(doc, label, body):
    """A paragraph with a bold label prefix and normal body text."""
    p = doc.add_paragraph()
    label_run = p.add_run(f'{label}: ')
    label_run.bold = True
    label_run.font.size = Pt(11)
    body_run = p.add_run(body)
    body_run.font.size = Pt(11)
    p.paragraph_format.space_after = Pt(4)
    return p


def add_code_block(doc, command_text):
    """Add a monospace code block on a shaded background."""
    for line in command_text.split('\n'):
        p = doc.add_paragraph()
        run = p.add_run(line if line else ' ')
        run.font.name = 'Consolas'
        run.font.size = Pt(9)
        run.font.color.rgb = NAVY
        p.paragraph_format.space_after = Pt(0)
        p.paragraph_format.space_before = Pt(0)
        p.paragraph_format.left_indent = Cm(0.5)
        # Shade the paragraph background
        p_pr = p._p.get_or_add_pPr()
        shd = OxmlElement('w:shd')
        shd.set(qn('w:val'), 'clear')
        shd.set(qn('w:color'), 'auto')
        shd.set(qn('w:fill'), LIGHT_GREY_BG)
        p_pr.append(shd)
    doc.add_paragraph()


def add_kv_table(doc, kv_pairs, key_width_cm=4.0, val_width_cm=13.0):
    """Add a two-column key/value table."""
    table = doc.add_table(rows=len(kv_pairs), cols=2)
    for row_idx, (key, val) in enumerate(kv_pairs):
        row = table.rows[row_idx]
        row.cells[0].width = Cm(key_width_cm)
        row.cells[1].width = Cm(val_width_cm)
        # Key cell
        p_key = row.cells[0].paragraphs[0]
        p_key.paragraph_format.space_after = Pt(0)
        run_key = p_key.add_run(key)
        run_key.bold = True
        run_key.font.size = Pt(10)
        shade_cell(row.cells[0], 'E8EEF5')
        # Value cell
        p_val = row.cells[1].paragraphs[0]
        p_val.paragraph_format.space_after = Pt(0)
        run_val = p_val.add_run(val)
        run_val.font.size = Pt(10)
        # Severity colouring on the value if it's a severity word
        if val.strip() in SEVERITY_COLOUR:
            run_val.font.color.rgb = SEVERITY_COLOUR[val.strip()]
            run_val.bold = True
        for cell in row.cells:
            set_cell_borders(cell)
    return table


def add_summary_table(doc, headers, rows):
    """Add a standard summary table with a shaded header row."""
    table = doc.add_table(rows=1 + len(rows), cols=len(headers))
    hdr_cells = table.rows[0].cells
    for i, h in enumerate(headers):
        p = hdr_cells[i].paragraphs[0]
        p.paragraph_format.space_after = Pt(0)
        run = p.add_run(h)
        run.bold = True
        run.font.size = Pt(10)
        run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
        shade_cell(hdr_cells[i], '1F3A5F')
        set_cell_borders(hdr_cells[i])
    for r_idx, row_data in enumerate(rows, start=1):
        for c_idx, val in enumerate(row_data):
            cell = table.rows[r_idx].cells[c_idx]
            p = cell.paragraphs[0]
            p.paragraph_format.space_after = Pt(0)
            run = p.add_run(str(val))
            run.font.size = Pt(10)
            if str(val).strip() in SEVERITY_COLOUR:
                run.font.color.rgb = SEVERITY_COLOUR[str(val).strip()]
                run.bold = True
            set_cell_borders(cell)
    return table


def cli_command_for(issue):
    """Return the AWS CLI command to fix this issue, formatted for readability."""
    # The scanner already writes a single-line command; wrap long ones.
    cmd = issue.get('fix', '')
    # Break at common continuation points for readability
    cmd = cmd.replace(' --', ' \\\n    --')
    return cmd


def build_executive_summary(doc, report):
    add_heading(doc, 'AWS Hybrid Cloud Security Assessment', level=1)
    add_heading(doc, 'Executive Report and Remediation Plan', level=1, colour=DARK_GREY)

    metadata = [
        ('Assessment date', report.get('scanned_at', datetime.now().isoformat())[:19].replace('T', ' ')),
        ('Region assessed', report.get('region', 'unknown')),
        ('Classification', 'Internal - Sensitive'),
        ('Prepared by', 'Automated scan (scan_and_fix.py)'),
    ]
    add_kv_table(doc, metadata, key_width_cm=4.5, val_width_cm=12.5)
    doc.add_paragraph()

    add_heading(doc, '1. Executive Summary', level=2)

    issues = report.get('issues', [])
    total = len(issues)
    high = sum(1 for i in issues if i['severity'] == 'HIGH')
    med = sum(1 for i in issues if i['severity'] == 'MEDIUM')
    low = sum(1 for i in issues if i['severity'] == 'LOW')
    ders = report.get('ders', 3 * high + 2 * med + low)

    add_para(doc,
        f'An assessment of the target AWS environment identified {total} distinct security '
        f'misconfigurations. Overall exposure was measured using the Data Exposure Risk Score '
        f'(DERS): a weighted scoring model where each finding contributes 3 points (High), '
        f'2 points (Medium), or 1 point (Low).'
    )

    add_summary_table(doc,
        ['Metric', 'Baseline', 'Post-remediation target'],
        [
            ['Total findings', str(total), '0'],
            ['High-severity findings', str(high), '0'],
            ['Medium-severity findings', str(med), '0'],
            ['Low-severity findings', str(low), '0'],
            ['Data Exposure Risk Score (DERS)', str(ders), '0'],
        ]
    )

    # Priority 1 count (data-leaking findings)
    p1_types = {'s3_public_access', 's3_public_policy'}
    p1_count = sum(1 for i in issues if i['type'] in p1_types)

    doc.add_paragraph()
    add_labelled_para(doc, 'Bottom line',
        f'{p1_count} of the {high} High-severity findings actively expose data to the public '
        f'internet at time of assessment. The remaining findings do not directly leak data but '
        f'create the conditions under which a single stolen credential could reach the entire '
        f'environment. All findings can be remediated using AWS-native controls at no additional '
        f'licensing cost, and total engineer effort to close every High finding is under 2 hours.'
    )


def build_business_impact_table(doc, report):
    add_heading(doc, '2. Business Impact at a Glance', level=2)
    add_para(doc,
        'The table below translates each technical finding into the business consequence a '
        'manager can weigh against remediation effort.'
    )

    rows = []
    for issue in report.get('issues', []):
        impact = BUSINESS_IMPACT.get(issue['type'], {})
        rows.append([
            issue['type'].replace('_', ' ').title(),
            issue['severity'],
            impact.get('if_exploited', '(business impact not catalogued)'),
        ])

    add_summary_table(doc, ['Finding', 'Severity', 'Business Impact if Exploited'], rows)


def build_detailed_findings(doc, report):
    add_heading(doc, '3. Detailed Findings', level=2)

    # Group by priority
    issues_by_priority = {1: [], 2: [], 3: [], 4: []}
    for issue in report.get('issues', []):
        p = BUSINESS_IMPACT.get(issue['type'], {}).get('priority', 4)
        issues_by_priority[p].append(issue)

    finding_counter = 0

    for priority in [1, 2, 3, 4]:
        priority_issues = issues_by_priority[priority]
        if not priority_issues:
            continue

        add_heading(doc, f'3.{priority} {PRIORITY_LABEL[priority]}', level=3)

        for issue in priority_issues:
            finding_counter += 1
            finding_id = f'F-{finding_counter:02d}'
            impact = BUSINESS_IMPACT.get(issue['type'], {})

            # Coloured heading per severity
            colour = SEVERITY_COLOUR.get(issue['severity'], DARK_GREY)
            add_heading(doc,
                f'{finding_id}: {issue["description"]}',
                level=3, colour=colour
            )

            # Metadata table
            add_kv_table(doc, [
                ('Severity', issue['severity']),
                ('Resource', issue['resource']),
                ('Issue type', issue['type']),
                ('CSA Top Threats 2025', issue.get('csa', 'not mapped')),
                ('NIST SP 800-53 Rev. 5', issue.get('nist', 'not mapped')),
                ('DERS contribution', str({'HIGH': 3, 'MEDIUM': 2, 'LOW': 1}[issue['severity']])),
                ('Recommended fix window', impact.get('window', 'Within 4 weeks')),
            ])
            doc.add_paragraph()

            # Plain-English explanation
            add_labelled_para(doc, 'What this means',
                impact.get('plain_english', 'No plain-English explanation catalogued for this issue type.'))
            doc.add_paragraph()

            # Business impact
            add_labelled_para(doc, 'Business impact if exploited',
                impact.get('if_exploited', 'Not catalogued.'))
            doc.add_paragraph()

            # Remediation
            add_labelled_para(doc, 'Remediation command', '')
            add_code_block(doc, cli_command_for(issue))

            # Verification note
            add_para(doc,
                'Verification: rerun scan_and_fix.py after applying this command; the finding '
                'should no longer appear in the JSON output.',
                italic=True, size=10, colour=DARK_GREY
            )
            doc.add_paragraph()


def build_timeline(doc, report):
    add_heading(doc, '4. Consolidated Remediation Timeline', level=2)

    issues_by_priority = {1: [], 2: [], 3: [], 4: []}
    for issue in report.get('issues', []):
        p = BUSINESS_IMPACT.get(issue['type'], {}).get('priority', 4)
        issues_by_priority[p].append(issue)

    finding_counter = 0
    priority_ids = {1: [], 2: [], 3: [], 4: []}
    for p in [1, 2, 3, 4]:
        for _ in issues_by_priority[p]:
            finding_counter += 1
            priority_ids[p].append(f'F-{finding_counter:02d}')

    priority_windows = {
        1: ('Within 24 hours', '30 minutes'),
        2: ('Within 1 week', '45 minutes'),
        3: ('Within 2 weeks', '45 minutes (MFA needs human enrolment)'),
        4: ('Within 4 weeks', '20 minutes'),
    }
    priority_labels_short = {
        1: 'Immediate (leak in progress)',
        2: 'Attack surface',
        3: 'Identity & governance',
        4: 'Visibility & resilience',
    }

    rows = []
    for p in [1, 2, 3, 4]:
        if not issues_by_priority[p]:
            continue
        window, effort = priority_windows[p]
        rows.append([
            priority_labels_short[p],
            ', '.join(priority_ids[p]) if priority_ids[p] else '(none)',
            window,
            effort,
        ])

    add_summary_table(doc,
        ['Priority', 'Findings', 'Recommended completion', 'Estimated effort'],
        rows,
    )
    doc.add_paragraph()

    # DERS trajectory
    add_heading(doc, '5. Verification Plan and DERS Trajectory', level=2)
    add_para(doc,
        'After each remediation window, rerun scan_and_fix.py to confirm the specific findings '
        'no longer appear, and (for S3 findings) rerun demonstrate_leak.py to confirm '
        'unauthenticated HTTP requests receive 403 Forbidden. The Data Exposure Risk Score '
        'should drop as follows:'
    )

    issues = report.get('issues', [])
    baseline_ders = report.get('ders', sum({'HIGH': 3, 'MEDIUM': 2, 'LOW': 1}[i['severity']] for i in issues))

    # Cumulative DERS after each priority window
    trajectory_rows = [['Baseline (nothing fixed)', str(baseline_ders)]]
    remaining_ders = baseline_ders
    for p in [1, 2, 3, 4]:
        if not issues_by_priority[p]:
            continue
        p_ders = sum({'HIGH': 3, 'MEDIUM': 2, 'LOW': 1}[i['severity']] for i in issues_by_priority[p])
        remaining_ders -= p_ders
        trajectory_rows.append([
            f'+ Priority {p} done ({priority_labels_short[p]})',
            str(remaining_ders),
        ])

    add_summary_table(doc, ['After remediation window', 'Expected DERS'], trajectory_rows)


def build_appendix(doc, report):
    add_heading(doc, 'Appendix A: Evidence of Public Data Exposure', level=2)
    add_para(doc,
        'The public S3 findings above were confirmed as actively leaking by demonstrate_leak.py, '
        'which issues plain HTTPS GET requests with no AWS credentials attached. Before '
        'remediation, all test files returned HTTP 200 OK with full contents. After the S3 '
        'remediation commands are applied, the same requests receive HTTP 403 Forbidden from '
        'AWS at the storage service boundary.'
    )
    add_para(doc,
        'Captured evidence files (if the --save-evidence flag was used) are stored alongside '
        'this report as evidence_<filename>_<timestamp>.txt and can be attached to any incident '
        'record.',
        italic=True, size=10, colour=DARK_GREY,
    )

    add_heading(doc, 'Appendix B: Scanner Tooling', level=2)
    add_para(doc,
        'This report was generated from the raw JSON output of scan_and_fix.py, a read-only '
        'AWS security scanner covering S3, EC2, IAM, and CloudTrail. Every finding maps to '
        'a NIST SP 800-53 Rev. 5 control and a Cloud Security Alliance Top Threats 2025 '
        'category through a single central catalogue in the scanner\'s source code, which '
        'means the standards mapping cannot drift between findings.'
    )
    add_para(doc,
        'The complete scanner source and the automated remediation script (fix_vulnerabilities.py) '
        'are provided as separate files in the assessment package.'
    )


def build_report(report_data, output_path):
    doc = Document()

    # Set page margins (2.5cm all round is comfortable for management docs)
    for section in doc.sections:
        section.left_margin = Cm(2.5)
        section.right_margin = Cm(2.5)
        section.top_margin = Cm(2.5)
        section.bottom_margin = Cm(2.5)

    # Set default font
    style = doc.styles['Normal']
    style.font.name = 'Calibri'
    style.font.size = Pt(11)

    build_executive_summary(doc, report_data)
    doc.add_page_break()
    build_business_impact_table(doc, report_data)
    doc.add_page_break()
    build_detailed_findings(doc, report_data)
    doc.add_page_break()
    build_timeline(doc, report_data)
    doc.add_page_break()
    build_appendix(doc, report_data)

    doc.save(output_path)


def main():
    parser = argparse.ArgumentParser(description='Generate a management-level Word report from a scan_and_fix.py JSON report.')
    parser.add_argument('report', help='Path to the JSON report produced by scan_and_fix.py --json')
    parser.add_argument('--output', default=None,
                        help='Output .docx filename (default: derived from report filename)')
    args = parser.parse_args()

    try:
        with open(args.report) as f:
            report_data = json.load(f)
    except FileNotFoundError:
        print(f'ERROR: report file not found: {args.report}')
        sys.exit(1)
    except json.JSONDecodeError as e:
        print(f'ERROR: report file is not valid JSON: {e}')
        sys.exit(1)

    output_path = args.output
    if not output_path:
        base = args.report.rsplit('.', 1)[0]
        ts = datetime.now().strftime('%Y%m%d_%H%M%S')
        output_path = f'{base}_management_report_{ts}.docx'

    issues = report_data.get('issues', [])
    print('=' * 70)
    print('GENERATING MANAGEMENT REPORT')
    print(f'Input:    {args.report}')
    print(f'Findings: {len(issues)}')
    print(f'Output:   {output_path}')
    print('=' * 70)

    build_report(report_data, output_path)

    print(f'\nReport written to {output_path}')
    print('Open it in Microsoft Word, Google Docs, or LibreOffice.')


if __name__ == '__main__':
    main()
