Fase 0 complete: extract all business logic, auth, database, and 176 endpoints from monolithic app.py into dedicated modules. Structure: - app.py: FastAPI hub (CORS, routers, startup/shutdown) - models.py: 13 Pydantic request models - utils.py: shared utilities (embed status, upload validation, process registries) - config.py: constants, env vars, model catalogs - auth/: crypto, jwt_auth, oidc, rate_limit - database/: db(), init_db(), schema DDL - services/: genai, compliance, chat, terraform, embeddings, cis_reports, mcp - routes/: 13 APIRouter modules (auth, users, oci_config, oci_explorer, genai, mcp, adb, embeddings, reports, chat, terraform, settings, cis_engine) Also: README updated with OCIR auth instructions for manual docker run. 86 tests passing.
1001 lines
44 KiB
Python
1001 lines
44 KiB
Python
"""DOCX compliance report generation — camo strip, floating images, professional formatting."""
|
|
import os, json, re, io
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
from typing import Optional, List, Dict, Any
|
|
|
|
from config import REPORTS, log
|
|
from database import db
|
|
from services.compliance_gen import _OCI_SERVICE_DESCRIPTIONS, _check_oci_services, _generate_compliance_report
|
|
|
|
def _generate_camo_strip() -> bytes:
|
|
"""Generate a camouflage-style decorative strip PNG (like Oracle PDF reports)."""
|
|
from PIL import Image, ImageDraw, ImageFilter
|
|
import random, io as _io
|
|
W, H = 36, 1500
|
|
# Exact colors from the HTML page-strip gradient — olive/sage/gold tones
|
|
C1 = (107, 127, 58) # #6b7f3a olive green
|
|
C2 = (138, 154, 78) # #8a9a4e sage green
|
|
C3 = (74, 103, 65) # #4a6741 forest
|
|
C4 = (45, 59, 42) # #2d3b2a dark forest
|
|
C5 = (196, 169, 77) # #c4a94d gold
|
|
C6 = (139, 109, 59) # #8b6d3b brown/tan
|
|
C7 = (61, 92, 58) # #3d5c3a deep green
|
|
ALL = [C1, C2, C3, C4, C5, C6, C7]
|
|
|
|
img = Image.new('RGB', (W, H), C4)
|
|
draw = ImageDraw.Draw(img)
|
|
random.seed(42)
|
|
import math
|
|
|
|
def _poly(cx, cy, r, sides=5):
|
|
pts = []
|
|
for i in range(sides):
|
|
a = 2 * math.pi * i / sides + random.uniform(-0.5, 0.5)
|
|
rx = r * random.uniform(0.5, 1.4)
|
|
ry = r * random.uniform(0.8, 2.2)
|
|
pts.append((cx + rx * math.cos(a), cy + ry * math.sin(a)))
|
|
return pts
|
|
|
|
# Vertical gradient base (like the CSS linear-gradient)
|
|
for y in range(H):
|
|
t = y / H
|
|
# Cycle through gradient stops
|
|
if t < 0.16: c = C1 if t < 0.08 else C2
|
|
elif t < 0.32: c = C3 if t < 0.24 else C4
|
|
elif t < 0.48: c = C5 if t < 0.40 else C6
|
|
elif t < 0.64: c = C7 if t < 0.56 else C1
|
|
elif t < 0.80: c = C5 if t < 0.72 else C3
|
|
else: c = C4 if t < 0.88 else C2
|
|
draw.line([(0, y), (W, y)], fill=c)
|
|
|
|
# Organic patches on top for camo texture
|
|
for _ in range(35):
|
|
c = random.choice(ALL)
|
|
cx = random.randint(-8, W + 8)
|
|
cy = random.randint(-20, H + 20)
|
|
draw.polygon(_poly(cx, cy, random.randint(12, 30), random.randint(4, 6)), fill=c)
|
|
|
|
for _ in range(25):
|
|
c = random.choice([C5, C6, C2])
|
|
cx = random.randint(0, W)
|
|
cy = random.randint(0, H)
|
|
draw.polygon(_poly(cx, cy, random.randint(6, 16), random.randint(3, 5)), fill=c)
|
|
|
|
img = img.filter(ImageFilter.GaussianBlur(radius=1.2))
|
|
buf = _io.BytesIO()
|
|
img.save(buf, format='PNG')
|
|
return buf.getvalue()
|
|
|
|
|
|
def _add_floating_image_to_header(header, image_bytes: bytes, width_emu: int, height_emu: int):
|
|
"""Add a floating behind-text image to a DOCX header (positioned at left edge, full page height)."""
|
|
from docx.oxml.ns import nsdecls
|
|
from docx.oxml import parse_xml, OxmlElement
|
|
import io as _io
|
|
|
|
rId, _ = header.part.get_or_add_image(_io.BytesIO(image_bytes))
|
|
sw, sh = str(width_emu), str(height_emu)
|
|
|
|
anchor_xml = f'''
|
|
<wp:anchor {nsdecls('wp','a','r','pic')}
|
|
distT="0" distB="0" distL="0" distR="0"
|
|
simplePos="0" relativeHeight="0" behindDoc="1"
|
|
locked="1" layoutInCell="1" allowOverlap="1">
|
|
<wp:simplePos x="0" y="0"/>
|
|
<wp:positionH relativeFrom="page"><wp:posOffset>0</wp:posOffset></wp:positionH>
|
|
<wp:positionV relativeFrom="page"><wp:posOffset>0</wp:posOffset></wp:positionV>
|
|
<wp:extent cx="{sw}" cy="{sh}"/>
|
|
<wp:wrapNone/>
|
|
<wp:docPr id="1" name="CamoStrip"/>
|
|
<a:graphic>
|
|
<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">
|
|
<pic:pic>
|
|
<pic:nvPicPr>
|
|
<pic:cNvPr id="0" name="strip.png"/>
|
|
<pic:cNvPicPr/>
|
|
</pic:nvPicPr>
|
|
<pic:blipFill>
|
|
<a:blip r:embed="{rId}"/>
|
|
<a:stretch><a:fillRect/></a:stretch>
|
|
</pic:blipFill>
|
|
<pic:spPr>
|
|
<a:xfrm>
|
|
<a:off x="0" y="0"/>
|
|
<a:ext cx="{sw}" cy="{sh}"/>
|
|
</a:xfrm>
|
|
<a:prstGeom prst="rect"/>
|
|
</pic:spPr>
|
|
</pic:pic>
|
|
</a:graphicData>
|
|
</a:graphic>
|
|
</wp:anchor>'''
|
|
|
|
anchor_el = parse_xml(anchor_xml)
|
|
drawing = OxmlElement('w:drawing')
|
|
drawing.append(anchor_el)
|
|
# Add to the first run in header paragraph
|
|
p = header.paragraphs[0]
|
|
runs = p.runs
|
|
if runs:
|
|
runs[0]._r.append(drawing)
|
|
else:
|
|
r = p.add_run()
|
|
r._r.append(drawing)
|
|
|
|
|
|
def _generate_compliance_docx(rid: str, tenancy: str, file_map: dict) -> bytes:
|
|
"""Generate a professionally styled DOCX version of the compliance report matching the HTML/PDF."""
|
|
import csv as csvmod, io as _io, re as _re
|
|
from datetime import datetime as _dt
|
|
from docx import Document
|
|
from docx.shared import Inches, Pt, Cm, RGBColor, Emu
|
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
from docx.enum.table import WD_TABLE_ALIGNMENT
|
|
from docx.oxml.ns import qn, nsdecls
|
|
from docx.oxml import parse_xml, OxmlElement
|
|
|
|
import json as _json
|
|
rdir = REPORTS / rid
|
|
|
|
# Read the same processed data used by the HTML report (ensures identical content)
|
|
data_path = rdir / "compliance_data.json"
|
|
if not data_path.exists():
|
|
log.warning(f"DOCX: compliance_data.json not found for {rid} — generate HTML report first")
|
|
return None
|
|
with open(data_path, "r", encoding="utf-8") as f:
|
|
report_data = _json.load(f)
|
|
|
|
tenancy_name = report_data.get("tenancy_name", tenancy)
|
|
extract_date = report_data.get("extract_date", "")
|
|
filtered = report_data.get("filtered", [])
|
|
sections_data = report_data.get("sections", {})
|
|
oci_services = report_data.get("oci_services", [])
|
|
file_path_map = report_data.get("file_path_map", file_map)
|
|
|
|
if not filtered:
|
|
return None
|
|
|
|
now = _dt.utcnow()
|
|
months_en = ["January","February","March","April","May","June","July","August","September","October","November","December"]
|
|
month_year = f"{months_en[now.month - 1]}, {now.year}"
|
|
|
|
# ── Exact colors from HTML/PDF spec ──
|
|
ORACLE_RED = RGBColor(0xC7, 0x46, 0x34)
|
|
HEADER_GREEN = '4A7C59'
|
|
FINDINGS_HEADER_GREEN = '4A6741'
|
|
ROW_ALT = 'EEF3F0'
|
|
GREEN = RGBColor(0x0F, 0x7B, 0x0F)
|
|
RED = RGBColor(0xC0, 0x00, 0x00)
|
|
MUTED = RGBColor(0x64, 0x74, 0x8B)
|
|
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
|
|
BODY_TEXT = RGBColor(0x1A, 0x1A, 0x1A)
|
|
TITLE_DARK = RGBColor(0x11, 0x18, 0x27)
|
|
SUBTITLE_GRAY = RGBColor(0x37, 0x41, 0x51)
|
|
ORANGE = RGBColor(0xD9, 0x77, 0x06)
|
|
|
|
# ── Helpers ──
|
|
def _cell_shading(cell, color_hex):
|
|
shading = parse_xml(f'<w:shd {nsdecls("w")} w:fill="{color_hex}"/>')
|
|
cell._tc.get_or_add_tcPr().append(shading)
|
|
|
|
def _set_cell_width(cell, width_inches):
|
|
tcPr = cell._tc.get_or_add_tcPr()
|
|
tcW = OxmlElement('w:tcW')
|
|
tcW.set(qn('w:w'), str(int(width_inches * 1440)))
|
|
tcW.set(qn('w:type'), 'dxa')
|
|
tcPr.append(tcW)
|
|
|
|
def _cell_margins(cell, top=40, bottom=40, left=80, right=80):
|
|
"""Set cell margins in twips for tighter padding."""
|
|
tcPr = cell._tc.get_or_add_tcPr()
|
|
tcMar = OxmlElement('w:tcMar')
|
|
for side, val in [('top', top), ('bottom', bottom), ('start', left), ('end', right)]:
|
|
el = OxmlElement(f'w:{side}')
|
|
el.set(qn('w:w'), str(val))
|
|
el.set(qn('w:type'), 'dxa')
|
|
tcMar.append(el)
|
|
tcPr.append(tcMar)
|
|
|
|
def _cell_text(cell, text, bold=False, color=None, size=Pt(9), align=WD_ALIGN_PARAGRAPH.LEFT, uppercase=False):
|
|
cell.text = ""
|
|
p = cell.paragraphs[0]
|
|
p.alignment = align
|
|
p.paragraph_format.space_before = Pt(2)
|
|
p.paragraph_format.space_after = Pt(2)
|
|
p.paragraph_format.line_spacing = 1.0
|
|
display = str(text).upper() if uppercase else str(text)
|
|
r = p.add_run(display)
|
|
r.font.size = size
|
|
r.font.name = 'Calibri'
|
|
r.bold = bold
|
|
if color:
|
|
r.font.color.rgb = color
|
|
else:
|
|
r.font.color.rgb = BODY_TEXT
|
|
|
|
def _table_borders(table, color='D0D5DD', size='4'):
|
|
tbl = table._tbl
|
|
tblPr = tbl.tblPr if tbl.tblPr is not None else OxmlElement('w:tblPr')
|
|
borders = OxmlElement('w:tblBorders')
|
|
for edge in ('top', 'left', 'bottom', 'right', 'insideH', 'insideV'):
|
|
el = OxmlElement(f'w:{edge}')
|
|
el.set(qn('w:val'), 'single')
|
|
el.set(qn('w:sz'), size)
|
|
el.set(qn('w:space'), '0')
|
|
el.set(qn('w:color'), color)
|
|
borders.append(el)
|
|
tblPr.append(borders)
|
|
|
|
def _set_repeat_header(table):
|
|
"""Make first row repeat as header on page breaks (like PDF)."""
|
|
tr = table.rows[0]._tr
|
|
trPr = tr.get_or_add_trPr()
|
|
tblHeader = OxmlElement('w:tblHeader')
|
|
trPr.append(tblHeader)
|
|
|
|
def _no_cell_spacing(table):
|
|
"""Remove cell spacing for tight table look."""
|
|
tbl = table._tbl
|
|
tblPr = tbl.tblPr if tbl.tblPr is not None else OxmlElement('w:tblPr')
|
|
spacing = OxmlElement('w:tblCellSpacing')
|
|
spacing.set(qn('w:w'), '0')
|
|
spacing.set(qn('w:type'), 'dxa')
|
|
tblPr.append(spacing)
|
|
|
|
def _styled_table(doc, headers, data_rows, col_widths=None, header_align=None, header_color=None):
|
|
"""Create a table matching the PDF style: green header, alternating rows."""
|
|
hdr_clr = header_color or HEADER_GREEN
|
|
tbl = doc.add_table(rows=1 + len(data_rows), cols=len(headers))
|
|
tbl.autofit = False
|
|
tbl.allow_autofit = False
|
|
tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
|
|
|
|
# Set column widths if provided
|
|
if col_widths:
|
|
for i, w in enumerate(col_widths):
|
|
for row in tbl.rows:
|
|
_set_cell_width(row.cells[i], w)
|
|
|
|
# Header row — green background, white text, uppercase
|
|
for i, h in enumerate(headers):
|
|
cell = tbl.rows[0].cells[i]
|
|
_cell_shading(cell, hdr_clr)
|
|
_cell_margins(cell)
|
|
h_align = WD_ALIGN_PARAGRAPH.CENTER
|
|
if header_align and i < len(header_align):
|
|
h_align = header_align[i]
|
|
elif i == 0:
|
|
h_align = WD_ALIGN_PARAGRAPH.LEFT
|
|
_cell_text(cell, h, bold=True, color=WHITE, size=Pt(9), align=h_align, uppercase=True)
|
|
|
|
# Data rows — alternating green tint / white
|
|
for ridx, row_data in enumerate(data_rows):
|
|
bg = ROW_ALT if ridx % 2 == 0 else 'FFFFFF'
|
|
for cidx, val in enumerate(row_data):
|
|
cell = tbl.rows[ridx + 1].cells[cidx]
|
|
_cell_shading(cell, bg)
|
|
_cell_margins(cell)
|
|
if isinstance(val, tuple):
|
|
txt, bld, clr = val[0], val[1], val[2]
|
|
a = WD_ALIGN_PARAGRAPH.CENTER if cidx > 0 and len(str(txt)) < 10 else WD_ALIGN_PARAGRAPH.LEFT
|
|
_cell_text(cell, txt, bold=bld, color=clr, size=Pt(9), align=a)
|
|
else:
|
|
a = WD_ALIGN_PARAGRAPH.CENTER if cidx > 0 and len(str(val)) < 10 else WD_ALIGN_PARAGRAPH.LEFT
|
|
_cell_text(cell, val, size=Pt(9), align=a)
|
|
|
|
_table_borders(tbl)
|
|
_no_cell_spacing(tbl)
|
|
_set_repeat_header(tbl)
|
|
return tbl
|
|
|
|
def _add_heading_red(doc, text, level=1):
|
|
h = doc.add_heading(text, level=level)
|
|
for r in h.runs:
|
|
r.font.color.rgb = ORACLE_RED
|
|
r.font.name = 'Calibri'
|
|
return h
|
|
|
|
def _clean_text(text):
|
|
"""Join artificially broken lines into flowing paragraphs.
|
|
Preserves: paragraph breaks, numbered lists, bullets, headers."""
|
|
if not text:
|
|
return text
|
|
lines = text.split('\n')
|
|
result = []
|
|
for line in lines:
|
|
line = line.strip()
|
|
if not line:
|
|
result.append('\n')
|
|
continue
|
|
starts_new = (
|
|
_re.match(r'^\d+[\.\)]\s', line) or
|
|
_re.match(r'^[a-z][\.\)]\s', line) or
|
|
line.startswith('- ') or line.startswith('* ') or
|
|
line.startswith('From ') or
|
|
line.startswith('Note:') or line.startswith('See ')
|
|
)
|
|
if starts_new:
|
|
result.append(line)
|
|
elif result and result[-1] != '\n' and not result[-1].endswith(('.', ':', '!', '?', ';')):
|
|
result[-1] = result[-1] + ' ' + line
|
|
else:
|
|
result.append(line)
|
|
return '\n'.join(result).strip()
|
|
|
|
def _add_body_para(doc, text, size=Pt(10), color=None, bold=False, italic=False, space_after=Pt(8), space_before=Pt(0), align=WD_ALIGN_PARAGRAPH.JUSTIFY):
|
|
"""Add paragraph(s) with consistent body styling. Detects numbered/bullet lists and indents them."""
|
|
text = _clean_text(text)
|
|
if not text:
|
|
return None
|
|
last_p = None
|
|
for chunk in text.split('\n'):
|
|
chunk = chunk.strip()
|
|
if not chunk:
|
|
continue
|
|
# Detect list items: "1. text", "a. text", "- text", "* text"
|
|
list_match = _re.match(r'^(\d+[\.\)]|[a-z][\.\)]|-|\*)\s+(.+)', chunk)
|
|
p = doc.add_paragraph()
|
|
p.alignment = WD_ALIGN_PARAGRAPH.LEFT if list_match else align
|
|
p.paragraph_format.space_before = Pt(1) if list_match else space_before
|
|
p.paragraph_format.space_after = Pt(3) if list_match else space_after
|
|
p.paragraph_format.line_spacing = 1.25
|
|
if list_match:
|
|
# Indent list items
|
|
p.paragraph_format.left_indent = Inches(0.3)
|
|
p.paragraph_format.first_line_indent = Inches(-0.3)
|
|
# Number/bullet in bold
|
|
r = p.add_run(list_match.group(1) + ' ')
|
|
r.font.name = 'Calibri'
|
|
r.font.size = size
|
|
r.font.color.rgb = RGBColor(0x2F, 0x55, 0x97)
|
|
r.bold = True
|
|
# Content
|
|
r = p.add_run(list_match.group(2))
|
|
r.font.name = 'Calibri'
|
|
r.font.size = size
|
|
r.font.color.rgb = color or BODY_TEXT
|
|
else:
|
|
r = p.add_run(chunk)
|
|
r.font.name = 'Calibri'
|
|
r.font.size = size
|
|
r.font.color.rgb = color or BODY_TEXT
|
|
r.bold = bold
|
|
r.italic = italic
|
|
last_p = p
|
|
return last_p
|
|
|
|
def _is_code_line(line):
|
|
"""Detect if a line is a CLI command or code snippet."""
|
|
line = line.strip()
|
|
if not line:
|
|
return False
|
|
# Strong indicators: starts with known CLI commands
|
|
cli_starts = ['oci ', 'aws ', 'az ', 'gcloud ', 'terraform ', 'kubectl ',
|
|
'curl ', 'grep ', 'cat ', 'echo ', 'export ', 'sudo ', '#!/']
|
|
if any(line.startswith(p) for p in cli_starts):
|
|
return True
|
|
# Continuation lines (start with --flag or -flag=value)
|
|
if _re.match(r'^--\w+', line) or _re.match(r'^-\w+=', line):
|
|
return True
|
|
# Shell constructs
|
|
if line in ('do', 'done', 'fi', 'then', 'else', 'esac'):
|
|
return True
|
|
if line.startswith('for ') and (' in ' in line or line.endswith('do')):
|
|
return True
|
|
return False
|
|
|
|
def _is_section_header(line):
|
|
"""Detect "From Console:", "From Command Line:", "Audit / Verification:", etc."""
|
|
low = line.strip().lower()
|
|
return (low.startswith('from console') or low.startswith('from oci console') or
|
|
low.startswith('from command') or low.startswith('from cli') or
|
|
low.startswith('from the command') or low.startswith('audit') and 'verification' in low)
|
|
|
|
def _add_code_block(doc, code_text):
|
|
"""Add a code block with monospace font and gray background."""
|
|
p = doc.add_paragraph()
|
|
p.paragraph_format.space_before = Pt(6)
|
|
p.paragraph_format.space_after = Pt(8)
|
|
p.paragraph_format.line_spacing = 1.1
|
|
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
pPr = p._p.get_or_add_pPr()
|
|
pShd = OxmlElement('w:shd')
|
|
pShd.set(qn('w:val'), 'clear')
|
|
pShd.set(qn('w:color'), 'auto')
|
|
pShd.set(qn('w:fill'), 'F3F4F6')
|
|
pPr.append(pShd)
|
|
pBdr = OxmlElement('w:pBdr')
|
|
for edge_name, color in [('left', '9CA3AF'), ('top', 'E5E7EB'), ('bottom', 'E5E7EB'), ('right', 'E5E7EB')]:
|
|
edge = OxmlElement(f'w:{edge_name}')
|
|
edge.set(qn('w:val'), 'single')
|
|
edge.set(qn('w:sz'), '6' if edge_name == 'left' else '4')
|
|
edge.set(qn('w:space'), '6' if edge_name == 'left' else '2')
|
|
edge.set(qn('w:color'), color)
|
|
pBdr.append(edge)
|
|
pPr.append(pBdr)
|
|
r = p.add_run(code_text)
|
|
r.font.name = 'Consolas'
|
|
r.font.size = Pt(8)
|
|
r.font.color.rgb = RGBColor(0x1F, 0x29, 0x37)
|
|
|
|
def _add_remediation_docx(doc, remediation_text):
|
|
"""Add remediation text with code blocks and section headers, matching HTML output."""
|
|
# Split into paragraphs first (double newline), then process each
|
|
paragraphs = _re.split(r'\n{2,}', remediation_text)
|
|
for para_text in paragraphs:
|
|
para_text = para_text.strip()
|
|
if not para_text:
|
|
continue
|
|
|
|
lines = para_text.split('\n')
|
|
|
|
# Check if entire paragraph is code
|
|
non_empty = [l.strip() for l in lines if l.strip()]
|
|
if non_empty and all(_is_code_line(l) for l in non_empty):
|
|
_add_code_block(doc, '\n'.join(non_empty))
|
|
continue
|
|
|
|
# Mixed content: process line by line
|
|
code_buffer = []
|
|
text_buffer = []
|
|
|
|
def flush_code():
|
|
if code_buffer:
|
|
_add_code_block(doc, '\n'.join(code_buffer))
|
|
code_buffer.clear()
|
|
|
|
def flush_text():
|
|
if text_buffer:
|
|
joined = ' '.join(text_buffer)
|
|
_add_body_para(doc, joined, size=Pt(9), space_after=Pt(6))
|
|
text_buffer.clear()
|
|
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if not stripped:
|
|
continue
|
|
|
|
if _is_section_header(stripped):
|
|
flush_text()
|
|
flush_code()
|
|
p = doc.add_paragraph()
|
|
p.paragraph_format.space_before = Pt(8)
|
|
p.paragraph_format.space_after = Pt(4)
|
|
r = p.add_run(stripped)
|
|
r.bold = True
|
|
r.font.size = Pt(10)
|
|
r.font.name = 'Calibri'
|
|
r.font.color.rgb = BODY_TEXT
|
|
elif _is_code_line(stripped):
|
|
flush_text()
|
|
code_buffer.append(stripped)
|
|
else:
|
|
flush_code()
|
|
# If line ends with sentence punctuation, flush as separate paragraph
|
|
text_buffer.append(stripped)
|
|
if stripped.endswith(('.', ':', '!', '?', ';')):
|
|
flush_text()
|
|
|
|
flush_code()
|
|
flush_text()
|
|
|
|
def _result_box(doc, tenancy_name, is_compliant, findings="0", total="0"):
|
|
"""Add a result box with colored background and left border (matching PDF)."""
|
|
p = doc.add_paragraph()
|
|
pPr = p._p.get_or_add_pPr()
|
|
# Background shading
|
|
pShd = OxmlElement('w:shd')
|
|
pShd.set(qn('w:val'), 'clear')
|
|
pShd.set(qn('w:color'), 'auto')
|
|
pShd.set(qn('w:fill'), 'F0FDF4' if is_compliant else 'FEF2F2')
|
|
pPr.append(pShd)
|
|
# Left border
|
|
pBdr = OxmlElement('w:pBdr')
|
|
left_b = OxmlElement('w:left')
|
|
left_b.set(qn('w:val'), 'single')
|
|
left_b.set(qn('w:sz'), '18')
|
|
left_b.set(qn('w:space'), '6')
|
|
left_b.set(qn('w:color'), '0F7B0F' if is_compliant else 'C00000')
|
|
pBdr.append(left_b)
|
|
pPr.append(pBdr)
|
|
# Padding via indentation
|
|
pInd = OxmlElement('w:ind')
|
|
pInd.set(qn('w:left'), '120')
|
|
pPr.append(pInd)
|
|
p.paragraph_format.space_before = Pt(8)
|
|
p.paragraph_format.space_after = Pt(8)
|
|
|
|
run = p.add_run("Result: ")
|
|
run.bold = True
|
|
run.font.size = Pt(10)
|
|
run.font.color.rgb = BODY_TEXT
|
|
run.font.name = 'Calibri'
|
|
run = p.add_run(f"Tenancy {tenancy_name}: ")
|
|
run.bold = True
|
|
run.font.size = Pt(10)
|
|
run.font.color.rgb = BODY_TEXT
|
|
run.font.name = 'Calibri'
|
|
|
|
if is_compliant:
|
|
run = p.add_run("Compliant")
|
|
run.font.color.rgb = GREEN
|
|
run.bold = True
|
|
run.font.size = Pt(10)
|
|
run.font.name = 'Calibri'
|
|
else:
|
|
detail = f" ({findings} of {total} items)" if findings and total else ""
|
|
run = p.add_run(f"Non-Compliant{detail}")
|
|
run.font.color.rgb = RED
|
|
run.bold = True
|
|
run.font.size = Pt(10)
|
|
run.font.name = 'Calibri'
|
|
return p
|
|
|
|
doc = Document()
|
|
|
|
# ── Global styles ──
|
|
style = doc.styles['Normal']
|
|
style.font.name = 'Calibri'
|
|
style.font.size = Pt(10)
|
|
style.font.color.rgb = BODY_TEXT
|
|
style.paragraph_format.space_after = Pt(4)
|
|
style.paragraph_format.line_spacing = 1.15
|
|
for i in range(1, 4):
|
|
hs = doc.styles[f'Heading {i}']
|
|
hs.font.color.rgb = ORACLE_RED
|
|
hs.font.name = 'Calibri'
|
|
doc.styles['Heading 1'].font.size = Pt(22)
|
|
doc.styles['Heading 1'].font.bold = True
|
|
doc.styles['Heading 2'].font.size = Pt(16)
|
|
doc.styles['Heading 3'].font.size = Pt(12)
|
|
|
|
# ── Page size A4 + Margins ──
|
|
sect = doc.sections[0]
|
|
sect.page_width = Cm(21.0)
|
|
sect.page_height = Cm(29.7)
|
|
sect.top_margin = Cm(2.5)
|
|
sect.bottom_margin = Cm(2.0)
|
|
sect.left_margin = Cm(2.5)
|
|
sect.right_margin = Cm(2.5)
|
|
|
|
# ── Header / Footer ──
|
|
hdr = sect.header
|
|
hdr.is_linked_to_previous = False
|
|
hp = hdr.paragraphs[0]
|
|
hp.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
r = hp.add_run("ORACLE")
|
|
r.font.color.rgb = ORACLE_RED
|
|
r.font.size = Pt(14)
|
|
r.bold = True
|
|
r.font.name = 'Calibri'
|
|
|
|
# Add decorative camouflage strip to header (floating, behind text, left edge)
|
|
try:
|
|
camo_bytes = _generate_camo_strip()
|
|
strip_w = int(1.0 * 360000) # ~1cm in EMU
|
|
strip_h = int(29.7 * 360000) # A4 height (297mm) in EMU
|
|
_add_floating_image_to_header(hdr, camo_bytes, strip_w, strip_h)
|
|
except Exception as e:
|
|
log.warning(f"DOCX: failed to add camo strip: {e}")
|
|
|
|
ftr = sect.footer
|
|
ftr.is_linked_to_previous = False
|
|
fp = ftr.paragraphs[0]
|
|
fp.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
r = fp.add_run("Confidential \u2013 Oracle Restricted")
|
|
r.font.size = Pt(8)
|
|
r.font.color.rgb = MUTED
|
|
r.font.name = 'Calibri'
|
|
|
|
# ═══════════════════════════════════════════════
|
|
# ── COVER PAGE ──
|
|
# ═══════════════════════════════════════════════
|
|
|
|
# Spacer to push title to center of page
|
|
for _ in range(10):
|
|
sp = doc.add_paragraph()
|
|
sp.paragraph_format.space_before = Pt(0)
|
|
sp.paragraph_format.space_after = Pt(0)
|
|
sp.paragraph_format.line_spacing = 1.0
|
|
|
|
# Title — centered, serif font
|
|
p = doc.add_paragraph()
|
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
p.paragraph_format.space_before = Pt(0)
|
|
p.paragraph_format.space_after = Pt(0)
|
|
r = p.add_run("CIS Foundations")
|
|
r.font.size = Pt(36)
|
|
r.font.color.rgb = TITLE_DARK
|
|
r.font.name = 'Georgia'
|
|
r.bold = False
|
|
|
|
p = doc.add_paragraph()
|
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
p.paragraph_format.space_before = Pt(0)
|
|
p.paragraph_format.space_after = Pt(12)
|
|
r = p.add_run("Benchmark Assessment")
|
|
r.font.size = Pt(36)
|
|
r.font.color.rgb = TITLE_DARK
|
|
r.font.name = 'Georgia'
|
|
r.bold = False
|
|
|
|
# Subtitle — centered
|
|
p = doc.add_paragraph()
|
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
p.paragraph_format.space_before = Pt(8)
|
|
p.paragraph_format.space_after = Pt(8)
|
|
r = p.add_run(f"{tenancy} \u2013 Oracle Cloud Infrastructure")
|
|
r.font.size = Pt(13)
|
|
r.font.color.rgb = SUBTITLE_GRAY
|
|
r.font.name = 'Calibri'
|
|
|
|
# Extra spacer before metadata
|
|
for _ in range(6):
|
|
sp = doc.add_paragraph()
|
|
sp.paragraph_format.space_before = Pt(0)
|
|
sp.paragraph_format.space_after = Pt(0)
|
|
sp.paragraph_format.line_spacing = 1.0
|
|
|
|
# Metadata — identical to PDF cover
|
|
def _meta_line(doc, text, bold_prefix=None, sz=Pt(10)):
|
|
p = doc.add_paragraph()
|
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
p.paragraph_format.space_before = Pt(1)
|
|
p.paragraph_format.space_after = Pt(1)
|
|
p.paragraph_format.line_spacing = 1.3
|
|
if bold_prefix:
|
|
r = p.add_run(f"{bold_prefix} ")
|
|
r.bold = True
|
|
r.font.size = sz
|
|
r.font.color.rgb = BODY_TEXT
|
|
r.font.name = 'Calibri'
|
|
r = p.add_run(text)
|
|
else:
|
|
r = p.add_run(text)
|
|
r.font.size = sz
|
|
r.font.color.rgb = SUBTITLE_GRAY
|
|
r.font.name = 'Calibri'
|
|
|
|
_meta_line(doc, tenancy, bold_prefix="Tenancy:")
|
|
_meta_line(doc, f"{month_year}, Version [1.0]", sz=Pt(9))
|
|
if extract_date:
|
|
_meta_line(doc, extract_date, bold_prefix="Extract date:", sz=Pt(9))
|
|
_meta_line(doc, f"Copyright \u00A9 {now.year}, Oracle and/or its affiliates", sz=Pt(9))
|
|
_meta_line(doc, "Confidential \u2013 Oracle Restricted", sz=Pt(9))
|
|
|
|
doc.add_page_break()
|
|
|
|
# ═══════════════════════════════════════════════
|
|
# ── DISCLAIMER ──
|
|
# ═══════════════════════════════════════════════
|
|
_add_heading_red(doc, "Disclaimer", level=1)
|
|
_add_body_para(doc,
|
|
'This document provides prescriptive guidance for establishing a secure baseline configuration '
|
|
'for the Oracle Cloud Infrastructure environment based on the CIS Oracle Cloud Infrastructure '
|
|
'Foundations Benchmark. The scope of this benchmark is to establish a base level of security for '
|
|
'anyone utilizing the included Oracle Cloud Infrastructure services. The benchmark is, however, not '
|
|
'an exhaustive list of all possible security configurations and architectures.',
|
|
space_after=Pt(8))
|
|
_add_body_para(doc,
|
|
'This document is provided "as is" for informational purposes only, without warranties of any kind, '
|
|
'whether express or implied. The findings and recommendations reflect the conditions observed at the '
|
|
'time of the assessment and do not guarantee the prevention of future security incidents or compliance outcomes.',
|
|
space_after=Pt(8))
|
|
|
|
# ═══════════════════════════════════════════════
|
|
# ── PROFILE DEFINITIONS ──
|
|
# ═══════════════════════════════════════════════
|
|
_add_heading_red(doc, "Profile Definitions", level=1)
|
|
p = doc.add_paragraph()
|
|
p.paragraph_format.line_spacing = 1.15
|
|
r = p.add_run("Level 1")
|
|
r.bold = True
|
|
r.font.name = 'Calibri'
|
|
r.font.color.rgb = BODY_TEXT
|
|
r = p.add_run(" \u2014 Items in this profile intend to be practical and prudent, provide a clear security benefit, "
|
|
"and not inhibit the utility of the technology beyond acceptable means.")
|
|
r.font.name = 'Calibri'
|
|
r.font.color.rgb = BODY_TEXT
|
|
|
|
p = doc.add_paragraph()
|
|
p.paragraph_format.line_spacing = 1.15
|
|
r = p.add_run("Level 2")
|
|
r.bold = True
|
|
r.font.name = 'Calibri'
|
|
r.font.color.rgb = BODY_TEXT
|
|
r = p.add_run(" \u2014 Items in this profile extend the Level 1 profile. Items in this profile exhibit one or more "
|
|
"of the following characteristics: are intended for environments or use cases where security is paramount, "
|
|
"act as defense in depth measure, and may negatively inhibit the utility or performance of the technology.")
|
|
r.font.name = 'Calibri'
|
|
r.font.color.rgb = BODY_TEXT
|
|
|
|
# ═══════════════════════════════════════════════
|
|
# ── TABLE OF CONTENTS ──
|
|
# ═══════════════════════════════════════════════
|
|
doc.add_page_break()
|
|
_add_heading_red(doc, "Table of Contents", level=1)
|
|
p = doc.add_paragraph()
|
|
r = p.add_run()
|
|
fld1 = OxmlElement('w:fldChar'); fld1.set(qn('w:fldCharType'), 'begin'); r._r.append(fld1)
|
|
inst = OxmlElement('w:instrText'); inst.set(qn('xml:space'), 'preserve'); inst.text = ' TOC \\o "1-3" \\h \\z \\u '; r._r.append(inst)
|
|
fld2 = OxmlElement('w:fldChar'); fld2.set(qn('w:fldCharType'), 'separate'); r._r.append(fld2)
|
|
ph = OxmlElement('w:r'); pht = OxmlElement('w:t'); pht.text = "Right-click and select 'Update Field' to generate TOC"; ph.append(pht); r._r.append(ph)
|
|
fld3 = OxmlElement('w:fldChar'); fld3.set(qn('w:fldCharType'), 'end'); r._r.append(fld3)
|
|
# Auto-update fields on open
|
|
settings = doc.settings.element
|
|
uf = OxmlElement('w:updateFields'); uf.set(qn('w:val'), 'true'); settings.append(uf)
|
|
|
|
doc.add_page_break()
|
|
|
|
# ═══════════════════════════════════════════════
|
|
# ── SECURITY OVERVIEW ──
|
|
# ═══════════════════════════════════════════════
|
|
_add_heading_red(doc, "Security Overview", level=1)
|
|
_add_body_para(doc,
|
|
'Oracle Cloud Infrastructure (OCI) is designed with security as a foundational pillar. '
|
|
'The CIS Oracle Cloud Infrastructure Foundations Benchmark provides prescriptive guidance for '
|
|
'establishing a secure baseline configuration. This assessment evaluates the tenancy against the '
|
|
'benchmark controls across key security domains including Identity and Access Management, Networking, '
|
|
'Logging and Monitoring, Object Storage, and other critical infrastructure services.',
|
|
space_after=Pt(8))
|
|
_add_body_para(doc,
|
|
'The following sections present a summary of findings by domain, a detailed benchmark recommendation '
|
|
'summary, and individual findings with remediation guidance for non-compliant controls.',
|
|
space_after=Pt(8))
|
|
|
|
doc.add_page_break()
|
|
|
|
# ═══════════════════════════════════════════════
|
|
# ── DOMAINS SUMMARY TABLE (data from compliance_data.json — identical to HTML) ──
|
|
# ═══════════════════════════════════════════════
|
|
_add_heading_red(doc, "CIS Security Assessment", level=1)
|
|
_add_body_para(doc, f"Number of controls per domain and non-compliant controls on tenancy {tenancy}:",
|
|
space_after=Pt(8), align=WD_ALIGN_PARAGRAPH.LEFT)
|
|
domain_data = [
|
|
(sec, str(sd["total"]), (str(sd["failed"]), True, RED), (str(sd["passed"]), True, GREEN))
|
|
for sec, sd in sections_data.items()
|
|
]
|
|
_styled_table(doc, ["DOMAINS", "TOTAL CONTROLS", "FAILED", "PASSED"], domain_data,
|
|
col_widths=[3.5, 1.2, 0.9, 0.9])
|
|
|
|
# Totals summary line
|
|
total_all = sum(sd["total"] for sd in sections_data.values())
|
|
total_passed = sum(sd["passed"] for sd in sections_data.values())
|
|
total_failed = total_all - total_passed
|
|
pct = round(total_passed / total_all * 100) if total_all else 0
|
|
p = doc.add_paragraph()
|
|
p.paragraph_format.space_before = Pt(10)
|
|
p.paragraph_format.space_after = Pt(4)
|
|
r = p.add_run(f"Total: {total_all} controls | ")
|
|
r.font.size = Pt(10)
|
|
r.font.color.rgb = BODY_TEXT
|
|
r.font.name = 'Calibri'
|
|
r = p.add_run(f"Passed: {total_passed} ({pct}%)")
|
|
r.font.color.rgb = GREEN
|
|
r.font.size = Pt(10)
|
|
r.bold = True
|
|
r.font.name = 'Calibri'
|
|
r = p.add_run(" | ")
|
|
r.font.size = Pt(10)
|
|
r.font.color.rgb = BODY_TEXT
|
|
r.font.name = 'Calibri'
|
|
r = p.add_run(f"Failed: {total_failed}")
|
|
r.font.color.rgb = RED
|
|
r.font.size = Pt(10)
|
|
r.bold = True
|
|
r.font.name = 'Calibri'
|
|
|
|
doc.add_page_break()
|
|
|
|
# ═══════════════════════════════════════════════
|
|
# ── BENCHMARK SUMMARY TABLE ──
|
|
# ═══════════════════════════════════════════════
|
|
_add_heading_red(doc, "CIS Benchmark Recommendation Summary", level=1)
|
|
bench_data = []
|
|
for r in filtered:
|
|
rec = r.get("Recommendation #", "")
|
|
title_txt = r.get("Title", "")
|
|
level = r.get("Level", "")
|
|
compliant = r.get("__compliant", False)
|
|
st_text = "OK" if compliant else "FAILED"
|
|
st_color = GREEN if compliant else RED
|
|
bench_data.append((rec, title_txt, f"L{level}" if level else "", (st_text, True, st_color)))
|
|
_styled_table(doc, ["Item", "Description", "Level", "Status"], bench_data,
|
|
col_widths=[0.6, 4.0, 0.5, 0.8])
|
|
|
|
doc.add_page_break()
|
|
|
|
# ═══════════════════════════════════════════════
|
|
# ── DETAILED FINDINGS ──
|
|
# ═══════════════════════════════════════════════
|
|
for sec_name, sd in sections_data.items():
|
|
_add_heading_red(doc, sec_name, level=1)
|
|
|
|
for r in sd["items"]:
|
|
compliant = r.get("__compliant", False)
|
|
rec = r.get("Recommendation #", "")
|
|
title_txt = r.get("Title", "")
|
|
level = r.get("Level", "")
|
|
findings = r.get("Findings", "0")
|
|
total = r.get("Total", "0")
|
|
csv_remediation = (r.get("Remediation", "") or "").strip()
|
|
rag_data = r.get("__rag_remediation", {})
|
|
if isinstance(rag_data, str):
|
|
rag_data = {"description": "", "rationale": "", "remediation": rag_data}
|
|
rag_desc = (rag_data.get("description", "") or "").strip() if isinstance(rag_data, dict) else ""
|
|
rag_rationale = (rag_data.get("rationale", "") or "").strip() if isinstance(rag_data, dict) else ""
|
|
rag_rem = (rag_data.get("remediation", "") or "").strip() if isinstance(rag_data, dict) else ""
|
|
|
|
# Recommendation heading in Oracle Red
|
|
_add_heading_red(doc, f"{rec} {title_txt}", level=2)
|
|
|
|
# Level badge
|
|
if level:
|
|
p = doc.add_paragraph()
|
|
p.paragraph_format.space_before = Pt(0)
|
|
p.paragraph_format.space_after = Pt(4)
|
|
r_run = p.add_run(f" Level {level} ")
|
|
r_run.font.size = Pt(8)
|
|
r_run.bold = True
|
|
r_run.font.color.rgb = MUTED
|
|
r_run.font.name = 'Calibri'
|
|
rPr = r_run._r.get_or_add_rPr()
|
|
shd = OxmlElement('w:shd')
|
|
shd.set(qn('w:val'), 'clear')
|
|
shd.set(qn('w:color'), 'auto')
|
|
shd.set(qn('w:fill'), 'E8E8E8')
|
|
rPr.append(shd)
|
|
|
|
# Result box with colored background and left border
|
|
_result_box(doc, tenancy_name, compliant, findings, total)
|
|
|
|
# Description (from RAG — same as HTML)
|
|
if not compliant and rag_desc:
|
|
_add_heading_red(doc, "Description:", level=3)
|
|
_add_body_para(doc, rag_desc, size=Pt(9), space_after=Pt(4))
|
|
|
|
# Rationale (from RAG — same as HTML)
|
|
if not compliant and rag_rationale:
|
|
_add_heading_red(doc, "Rationale:", level=3)
|
|
_add_body_para(doc, rag_rationale, size=Pt(9), space_after=Pt(4))
|
|
|
|
# Remediation (from RAG or CSV — same as HTML, with code blocks)
|
|
remediation = rag_rem or csv_remediation
|
|
if not compliant and remediation:
|
|
_add_heading_red(doc, "Remediation:", level=3)
|
|
_add_remediation_docx(doc, remediation)
|
|
|
|
# Affected Resources table
|
|
if not compliant:
|
|
csv_filename = (r.get("Filename", "") or "").strip().strip('"').strip("'")
|
|
if csv_filename and csv_filename in file_path_map:
|
|
csv_file_path = Path(file_path_map[csv_filename])
|
|
if csv_file_path.exists():
|
|
try:
|
|
with open(csv_file_path, "r", encoding="utf-8") as cf:
|
|
csv_rows = list(csvmod.DictReader(cf))
|
|
if csv_rows:
|
|
cols = [c for c in csv_rows[0].keys() if c.lower() not in
|
|
("extract_date", "deep_link", "domain_deeplink", "defined_tags",
|
|
"domain_id", "external_identifier", "freeform_tags", "system_tags")][:5]
|
|
res_data = []
|
|
for crow in csv_rows[:10]:
|
|
row_vals = []
|
|
for c in cols:
|
|
val = crow.get(c, "")
|
|
if val.startswith("=HYPERLINK"):
|
|
m = _re.search(r',\s*"([^"]+)"', val)
|
|
val = m.group(1) if m else ""
|
|
if val.startswith("ocid1.") and len(val) > 30:
|
|
val = val[:20] + "..." + val[-8:]
|
|
if len(val) > 50:
|
|
val = val[:47] + "..."
|
|
row_vals.append(val)
|
|
res_data.append(tuple(row_vals))
|
|
if res_data:
|
|
_add_heading_red(doc, "Affected Resources:", level=3)
|
|
n = len(cols)
|
|
cw = [6.5 / n] * n
|
|
_styled_table(doc,
|
|
[c.replace("_", " ").title() for c in cols],
|
|
res_data,
|
|
col_widths=cw,
|
|
header_color=FINDINGS_HEADER_GREEN)
|
|
if len(csv_rows) > 10:
|
|
p = doc.add_paragraph(f"Showing 10 of {len(csv_rows)} resources.")
|
|
p.runs[0].font.size = Pt(8)
|
|
p.runs[0].font.color.rgb = MUTED
|
|
except Exception as e:
|
|
log.warning(f"DOCX: resource table error for {csv_filename}: {e}")
|
|
|
|
# ═══════════════════════════════════════════════
|
|
# ── OCI SERVICES (from same data as HTML report) ──
|
|
# ═══════════════════════════════════════════════
|
|
try:
|
|
if oci_services:
|
|
svc_data = []
|
|
for svc in oci_services:
|
|
st = svc.get("status", "WARNING")
|
|
desc = svc.get("description", "")
|
|
st_color = GREEN if st == "OK" else ORANGE
|
|
svc_data.append(((st, True, st_color), svc["service"], desc))
|
|
|
|
doc.add_page_break()
|
|
_add_heading_red(doc, "OCI SERVICES", level=1)
|
|
_add_heading_red(doc, "Recommendations:", level=2)
|
|
_add_body_para(doc,
|
|
"The recommended services are already enabled. This way we recommend further development of their usage:",
|
|
space_after=Pt(8), align=WD_ALIGN_PARAGRAPH.LEFT)
|
|
_styled_table(doc, ["STATUS", "SERVICE", "Description"], svc_data,
|
|
col_widths=[0.8, 2.0, 3.7])
|
|
|
|
# Service detail descriptions
|
|
for svc in oci_services:
|
|
info = _OCI_SERVICE_DESCRIPTIONS.get(svc["service"], {})
|
|
if not info:
|
|
continue
|
|
_add_heading_red(doc, info.get("title", svc["service"]), level=2)
|
|
desc = info.get("description", "")
|
|
for para in desc.split("\n"):
|
|
para = para.strip()
|
|
if para:
|
|
_add_body_para(doc, para, space_after=Pt(4))
|
|
rec_text = info.get("recommendation", "")
|
|
if rec_text:
|
|
p = doc.add_paragraph()
|
|
p.paragraph_format.line_spacing = 1.15
|
|
r = p.add_run("Recommendation: ")
|
|
r.bold = True
|
|
r.font.name = 'Calibri'
|
|
r.font.color.rgb = BODY_TEXT
|
|
r = p.add_run(rec_text)
|
|
r.font.name = 'Calibri'
|
|
r.font.color.rgb = BODY_TEXT
|
|
# Cloud Guard recommendations table
|
|
cg_recs = svc.get("recommendations", [])
|
|
if cg_recs:
|
|
cg_data = [(cr["name"], (str(cr["count"]), True, None)) for cr in cg_recs]
|
|
_styled_table(doc, ["Recommendations", "Total"], cg_data,
|
|
col_widths=[5.5, 0.8])
|
|
except Exception as e:
|
|
log.warning(f"DOCX: OCI services section error: {e}")
|
|
|
|
# ═══════════════════════════════════════════════
|
|
# ── BACK PAGE ──
|
|
# ═══════════════════════════════════════════════
|
|
doc.add_page_break()
|
|
_add_heading_red(doc, "Connect with us", level=1)
|
|
|
|
_add_body_para(doc,
|
|
"Call +1.800.ORACLE1 or visit oracle.com.\nOutside North America, find your local office at: oracle.com/contact.",
|
|
space_after=Pt(12), align=WD_ALIGN_PARAGRAPH.LEFT)
|
|
|
|
p = doc.add_paragraph()
|
|
p.paragraph_format.space_before = Pt(8)
|
|
p.paragraph_format.space_after = Pt(4)
|
|
for icon, link in [("Blog", "blogs.oracle.com"), ("Facebook", "facebook.com/oracle"), ("Twitter", "twitter.com/oracle")]:
|
|
r = p.add_run(f" {icon}: {link} ")
|
|
r.font.size = Pt(9)
|
|
r.font.color.rgb = MUTED
|
|
r.font.name = 'Calibri'
|
|
|
|
p = doc.add_paragraph()
|
|
p.paragraph_format.space_before = Pt(24)
|
|
p.paragraph_format.space_after = Pt(0)
|
|
r = p.add_run(
|
|
f"Copyright \u00A9 {now.year}, Oracle and/or its affiliates. This document is provided for information purposes only, "
|
|
"and the contents hereof are subject to change without notice. This document is not warranted to be error-free, "
|
|
"nor subject to any other warranties or conditions, whether expressed orally or implied in law. "
|
|
"Oracle, Java, MySQL, and NetSuite are registered trademarks of Oracle and/or its affiliates."
|
|
)
|
|
r.font.size = Pt(7)
|
|
r.font.color.rgb = MUTED
|
|
r.font.name = 'Calibri'
|
|
|
|
buf = _io.BytesIO()
|
|
doc.save(buf)
|
|
return buf.getvalue()
|
|
|
|
|