- Professional Oracle-format compliance report with RAG remediation from ADB vector store - React 19 SPA at /app/ (16 pages, TypeScript, Vite, Zustand, i18n pt/en 625 keys) - Report delete endpoint, embed individual report files into vector store - Terraform ZIP download (client-side, pure JS) - Explorer silent polling during start/stop (no flickering) - HTML report and compliance report sections start minimized - Token auth fix for compliance report CSV download links
657 lines
24 KiB
JavaScript
657 lines
24 KiB
JavaScript
// n8n Code node (JavaScript)
|
||
// Relatório HTML -> PDF (Gotenberg):
|
||
// - Capa
|
||
// - Purpose + Disclaimer juntos
|
||
// - TOC em nova página (listando cada recomendação: "Rec — Title")
|
||
// - Executive Summary em nova página (se existir)
|
||
// - Findings Overview em nova página (tabela DOMAINS / TOTAL CONTROLS / FAILED / PASSED)
|
||
// - Seções (cards): Asset Management começa em nova página
|
||
//
|
||
// Seções:
|
||
// - "Logging and Monitoring1" -> "Logging and Monitoring"
|
||
// - Remove seção "Storage" genérica; mantém apenas:
|
||
// Storage - Block Volumes / Storage - File Storage Service / Storage - Object Storage
|
||
//
|
||
// Status:
|
||
// - Compliant = Yes
|
||
// - Non-compliant = tudo que NÃO for Yes (inclui Unknown)
|
||
//
|
||
// Deduplicação forte para evitar duplicatas vindas do fluxo/merge.
|
||
//
|
||
// Alteração pedida AGORA:
|
||
// - Remove o valor que aparece logo abaixo do gráfico (ex.: "100%").
|
||
// Ou seja: mantém o gráfico + o % dentro do próprio gráfico (lado esquerdo), mas remove a linha extra.
|
||
|
||
const items = $input.all();
|
||
|
||
// ====== INPUT SPLIT ======
|
||
const summaryTexts = items
|
||
.map(i => i.json?.output)
|
||
.filter(v => typeof v === "string")
|
||
.map(s => String(s).trim())
|
||
.filter(Boolean);
|
||
|
||
const allRowsRaw = items
|
||
.map(i => i.json?.output)
|
||
.filter(v => v && typeof v === "object" && !Array.isArray(v));
|
||
|
||
// Ajuste se tenancyName vier em outro campo:
|
||
const tenancyName = ($json?.tenancyName || $json?.tenancy || "Nome do Tenancy");
|
||
const version = "1.0";
|
||
|
||
// Texto fixo
|
||
const PURPOSE_STATEMENT_TEXT =
|
||
"The purpose of the cloud security assessment is to comprehensively evaluate the organization's security posture in its cloud environment, identifying vulnerabilities and gaps that could compromise data integrity, confidentiality, and availability. This assessment aims to enhance information security maturity, strengthen defenses against cyber threats, ensure compliance with regulations, and foster a culture of security awareness. Through periodic assessments, the organization proactively mitigates risks, ensuring continuous security for cloud-configured assets and maintaining stakeholders' trust in data protection capabilities.";
|
||
|
||
const DISCLAIMER_TEXT =
|
||
"This document in any form, software or printed matter, contains proprietary information that is the exclusive property of Oracle. Your access to and use of this confidential material is subject to the terms and conditions of your Oracle software license and service agreement, which has been executed and with which you agree to comply. This document and information contained herein may not be disclosed, copied, reproduced or distributed to anyone outside Oracle without prior written consent of Oracle. This document is not part of your license agreement, nor can it be incorporated into any contractual agreement with Oracle or its subsidiaries or affiliates.\n\nThis document is for informational purposes only and is intended solely to assist you in planning for the implementation and upgrade of the product features described. It is not a commitment to deliver any material, code, or functionality, and should not be relied upon in making purchasing decisions. The development, release, timing, and pricing of any features or functionality described in this document remains at the sole discretion of Oracle. Due to the nature of the product architecture, it may not be possible to safely include all features described in this document without risking significant destabilization of the code.";
|
||
|
||
// ====== HELPERS ======
|
||
function escapeHtml(value) {
|
||
return String(value ?? "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
function normStr(v) {
|
||
return String(v ?? "").trim();
|
||
}
|
||
|
||
// Apenas Yes/No (Unknown vira No)
|
||
function normStatus2(v) {
|
||
const s = normStr(v).toLowerCase();
|
||
if (["yes", "true", "compliant", "ok"].includes(s)) return "Yes";
|
||
return "No";
|
||
}
|
||
|
||
function parsePercent(value) {
|
||
const s = normStr(value);
|
||
if (!s) return null;
|
||
const m = s.replace(",", ".").match(/(\d+(\.\d+)?)/);
|
||
if (!m) return null;
|
||
const n = Number(m[1]);
|
||
if (!Number.isFinite(n)) return null;
|
||
return Math.max(0, Math.min(100, n));
|
||
}
|
||
|
||
function parseFindingsNumber(value) {
|
||
const s = normStr(value);
|
||
if (!s) return null;
|
||
const m = s.replaceAll(",", "").match(/\d+/);
|
||
if (!m) return null;
|
||
const n = Number(m[0]);
|
||
return Number.isFinite(n) ? n : null;
|
||
}
|
||
|
||
function groupBy(arr, keyFn) {
|
||
const map = new Map();
|
||
for (const x of arr) {
|
||
const k = keyFn(x);
|
||
if (!map.has(k)) map.set(k, []);
|
||
map.get(k).push(x);
|
||
}
|
||
return map;
|
||
}
|
||
|
||
// Ordenação "1.2" < "1.10" < "2.1"
|
||
function sortRecommendation(a, b) {
|
||
const pa = String(a ?? "").split(".").map(x => Number(x));
|
||
const pb = String(b ?? "").split(".").map(x => Number(x));
|
||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||
const da = pa[i] ?? 0;
|
||
const db = pb[i] ?? 0;
|
||
if (da !== db) return da - db;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
function monthYearPt(date = new Date()) {
|
||
const months = [
|
||
"Janeiro", "Fevereiro", "Março", "Abril", "Maio", "Junho",
|
||
"Julho", "Agosto", "Setembro", "Outubro", "Novembro", "Dezembro",
|
||
];
|
||
return `${months[date.getMonth()]}, ${date.getFullYear()}`;
|
||
}
|
||
|
||
function progressBar(pct) {
|
||
if (pct === null) {
|
||
return `<div class="pct-row"><div class="pct mono muted">N/A</div></div>`;
|
||
}
|
||
const width = Math.max(0, Math.min(100, pct));
|
||
const cls = pct <= 20 ? "bad" : pct <= 60 ? "warn" : "ok";
|
||
return `
|
||
<div class="pct-row">
|
||
<div class="pct mono">${pct.toFixed(0)}%</div>
|
||
<div class="bar"><div class="fill ${cls}" style="width:${width}%"></div></div>
|
||
</div>
|
||
`.trim();
|
||
}
|
||
|
||
function field(label, value, opts = {}) {
|
||
const v = value === undefined || value === null ? "" : String(value);
|
||
if (!opts.showWhenEmpty && !normStr(v)) return "";
|
||
return `
|
||
<div class="field">
|
||
<div class="label">${escapeHtml(label)}</div>
|
||
<div class="value">${escapeHtml(v)}</div>
|
||
</div>
|
||
`.trim();
|
||
}
|
||
|
||
function pill(label, value) {
|
||
const v = normStr(value);
|
||
if (!v) return "";
|
||
return `<span class="pill"><span class="pill-k">${escapeHtml(label)}:</span> ${escapeHtml(v)}</span>`;
|
||
}
|
||
|
||
function badge2(status2) {
|
||
return status2 === "Yes"
|
||
? `<span class="badge yes">Compliant</span>`
|
||
: `<span class="badge no">Non-compliant</span>`;
|
||
}
|
||
|
||
function slugify(s) {
|
||
return String(s ?? "")
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[\u0300-\u036f]/g, "")
|
||
.replace(/[^\w\s-]/g, "")
|
||
.replace(/\s+/g, "-")
|
||
.replace(/-+/g, "-")
|
||
.replace(/^-|-$/g, "");
|
||
}
|
||
|
||
function sectionId(sectionName) {
|
||
return `section-${slugify(sectionName) || "unspecified"}`;
|
||
}
|
||
|
||
// ====== SECTION NORMALIZATION + FILTERING ======
|
||
const ALLOWED_SECTIONS = new Set([
|
||
"Asset Management",
|
||
"Compute",
|
||
"Identity and Access Management",
|
||
"Logging and Monitoring",
|
||
"Networking",
|
||
"Storage - Block Volumes",
|
||
"Storage - File Storage Service",
|
||
"Storage - Object Storage",
|
||
]);
|
||
|
||
function normalizeSectionName(raw) {
|
||
let s = String(raw ?? "").trim();
|
||
s = s.replace(/\s+/g, " ");
|
||
|
||
if (/^Logging and Monitoring\s*\d*$/i.test(s)) return "Logging and Monitoring";
|
||
if (/^Storage$/i.test(s)) return null;
|
||
|
||
if (/^Storage\s*-\s*Block Volumes$/i.test(s)) return "Storage - Block Volumes";
|
||
if (/^Storage\s*-\s*File Storage Service$/i.test(s)) return "Storage - File Storage Service";
|
||
if (/^Storage\s*-\s*Object Storage$/i.test(s)) return "Storage - Object Storage";
|
||
|
||
return s || null;
|
||
}
|
||
|
||
// ====== DEDUPE ======
|
||
function makeDedupKey(row, normalizedSection) {
|
||
const section = normalizedSection || "Unspecified";
|
||
const rec = normStr(row["Recommendation #"]) || "no-rec";
|
||
const title = normStr(row["Title"]) || "no-title";
|
||
return `${section}||${rec}||${title}`.toLowerCase();
|
||
}
|
||
|
||
const dedupMap = new Map();
|
||
for (const r of allRowsRaw) {
|
||
const sec = normalizeSectionName(r["Section"]);
|
||
if (!sec) continue;
|
||
if (!ALLOWED_SECTIONS.has(sec)) continue;
|
||
|
||
const key = makeDedupKey(r, sec);
|
||
if (!dedupMap.has(key)) {
|
||
dedupMap.set(key, { ...r, Section: sec });
|
||
}
|
||
}
|
||
const allRows = [...dedupMap.values()];
|
||
|
||
// ====== ENRICH ======
|
||
const now = new Date();
|
||
const monthYear = monthYearPt(now);
|
||
|
||
const enrichedAll = allRows.map(r => {
|
||
const pctRaw = r["Compliance Percentage Per Recommendation"];
|
||
const pct = parsePercent(pctRaw);
|
||
const findingsRaw = r["Findings"];
|
||
const findingsNum = parseFindingsNumber(findingsRaw);
|
||
const status2 = normStatus2(r["Compliant"]);
|
||
|
||
const rec = normStr(r["Recommendation #"]) || "rec";
|
||
const section = normStr(r["Section"]) || "Unspecified";
|
||
const recIdVal = `rec-${slugify(section)}-${slugify(rec)}`;
|
||
|
||
return {
|
||
...r,
|
||
__status2: status2,
|
||
__pct: pct,
|
||
__pctRaw: pctRaw,
|
||
__findingsNum: findingsNum,
|
||
__findingsRaw: findingsRaw,
|
||
__recId: recIdVal,
|
||
};
|
||
});
|
||
|
||
const bySection = groupBy(enrichedAll, r => normStr(r["Section"]) || "Unspecified");
|
||
|
||
const SECTION_ORDER = [
|
||
"Asset Management",
|
||
"Compute",
|
||
"Identity and Access Management",
|
||
"Logging and Monitoring",
|
||
"Networking",
|
||
"Storage - Block Volumes",
|
||
"Storage - File Storage Service",
|
||
"Storage - Object Storage",
|
||
];
|
||
|
||
const sectionEntries = SECTION_ORDER
|
||
.map(name => {
|
||
const rows = bySection.get(name) || [];
|
||
const total = rows.length;
|
||
const passed = rows.filter(r => r.__status2 === "Yes").length;
|
||
const failed = total - passed;
|
||
return { name, id: sectionId(name), total, passed, failed, rows };
|
||
})
|
||
.filter(s => s.total > 0);
|
||
|
||
const totalAll = enrichedAll.length;
|
||
const counts = enrichedAll.reduce((acc, r) => {
|
||
acc[r.__status2] = (acc[r.__status2] ?? 0) + 1;
|
||
return acc;
|
||
}, { Yes: 0, No: 0 });
|
||
|
||
const extractDates = [...new Set(allRows.map(r => normStr(r["extract_date"])).filter(Boolean))];
|
||
const extractDateCover = extractDates.length ? extractDates[0] : "";
|
||
|
||
// ====== TOC: Section + each recommendation ======
|
||
const toc = [];
|
||
toc.push({ title: "Purpose statement", href: "#purpose", right: "" });
|
||
toc.push({ title: "Disclaimer", href: "#disclaimer", right: "" });
|
||
if (summaryTexts.length) toc.push({ title: "Executive Summary", href: "#exec-summary", right: "" });
|
||
toc.push({ title: "Findings Overview", href: "#findings-overview", right: "" });
|
||
|
||
for (const s of sectionEntries) {
|
||
toc.push({ title: s.name, href: `#${s.id}`, right: String(s.total), level: 0 });
|
||
|
||
const rowsSorted = [...s.rows].sort((a, b) =>
|
||
sortRecommendation(a["Recommendation #"], b["Recommendation #"])
|
||
);
|
||
|
||
for (const r of rowsSorted) {
|
||
const rec = normStr(r["Recommendation #"]) || "-";
|
||
const title = normStr(r["Title"]) || "-";
|
||
toc.push({
|
||
title: `${rec} — ${title}`,
|
||
href: `#${r.__recId}`,
|
||
right: "",
|
||
level: 1,
|
||
});
|
||
}
|
||
}
|
||
|
||
function tocLine({ title, href, right, level = 0 }) {
|
||
const indentClass = level === 1 ? "toc-l1" : "toc-l0";
|
||
const safeRight = right ? escapeHtml(right) : " ";
|
||
return `
|
||
<div class="toc-line ${indentClass}">
|
||
<a class="toc-link" href="${escapeHtml(href)}">${escapeHtml(title)}</a>
|
||
<span class="toc-leader" aria-hidden="true"></span>
|
||
<span class="toc-num">${safeRight}</span>
|
||
</div>
|
||
`.trim();
|
||
}
|
||
|
||
// ====== HTML ======
|
||
const html = `
|
||
<!doctype html>
|
||
<html lang="pt-br">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>Oracle Cloud Security Assessment</title>
|
||
|
||
<style>
|
||
@page { size: A4; margin: 14mm; }
|
||
html, body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||
|
||
:root{
|
||
--text:#0f172a;
|
||
--muted:#64748b;
|
||
--line:#e2e8f0;
|
||
--soft:#f8fafc;
|
||
--oracle-red:#ff0000;
|
||
|
||
--header-blue:#2f5597;
|
||
--row-alt:#e8f0f7;
|
||
--fail:#c00000;
|
||
--pass:#0f7b0f;
|
||
|
||
--bad:#ef4444;
|
||
--warn:#f59e0b;
|
||
--ok:#22c55e;
|
||
}
|
||
|
||
body{ margin:0; font-family: Arial, Helvetica, sans-serif; color: var(--text); background: #ffffff; }
|
||
.wrap{ max-width: 980px; margin: 0 auto; }
|
||
a{ color: inherit; text-decoration: none; }
|
||
a:hover{ text-decoration: underline; }
|
||
.page-break-before{ break-before: page; page-break-before: always; }
|
||
|
||
/* CAPA */
|
||
.cover{
|
||
position: relative;
|
||
min-height: calc(297mm - 28mm);
|
||
page-break-after: always;
|
||
background: #fff;
|
||
}
|
||
.cover-oracle{ position: absolute; top: 0; left: 0; font-weight: 700; letter-spacing: 0.6px; color: var(--oracle-red); font-size: 18px; }
|
||
.cover-rect{ position: absolute; top: 26mm; left: 6mm; right: 6mm; height: 82mm; border: 1px solid #9ca3af; }
|
||
.cover-title{ position: absolute; left: 0; bottom: 62mm; font-size: 44px; line-height: 1.05; font-weight: 500; color: #111827; max-width: 165mm; }
|
||
.cover-subtitle{ position: absolute; left: 0; bottom: 52mm; font-size: 12px; color: #111827; }
|
||
.cover-meta{ position: absolute; left: 0; bottom: 18mm; font-size: 11px; color: #111827; line-height: 1.45; }
|
||
|
||
/* PD */
|
||
.pd{
|
||
border:1px solid var(--line);
|
||
background: #fff;
|
||
border-radius: 12px;
|
||
padding: 12px 14px;
|
||
margin: 0 0 12px;
|
||
break-inside: avoid;
|
||
page-break-inside: avoid;
|
||
}
|
||
.pd h2{ font-size: 13px; margin: 0 0 6px; color: var(--oracle-red); }
|
||
.pd p{
|
||
margin: 0;
|
||
font-size: 11px;
|
||
line-height: 1.45;
|
||
white-space: pre-wrap;
|
||
overflow-wrap: anywhere;
|
||
word-break: break-word;
|
||
text-align: justify;
|
||
text-justify: inter-word;
|
||
}
|
||
.pd .spacer{ height: 8px; }
|
||
|
||
/* TOC */
|
||
.toc{ background: #fff; margin: 0 0 12px; padding: 0; }
|
||
.toc-brand{ color: var(--oracle-red); font-weight: 700; letter-spacing: .6px; font-size: 16px; margin: 0 0 6px; }
|
||
.toc-title{ color: var(--oracle-red); font-size: 13px; margin: 0 0 10px; }
|
||
.toc-rule{ height: 1px; background: #cbd5e1; margin: 0 0 10px; }
|
||
.toc-lines{ display: flex; flex-direction: column; gap: 6px; font-size: 12px; }
|
||
.toc-line{ display: grid; grid-template-columns: auto 1fr auto; align-items: baseline; gap: 10px; }
|
||
.toc-l0{ padding-left: 0; font-weight: 600; }
|
||
.toc-l1{ padding-left: 14px; font-weight: 400; color: #111827; }
|
||
.toc-link{ min-width: 0; overflow-wrap: anywhere; word-break: break-word; }
|
||
.toc-leader{ border-bottom: 1px dotted #94a3b8; transform: translateY(-2px); }
|
||
.toc-num{ width: 28px; text-align: right; color: #111827; font-variant-numeric: tabular-nums; }
|
||
|
||
/* Summary */
|
||
.summary{ border:1px solid var(--line); background: #fff; border-radius: 12px; padding: 12px 14px; margin: 0 0 12px; }
|
||
.summary h2{ font-size: 14px; margin: 0 0 8px; }
|
||
.summary .sub{ font-size: 12px; color: var(--muted); line-height: 1.4; margin: 0 0 10px; }
|
||
.summary-text{
|
||
font-size: 12px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word;
|
||
color: #111827; padding: 10px 10px; border: 1px solid #f1f5f9; border-radius: 10px;
|
||
background: linear-gradient(180deg, #ffffff, var(--soft)); margin-top: 10px;
|
||
break-inside: avoid; page-break-inside: avoid; text-align: justify; text-justify: inter-word;
|
||
}
|
||
|
||
/* Findings Overview (tabela) */
|
||
.overview{
|
||
border:1px solid var(--line);
|
||
background:#fff;
|
||
border-radius: 12px;
|
||
padding: 12px 14px;
|
||
margin: 0 0 12px;
|
||
}
|
||
.overview h1{ font-size: 16px; margin: 0 0 10px; }
|
||
.overview-sub{ font-size: 12px; color: var(--muted); margin-bottom: 10px; }
|
||
.domains-table{
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
border: 1px solid #cbd5e1;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
font-size: 12px;
|
||
}
|
||
.domains-table thead th{
|
||
background: var(--header-blue);
|
||
color: #fff;
|
||
padding: 8px 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: .4px;
|
||
font-size: 11px;
|
||
border-right: 1px solid rgba(255,255,255,.2);
|
||
text-align: center;
|
||
}
|
||
.domains-table thead th:first-child{ text-align: left; }
|
||
.domains-table thead th:last-child{ border-right: none; }
|
||
.domains-table tbody td{
|
||
padding: 7px 10px;
|
||
border-top: 1px solid #cbd5e1;
|
||
border-right: 1px solid #cbd5e1;
|
||
vertical-align: middle;
|
||
}
|
||
.domains-table tbody td:last-child{ border-right: none; }
|
||
.domains-table tbody tr:nth-child(odd){ background: var(--row-alt); }
|
||
.dom{ font-weight: 600; }
|
||
.num{ text-align: center; font-variant-numeric: tabular-nums; }
|
||
.fail{ color: var(--fail); font-weight: 700; }
|
||
.pass{ color: var(--pass); font-weight: 700; }
|
||
|
||
/* Section + Cards */
|
||
.section{ margin-top: 14px; }
|
||
.section h2{ font-size: 14px; margin: 14px 0 10px; }
|
||
.group-title{ margin: 12px 0 8px; font-size: 13px; font-weight: 800; }
|
||
.group-title.yes{ color: #166534; }
|
||
.group-title.no{ color: #991b1b; }
|
||
|
||
.cards{ display:flex; flex-direction:column; gap: 10px; }
|
||
.card{
|
||
border: 1px solid var(--line);
|
||
border-radius: 14px;
|
||
background: #fff;
|
||
padding: 10px 12px;
|
||
break-inside: avoid;
|
||
page-break-inside: avoid;
|
||
overflow: hidden;
|
||
}
|
||
.card-head{
|
||
display:flex;
|
||
align-items:flex-start;
|
||
justify-content:space-between;
|
||
gap: 10px;
|
||
margin-bottom: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.card-head > div{ min-width: 0; }
|
||
.left{ flex: 2 1 420px; min-width: 0; }
|
||
.right{ flex: 1 1 240px; max-width: 320px; margin-left: auto; }
|
||
.card-title{ font-size: 13px; font-weight: 800; margin: 0; line-height: 1.3; overflow-wrap: anywhere; word-break: break-word; }
|
||
.meta-row{ margin-top: 6px; display:flex; flex-wrap:wrap; gap: 6px; align-items: center; }
|
||
|
||
.pill{ display:inline-block; padding: 2px 8px; border:1px solid var(--line); border-radius: 999px; font-size: 11px; color: var(--muted); background: #fff; white-space: nowrap; }
|
||
.pill-k{ font-weight: 700; color: var(--muted); }
|
||
|
||
.badge{ display:inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 800; border: 1px solid var(--line); white-space: nowrap; }
|
||
.badge.yes{ color:#166534; background: #dcfce7; border-color:#86efac; }
|
||
.badge.no{ color:#991b1b; background: #fee2e2; border-color:#fca5a5; }
|
||
|
||
.mono{ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||
.muted{ color: var(--muted); }
|
||
|
||
.field{ margin-top: 8px; }
|
||
.label{ font-size: 11px; font-weight: 800; color: var(--muted); margin-bottom: 3px; }
|
||
.value{
|
||
font-size: 12px; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word;
|
||
text-align: justify; text-justify: inter-word;
|
||
}
|
||
|
||
.pct-row{ display:flex; align-items:center; gap:10px; }
|
||
.pct{ width: 44px; text-align:right; font-size: 12px; }
|
||
.bar{ flex: 1; height: 10px; background: #eef2ff; border: 1px solid #e5e7eb; border-radius: 999px; overflow: hidden; }
|
||
.fill{ height: 100%; border-radius: 999px; }
|
||
.fill.bad{ background: var(--bad); }
|
||
.fill.warn{ background: var(--warn); }
|
||
.fill.ok{ background: var(--ok); }
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
<div class="wrap">
|
||
|
||
<!-- CAPA -->
|
||
<div class="cover">
|
||
<div class="cover-oracle">ORACLE</div>
|
||
<div class="cover-rect"></div>
|
||
|
||
<div class="cover-title">Oracle Cloud Security<br/>Assessment</div>
|
||
<div class="cover-subtitle">Tenancy – Oracle Cloud Infrastructure</div>
|
||
|
||
<div class="cover-meta">
|
||
<div><b>Tenancy:</b> ${escapeHtml(tenancyName)}</div>
|
||
<div>${escapeHtml(monthYear)}, Version [${escapeHtml(version)}]</div>
|
||
${extractDateCover ? `<div><b>Extract date:</b> ${escapeHtml(extractDateCover)}</div>` : ""}
|
||
<div>Copyright © 2025, Oracle and/or its affiliates</div>
|
||
<div>Confidential – Oracle Restricted</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Purpose + Disclaimer -->
|
||
<div class="pd" id="purpose">
|
||
<h2>Purpose Statement</h2>
|
||
<p>${escapeHtml(PURPOSE_STATEMENT_TEXT)}</p>
|
||
<div class="spacer"></div>
|
||
<h2 id="disclaimer">Disclaimer</h2>
|
||
<p>${escapeHtml(DISCLAIMER_TEXT)}</p>
|
||
</div>
|
||
|
||
<!-- TOC em nova página -->
|
||
<div class="toc page-break-before">
|
||
<div class="toc-brand">ORACLE</div>
|
||
<div class="toc-title">Table of contents</div>
|
||
<div class="toc-rule"></div>
|
||
<div class="toc-lines">
|
||
${toc.map(t => tocLine(t)).join("")}
|
||
</div>
|
||
</div>
|
||
|
||
${summaryTexts.length ? `
|
||
<!-- Executive Summary em nova página -->
|
||
<div class="summary page-break-before" id="exec-summary">
|
||
<h2>Executive Summary</h2>
|
||
<div class="sub">Conteúdo gerado dinamicamente pelo fluxo</div>
|
||
${summaryTexts.map(t => `<div class="summary-text">${escapeHtml(t)}</div>`).join("")}
|
||
</div>
|
||
` : ""}
|
||
|
||
<!-- Findings Overview em nova página -->
|
||
<div class="overview page-break-before" id="findings-overview">
|
||
<h1>Findings Overview</h1>
|
||
<div class="overview-sub">Resumo por domínio (Section)</div>
|
||
|
||
<table class="domains-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="text-align:left;">DOMAINS</th>
|
||
<th>TOTAL CONTROLS</th>
|
||
<th>FAILED</th>
|
||
<th>PASSED</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${sectionEntries.map(s => `
|
||
<tr>
|
||
<td class="dom">${escapeHtml(s.name)}</td>
|
||
<td class="num">${s.total}</td>
|
||
<td class="num fail">${s.failed}</td>
|
||
<td class="num pass">${s.passed}</td>
|
||
</tr>
|
||
`).join("")}
|
||
</tbody>
|
||
</table>
|
||
|
||
<div class="overview-sub" style="margin-top:10px;">
|
||
Total: <b>${totalAll}</b> • Passed: <b>${counts.Yes}</b> • Failed: <b>${counts.No}</b>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Seções -->
|
||
${sectionEntries.map(s => {
|
||
const yesRows = s.rows
|
||
.filter(r => r.__status2 === "Yes")
|
||
.sort((a, b) => sortRecommendation(a["Recommendation #"], b["Recommendation #"]));
|
||
|
||
const noRows = s.rows
|
||
.filter(r => r.__status2 === "No")
|
||
.sort((a, b) => sortRecommendation(a["Recommendation #"], b["Recommendation #"]));
|
||
|
||
const renderCards = (rows) => `
|
||
<div class="cards">
|
||
${rows.map(r => `
|
||
<div class="card" id="${escapeHtml(r.__recId)}">
|
||
<div class="card-head">
|
||
<div class="left">
|
||
<div class="card-title">
|
||
${escapeHtml(normStr(r["Recommendation #"]) || "-")} — ${escapeHtml(normStr(r["Title"]) || "-")}
|
||
</div>
|
||
<div class="meta-row">
|
||
${badge2(r.__status2)}
|
||
${pill("Section", r["Section"])}
|
||
${pill("Level", r["Level"])}
|
||
${pill("Findings", r.__findingsNum === null ? "N/A" : r.__findingsNum)}
|
||
${pill("CIS v8", r["CIS v8"])}
|
||
${pill("extract_date", r["extract_date"])}
|
||
${pill("Filename", r["Filename"])}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="right">
|
||
${progressBar(r.__pct)}
|
||
</div>
|
||
</div>
|
||
|
||
${field("Findings (raw)", r.__findingsRaw, { showWhenEmpty: true })}
|
||
${field("Remediation", r["Remediation"], { showWhenEmpty: true })}
|
||
${field("CCCS Guard Rail", r["CCCS Guard Rail"], { showWhenEmpty: false })}
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
`;
|
||
|
||
const sectionExtraClass = s.name === "Asset Management" ? "page-break-before" : "";
|
||
|
||
// Compliant primeiro, depois Non-compliant
|
||
return `
|
||
<div class="section ${sectionExtraClass}" id="${escapeHtml(s.id)}">
|
||
<h2>${escapeHtml(s.name)} <span class="muted">(${s.total})</span></h2>
|
||
|
||
${yesRows.length ? `
|
||
<div class="group-title yes">Compliant (${yesRows.length})</div>
|
||
${renderCards(yesRows)}
|
||
` : ""}
|
||
|
||
${noRows.length ? `
|
||
<div class="group-title no">Non-compliant (${noRows.length})</div>
|
||
${renderCards(noRows)}
|
||
` : ""}
|
||
</div>
|
||
`;
|
||
}).join("")}
|
||
|
||
</div>
|
||
</body>
|
||
</html>
|
||
`.trim();
|
||
|
||
return [{ json: { html } }]; |