983414f278
The playbook editor's input field is a single <input>, which drops the newlines of a pasted text: the whole description reached the script as one line starting with ## and rendered as a single heading. description and callout_text without a real line break now take the two characters \n as one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJu1jvaug4qY1kMgz8wx41
594 lines
37 KiB
Python
594 lines
37 KiB
Python
import colorsys, datetime, html, json, os, re, sys
|
|
|
|
# White ACKnowledge wordmark, 320x76 PNG (shown at 160x38). A data-URI image is
|
|
# turned into an inline attachment by exchange-send-mail and mail-sender; mail
|
|
# clients block SVG, hence the PNG.
|
|
LOGO_PNG_B64 = "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"
|
|
|
|
OUTER = "#55555a"
|
|
PAGE = "#1e1e1e"
|
|
HEADER = "#4a4a7c"
|
|
HEADER_RULE = "#5d5d8f"
|
|
PANEL = "#262626"
|
|
TEXT = "#e6e6e6"
|
|
MUTED = "#a9a9b8"
|
|
BLUE = "#4f9ae0"
|
|
BLUE_RULE = "#2f7fc8"
|
|
SIGNATURE = "#d8dcff"
|
|
WHITE = "#ffffff"
|
|
FONT = "'Exo 2','Segoe UI',Helvetica,Arial,sans-serif"
|
|
BODY_FONT = "'Segoe UI',Helvetica,Arial,sans-serif"
|
|
MONO = "Consolas,'SFMono-Regular',Menlo,'Courier New',monospace"
|
|
|
|
LEVELS = {
|
|
"critical": "#b3261e",
|
|
"high": "#c2410c",
|
|
"medium": "#7a3f12",
|
|
"low": "#2e6b3a",
|
|
"info": "#455a64",
|
|
"ok": "#1e7d4f",
|
|
}
|
|
LEVEL_ALIASES = {
|
|
"critique": "critical", "haute": "high", "élevée": "high", "elevee": "high", "élevé": "high",
|
|
"moyenne": "medium", "moyen": "medium", "faible": "low", "basse": "low",
|
|
"informational": "info", "information": "info",
|
|
}
|
|
LEVEL_LABELS = {
|
|
"fr": {"critical": "CRITIQUE", "high": "ÉLEVÉE", "medium": "MOYENNE", "low": "FAIBLE", "info": "INFO", "ok": "OK"},
|
|
"en": {"critical": "CRITICAL", "high": "HIGH", "medium": "MEDIUM", "low": "LOW", "info": "INFO", "ok": "OK"},
|
|
}
|
|
|
|
TEMPLATES = {
|
|
"sla_breach": {"color": "#a52a22", "icon": "⚠", "fr": "ALERTE SLA", "en": "SLA ALERT"},
|
|
"incident_notification": {"color": None, "icon": "●", "fr": "INCIDENT", "en": "INCIDENT"},
|
|
"incident_update": {"color": "#1f6fb2", "icon": "↻", "fr": "POINT DE SITUATION", "en": "STATUS UPDATE"},
|
|
"incident_closure": {"color": "#1e7d4f", "icon": "✓", "fr": "INCIDENT CLÔTURÉ", "en": "INCIDENT CLOSED"},
|
|
"action_required": {"color": "#b8620a", "icon": "⚑", "fr": "ACTION REQUISE", "en": "ACTION REQUIRED"},
|
|
"escalation": {"color": "#a8246f", "icon": "▲", "fr": "ESCALADE", "en": "ESCALATION"},
|
|
"threat_bulletin": {"color": "#6b3fa0", "icon": "◆", "fr": "BULLETIN DE VEILLE", "en": "THREAT BULLETIN"},
|
|
"info": {"color": "#55606e", "icon": "", "fr": "INFORMATION", "en": "INFORMATION"},
|
|
}
|
|
|
|
STRINGS = {
|
|
"fr": {
|
|
"months": None,
|
|
"at": "à",
|
|
"soc_footer": "Ce message est généré automatiquement par le bot <strong style=\"color:{blue}\">{bot}</strong> — ACKnowledge SOC • Cybersécurité • Cloud • DevSecOps",
|
|
"soc_footer_text": "Ce message est généré automatiquement par le bot {bot} — ACKnowledge SOC • Cybersécurité • Cloud • DevSecOps",
|
|
"client_footer": "Ce message et ses éventuelles pièces jointes sont confidentiels et destinés exclusivement à leurs destinataires. Si vous l'avez reçu par erreur, merci d'en avertir l'expéditeur et de le supprimer.",
|
|
"tagline": "ACKnowledge SOC • Cybersécurité • Cloud • DevSecOps",
|
|
},
|
|
"en": {
|
|
"months": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
|
|
"at": "",
|
|
"soc_footer": "This message was generated automatically by the <strong style=\"color:{blue}\">{bot}</strong> bot — ACKnowledge SOC • Cybersecurity • Cloud • DevSecOps",
|
|
"soc_footer_text": "This message was generated automatically by the {bot} bot — ACKnowledge SOC • Cybersecurity • Cloud • DevSecOps",
|
|
"client_footer": "This message and any attachments are confidential and intended solely for their addressees. If you received it in error, please notify the sender and delete it.",
|
|
"tagline": "ACKnowledge SOC • Cybersecurity • Cloud • DevSecOps",
|
|
},
|
|
}
|
|
|
|
|
|
def _cfg():
|
|
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
|
|
|
|
|
def _inputs():
|
|
return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
|
|
|
|
|
def _s(v):
|
|
return "" if v is None else str(v).strip()
|
|
|
|
|
|
def _mix(a, b, t):
|
|
"""a blended toward b by t (0..1), as #rrggbb — solid colours, since Outlook drops rgba()."""
|
|
a = [int(a[i:i + 2], 16) for i in (1, 3, 5)]
|
|
b = [int(b[i:i + 2], 16) for i in (1, 3, 5)]
|
|
return "#" + "".join("%02x" % round(x + (y - x) * t) for x, y in zip(a, b))
|
|
|
|
|
|
def _vivid(c):
|
|
"""A light, saturated tint of c for text on a dark tinted box."""
|
|
h, _, sat = colorsys.rgb_to_hls(*(int(c[i:i + 2], 16) / 255 for i in (1, 3, 5)))
|
|
return "#" + "".join("%02x" % round(x * 255) for x in colorsys.hls_to_rgb(h, 0.64, max(sat, 0.75)))
|
|
|
|
|
|
def _level(name):
|
|
n = _s(name).lower()
|
|
n = LEVEL_ALIASES.get(n, n)
|
|
return n if n in LEVELS else None
|
|
|
|
|
|
def _pill(level, label):
|
|
return (
|
|
'<span style="display:inline-block;background:%s;color:#ffffff;font-family:%s;font-size:12px;'
|
|
'font-weight:700;letter-spacing:1.5px;line-height:16px;padding:4px 14px;border-radius:14px;'
|
|
'text-transform:uppercase;white-space:nowrap">%s</span>' % (LEVELS[level], FONT, label)
|
|
)
|
|
|
|
|
|
# --- Markdown ---------------------------------------------------------------
|
|
# A small renderer for what a playbook writes in a mail: paragraphs, headings,
|
|
# emphasis, links, lists, quotes, rules, GFM tables, code, and {{level}} pills.
|
|
# Raw HTML is escaped, never passed through: the text often carries alert data.
|
|
|
|
_P = 'style="margin:0 0 14px;font-family:%s;font-size:15px;line-height:24px;color:%s"' % (BODY_FONT, TEXT)
|
|
_TABLE_SEP = re.compile(r"^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$")
|
|
_LIST = re.compile(r"^(\s*)([-*+]|\d{1,3}[.)])\s+(.*)$")
|
|
_HR = re.compile(r"^\s{0,3}([-*_])(\s*\1){2,}\s*$")
|
|
_HEADING = re.compile(r"^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$")
|
|
_FENCE = re.compile(r"^\s{0,3}(```|~~~)")
|
|
|
|
|
|
class Inline:
|
|
def __init__(self, lang):
|
|
self.lang = lang
|
|
|
|
def render(self, text):
|
|
keep = []
|
|
|
|
def stash(fragment):
|
|
keep.append(fragment)
|
|
return "\x00%d\x00" % (len(keep) - 1)
|
|
|
|
text = re.sub(
|
|
r"`([^`]+)`",
|
|
lambda m: stash(
|
|
'<code style="font-family:%s;font-size:13px;background:#2c2c2c;color:#f2c879;padding:1px 6px;'
|
|
'border-radius:4px;word-break:break-all">%s</code>' % (MONO, html.escape(m.group(1)))
|
|
),
|
|
text,
|
|
)
|
|
text = html.escape(text, quote=True)
|
|
|
|
def link(label, url):
|
|
raw = html.unescape(url)
|
|
if not re.match(r"^(https?://|mailto:)", raw, re.I):
|
|
return label
|
|
return stash('<a href="%s" style="color:%s;text-decoration:underline">%s</a>' % (url, BLUE, label))
|
|
|
|
text = re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)", lambda m: link(self._emphasis(m.group(1)), m.group(2)), text)
|
|
text = re.sub(r"<((?:https?://|mailto:)[^\s&]+)>", lambda m: link(m.group(1), m.group(1)), text)
|
|
text = re.sub(r"(?<![\w/\x00])(https?://[^\s<\x00]+[^\s<\x00.,;:!?)])", lambda m: link(m.group(1), m.group(1)), text)
|
|
text = re.sub(r"\{\{\s*([^}:]+?)\s*(?::\s*([^}]*?)\s*)?\}\}", lambda m: self._pill(m, stash), text)
|
|
text = self._emphasis(text)
|
|
while "\x00" in text:
|
|
text = re.sub(r"\x00(\d+)\x00", lambda m: keep[int(m.group(1))], text)
|
|
return text
|
|
|
|
def _pill(self, m, stash):
|
|
level = _level(m.group(1))
|
|
if not level:
|
|
return m.group(0)
|
|
return stash(_pill(level, m.group(2) or m.group(1).upper()))
|
|
|
|
@staticmethod
|
|
def _emphasis(text):
|
|
text = re.sub(r"\*\*(?=\S)(.+?)(?<=\S)\*\*", r'<strong style="color:#ffffff">\1</strong>', text)
|
|
text = re.sub(r"(?<![\w])__(?=\S)(.+?)(?<=\S)__(?![\w])", r'<strong style="color:#ffffff">\1</strong>', text)
|
|
text = re.sub(r"(?<![\w*])\*(?=\S)(.+?)(?<=\S)\*(?![\w*])", r"<em>\1</em>", text)
|
|
text = re.sub(r"(?<![\w])_(?=\S)(.+?)(?<=\S)_(?![\w])", r"<em>\1</em>", text)
|
|
text = re.sub(r"~~(?=\S)(.+?)(?<=\S)~~", r"<s>\1</s>", text)
|
|
return text
|
|
|
|
|
|
def _cells(line):
|
|
line = line.strip()
|
|
if line.startswith("|"):
|
|
line = line[1:]
|
|
if line.endswith("|") and not line.endswith("\\|"):
|
|
line = line[:-1]
|
|
return [c.strip().replace("\\|", "|") for c in re.split(r"(?<!\\)\|", line)]
|
|
|
|
|
|
def _starts_block(lines, i):
|
|
line = lines[i]
|
|
return bool(
|
|
_FENCE.match(line) or _HEADING.match(line) or _HR.match(line) or line.lstrip().startswith(">")
|
|
or _LIST.match(line) or ("|" in line and i + 1 < len(lines) and _TABLE_SEP.match(lines[i + 1]))
|
|
)
|
|
|
|
|
|
def markdown(md, lang, accent):
|
|
lines = md.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
return _blocks(lines, Inline(lang), accent)
|
|
|
|
|
|
def _blocks(lines, inl, accent):
|
|
out = []
|
|
i = 0
|
|
n = len(lines)
|
|
while i < n:
|
|
line = lines[i]
|
|
if not line.strip():
|
|
i += 1
|
|
continue
|
|
|
|
m = _FENCE.match(line)
|
|
if m:
|
|
fence = m.group(1)
|
|
body = []
|
|
i += 1
|
|
while i < n and not lines[i].strip().startswith(fence):
|
|
body.append(lines[i])
|
|
i += 1
|
|
i += 1
|
|
out.append(
|
|
'<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 16px">'
|
|
'<tr><td bgcolor="#141414" style="background:#141414;border:1px solid #333333;border-radius:8px;padding:12px 14px">'
|
|
'<pre style="margin:0;font-family:%s;font-size:13px;line-height:20px;color:%s;white-space:pre-wrap;'
|
|
'word-break:break-all">%s</pre></td></tr></table>' % (MONO, TEXT, html.escape("\n".join(body)))
|
|
)
|
|
continue
|
|
|
|
m = _HEADING.match(line)
|
|
if m:
|
|
level = len(m.group(1))
|
|
content = inl.render(m.group(2))
|
|
if level <= 2:
|
|
out.append(
|
|
'<h2 style="margin:28px 0 16px;padding:0 0 10px;border-bottom:2px solid %s;font-family:%s;font-size:16px;'
|
|
'line-height:22px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:%s">%s</h2>'
|
|
% (BLUE_RULE, FONT, BLUE, content)
|
|
)
|
|
else:
|
|
out.append(
|
|
'<h3 style="margin:22px 0 10px;font-family:%s;font-size:16px;line-height:22px;font-weight:700;color:#ffffff">%s</h3>'
|
|
% (FONT, content)
|
|
)
|
|
i += 1
|
|
continue
|
|
|
|
if _HR.match(line):
|
|
out.append('<hr style="border:0;border-top:1px solid #3a3a3a;margin:22px 0">')
|
|
i += 1
|
|
continue
|
|
|
|
if "|" in line and i + 1 < n and _TABLE_SEP.match(lines[i + 1]):
|
|
head = _cells(line)
|
|
aligns = []
|
|
for c in _cells(lines[i + 1]):
|
|
aligns.append("center" if c.startswith(":") and c.endswith(":") else "right" if c.endswith(":") else "left")
|
|
i += 2
|
|
rows = []
|
|
while i < n and lines[i].strip() and "|" in lines[i]:
|
|
rows.append(_cells(lines[i]))
|
|
i += 1
|
|
out.append(_table(head, aligns, rows, inl))
|
|
continue
|
|
|
|
if line.lstrip().startswith(">"):
|
|
quoted = []
|
|
while i < n and lines[i].lstrip().startswith(">"):
|
|
quoted.append(re.sub(r"^\s*>\s?", "", lines[i]))
|
|
i += 1
|
|
out.append(
|
|
'<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 16px">'
|
|
'<tr><td style="border-left:3px solid %s;padding:2px 0 2px 16px">%s</td></tr></table>'
|
|
% (accent, _blocks(quoted, inl, accent))
|
|
)
|
|
continue
|
|
|
|
if _LIST.match(line):
|
|
html_list, i = _list(lines, i, inl, accent)
|
|
out.append(html_list)
|
|
continue
|
|
|
|
para = []
|
|
while i < n and lines[i].strip() and (not para or not _starts_block(lines, i)):
|
|
para.append(inl.render(lines[i].strip()))
|
|
i += 1
|
|
out.append("<p %s>%s</p>" % (_P, "<br>".join(para)))
|
|
return "".join(out)
|
|
|
|
|
|
def _list(lines, i, inl, accent):
|
|
first = _LIST.match(lines[i])
|
|
indent = len(first.group(1))
|
|
ordered = first.group(2)[0].isdigit()
|
|
start = int(re.match(r"\d+", first.group(2)).group(0)) if ordered else 1
|
|
items = []
|
|
n = len(lines)
|
|
while i < n:
|
|
m = _LIST.match(lines[i])
|
|
if m and len(m.group(1)) == indent and m.group(2)[0].isdigit() == ordered:
|
|
items.append([m.group(3)])
|
|
i += 1
|
|
continue
|
|
line = lines[i]
|
|
if not line.strip():
|
|
# A blank line ends the list unless the list (or an item's body) goes on.
|
|
j = i + 1
|
|
if j < n and lines[j].strip() and (len(lines[j]) - len(lines[j].lstrip())) > indent:
|
|
items[-1].append("")
|
|
i += 1
|
|
continue
|
|
nxt = _LIST.match(lines[j]) if j < n else None
|
|
if nxt and len(nxt.group(1)) == indent:
|
|
i += 1
|
|
continue
|
|
break
|
|
if (len(line) - len(line.lstrip())) > indent:
|
|
items[-1].append(line[indent:])
|
|
i += 1
|
|
continue
|
|
if m and len(m.group(1)) < indent:
|
|
break
|
|
if not m and not _starts_block(lines, i):
|
|
items[-1].append(line) # lazy continuation
|
|
i += 1
|
|
continue
|
|
break
|
|
|
|
li = []
|
|
for body in items:
|
|
head, rest = body[0], body[1:]
|
|
inner = inl.render(head.strip())
|
|
if any(r.strip() for r in rest):
|
|
dedent = min((len(r) - len(r.lstrip()) for r in rest if r.strip()), default=0)
|
|
nested = [r[dedent:] for r in rest]
|
|
if not _LIST.match(next(r for r in nested if r.strip())):
|
|
inner += "<br>"
|
|
inner += _blocks(nested, inl, accent).replace("margin:0 0 14px", "margin:6px 0 0")
|
|
li.append('<li style="margin:0 0 6px;font-family:%s;font-size:15px;line-height:24px;color:%s">%s</li>' % (BODY_FONT, TEXT, inner))
|
|
tag = "ol" if ordered else "ul"
|
|
attr = ' start="%d"' % start if ordered and start != 1 else ""
|
|
return '<%s%s style="margin:0 0 14px;padding:0 0 0 24px;color:%s">%s</%s>' % (tag, attr, TEXT, "".join(li), tag), i
|
|
|
|
|
|
def _table(head, aligns, rows, inl):
|
|
width = len(head)
|
|
th = []
|
|
for k, cell in enumerate(head):
|
|
radius = "border-radius:%s;" % ("9px 0 0 0" if k == 0 else "0 9px 0 0") if k in (0, width - 1) else ""
|
|
th.append(
|
|
'<th align="%s" bgcolor="%s" style="background:%s;%spadding:14px 16px;font-family:%s;font-size:12px;line-height:16px;'
|
|
'font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:#b8b8b8;text-align:%s">%s</th>'
|
|
% (aligns[k] if k < len(aligns) else "left", PANEL, PANEL, radius, FONT,
|
|
aligns[k] if k < len(aligns) else "left", inl.render(cell))
|
|
)
|
|
body = []
|
|
for r in rows:
|
|
r = (r + [""] * width)[:width]
|
|
tds = []
|
|
for k, cell in enumerate(r):
|
|
align = aligns[k] if k < len(aligns) else "left"
|
|
tds.append(
|
|
'<td align="%s" valign="middle" style="padding:14px 16px;border-top:1px solid #333333;font-family:%s;font-size:14px;'
|
|
'line-height:21px;color:%s;text-align:%s">%s</td>' % (align, BODY_FONT, TEXT, align, inl.render(cell))
|
|
)
|
|
body.append("<tr>%s</tr>" % "".join(tds))
|
|
return (
|
|
'<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" border="0" '
|
|
'style="border-collapse:separate;border:1px solid #d9d9d9;border-radius:10px;margin:0 0 18px">'
|
|
"<tr>%s</tr>%s</table>" % ("".join(th), "".join(body))
|
|
)
|
|
|
|
|
|
def markdown_text(md, lang):
|
|
"""The plain-text part: the markdown as written, with pills spelled out."""
|
|
def pill(m):
|
|
level = _level(m.group(1))
|
|
if not level:
|
|
return m.group(0)
|
|
return "[" + (m.group(2) or m.group(1).upper()) + "]"
|
|
return re.sub(r"\{\{\s*([^}:]+?)\s*(?::\s*([^}]*?)\s*)?\}\}", pill, md.strip())
|
|
|
|
|
|
def _plain(md):
|
|
"""A one-line excerpt for the hidden preheader."""
|
|
t = re.sub(r"```.*?```", " ", md, flags=re.S)
|
|
t = re.sub(r"\{\{\s*[^}:]+?\s*(?::\s*([^}]*?)\s*)?\}\}", lambda m: m.group(1) or "", t)
|
|
t = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", t)
|
|
t = re.sub(r"^\s*(#{1,6}|>|[-*+]|\d+[.)])\s+", "", t, flags=re.M)
|
|
t = re.sub(r"[*_`~|]", "", t)
|
|
t = re.sub(r"^\s*:?-{2,}.*$", "", t, flags=re.M)
|
|
return re.sub(r"\s+", " ", t).strip()[:140]
|
|
|
|
|
|
# --- Mail -------------------------------------------------------------------
|
|
|
|
def _when(raw, lang):
|
|
if raw:
|
|
try:
|
|
d = datetime.datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
raise Exception("date must be ISO 8601 (e.g. 2026-07-16T12:54:00Z), got " + json.dumps(raw))
|
|
if d.tzinfo is None:
|
|
d = d.replace(tzinfo=datetime.timezone.utc)
|
|
d = d.astimezone(datetime.timezone.utc)
|
|
else:
|
|
d = datetime.datetime.now(datetime.timezone.utc)
|
|
if lang == "fr":
|
|
return d.strftime("%d/%m/%Y à %H:%M UTC")
|
|
return "%d %s %d, %s UTC" % (d.day, STRINGS["en"]["months"][d.month - 1], d.year, d.strftime("%H:%M"))
|
|
|
|
|
|
def _lines(v):
|
|
"""The markdown with its line breaks. The playbook editor's input field is a
|
|
single line and drops pasted newlines, so a text without one may spell them
|
|
as the two characters \\n instead."""
|
|
v = str(v or "").strip()
|
|
if "\n" not in v and "\\n" in v:
|
|
v = v.replace("\\r\\n", "\n").replace("\\n", "\n")
|
|
return v
|
|
|
|
|
|
def _choice(inputs, key, allowed, default):
|
|
v = _s(inputs.get(key)).lower() or default
|
|
if v not in allowed:
|
|
raise Exception("%s must be one of %s, got %s" % (key, ", ".join(allowed), json.dumps(v)))
|
|
return v
|
|
|
|
|
|
def render(cfg, inputs):
|
|
template = _choice(inputs, "template", list(TEMPLATES), "info")
|
|
lang = _choice(inputs, "lang", ["fr", "en"], "fr")
|
|
audience = _choice(inputs, "audience", ["soc", "client"], "soc")
|
|
subject = _s(inputs.get("subject"))
|
|
description = _lines(inputs.get("description"))
|
|
if not subject:
|
|
raise Exception("subject is required")
|
|
if not description:
|
|
raise Exception("description is required")
|
|
|
|
severity = None
|
|
if _s(inputs.get("severity")):
|
|
severity = _level(inputs.get("severity"))
|
|
if not severity or severity == "ok":
|
|
raise Exception("severity must be one of critical, high, medium, low, info, got " + json.dumps(_s(inputs.get("severity"))))
|
|
case_ref = _s(inputs.get("case_ref"))
|
|
button_label = _s(inputs.get("button_label"))
|
|
button_url = _s(inputs.get("button_url"))
|
|
if button_url and not re.match(r"^https?://", button_url, re.I):
|
|
raise Exception("button_url must start with http:// or https://")
|
|
if button_label and not button_url:
|
|
raise Exception("button_url is required with button_label")
|
|
callout_text = _lines(inputs.get("callout_text"))
|
|
bot = _s(cfg.get("bot_name")) or "Riposte"
|
|
|
|
t = TEMPLATES[template]
|
|
badge = t[lang]
|
|
accent = t["color"]
|
|
if template == "incident_notification":
|
|
accent = LEVELS[severity] if severity else LEVELS["info"]
|
|
if severity:
|
|
badge = "%s %s" % (badge, LEVEL_LABELS[lang][severity]) if lang == "fr" else "%s %s" % (LEVEL_LABELS[lang][severity], badge)
|
|
badge_full = (t["icon"] + " " + badge).strip()
|
|
callout_title = _s(inputs.get("callout_title")) or badge_full
|
|
|
|
inl = Inline(lang)
|
|
esc = html.escape
|
|
when = _when(_s(inputs.get("date")), lang)
|
|
|
|
meta = [esc(when)]
|
|
if case_ref:
|
|
meta.append('<span style="font-family:%s;color:#ffffff">%s</span>' % (MONO, esc(case_ref)))
|
|
if severity and template != "incident_notification":
|
|
meta.append(_pill(severity, LEVEL_LABELS[lang][severity]))
|
|
meta_html = '<span style="color:%s"> · </span>' % MUTED
|
|
meta_html = meta_html.join(meta)
|
|
|
|
callout = ""
|
|
if callout_text:
|
|
callout = (
|
|
'<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 30px">'
|
|
'<tr><td bgcolor="%s" style="background:%s;border:2px solid %s;border-radius:12px;padding:24px 28px">'
|
|
'<div style="margin:0 0 10px;font-family:%s;font-size:22px;line-height:28px;font-weight:700;letter-spacing:2px;color:%s">%s</div>'
|
|
"%s</td></tr></table>"
|
|
% (_mix(accent, "#565656", 0.8), _mix(accent, "#565656", 0.8), accent, FONT, _vivid(accent),
|
|
esc(callout_title), markdown(callout_text, lang, accent).replace("margin:0 0 14px", "margin:0 0 6px"))
|
|
)
|
|
|
|
button = ""
|
|
if button_url:
|
|
button = (
|
|
'<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:10px 0 30px"><tr>'
|
|
'<td bgcolor="%s" style="background:%s;border-radius:8px">'
|
|
'<a href="%s" target="_blank" style="display:inline-block;padding:13px 28px;font-family:%s;font-size:14px;line-height:18px;'
|
|
'font-weight:700;letter-spacing:1px;color:#ffffff;text-decoration:none;border-radius:8px">%s</a></td></tr></table>'
|
|
% (BLUE_RULE, BLUE_RULE, esc(button_url, quote=True), FONT, esc(button_label or button_url))
|
|
)
|
|
|
|
if audience == "client":
|
|
footer = esc(STRINGS[lang]["client_footer"]) + "<br>" + esc(STRINGS[lang]["tagline"])
|
|
footer_text = STRINGS[lang]["client_footer"] + "\n" + STRINGS[lang]["tagline"]
|
|
else:
|
|
footer = STRINGS[lang]["soc_footer"].format(blue=BLUE, bot=esc(bot))
|
|
footer_text = STRINGS[lang]["soc_footer_text"].format(bot=bot)
|
|
|
|
logo = ""
|
|
if LOGO_PNG_B64 and not LOGO_PNG_B64.startswith("__"):
|
|
logo = ('<img src="data:image/png;base64,%s" width="160" height="38" alt="ACKnowledge" '
|
|
'style="display:block;width:160px;height:38px;border:0;outline:none">' % LOGO_PNG_B64)
|
|
|
|
preheader = esc(_plain(callout_text + " " + description) if callout_text else _plain(description))
|
|
|
|
page = """<!DOCTYPE html>
|
|
<html lang="{lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<meta name="color-scheme" content="dark"><meta name="supported-color-schemes" content="dark">
|
|
<title>{title}</title>
|
|
<style>
|
|
@media (max-width:660px){{.container{{width:100%!important}}.px{{padding-left:22px!important;padding-right:22px!important}}.stack{{display:block!important;width:100%!important;text-align:left!important}}.stack-gap{{padding-top:16px!important}}}}
|
|
</style></head>
|
|
<body style="margin:0;padding:0;background:{outer}" bgcolor="{outer}">
|
|
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:{outer}">{preheader}</div>
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="{outer}" style="background:{outer}"><tr><td align="center" style="padding:28px 12px">
|
|
<table role="presentation" class="container" width="640" cellpadding="0" cellspacing="0" border="0" bgcolor="{page}" style="width:640px;max-width:640px;background:{page}">
|
|
|
|
<tr><td class="px" bgcolor="{header}" style="background:{header};padding:36px 40px 30px">
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
|
|
<td class="stack" valign="top" style="font-family:{font}">
|
|
<div style="font-size:32px;line-height:38px;font-weight:700;color:#ffffff">ACK<span style="color:{blue}">nowledge</span></div>
|
|
<div style="padding-top:6px;font-size:11px;line-height:16px;letter-spacing:2.5px;white-space:nowrap;color:{blue}">SECURITY OPERATIONS CENTER | SOC 24/7</div>
|
|
</td>
|
|
<td class="stack stack-gap" valign="top" align="right" style="text-align:right">
|
|
<span style="display:inline-block;background:{accent};color:#ffffff;font-family:{font};font-size:14px;line-height:18px;font-weight:700;letter-spacing:2px;padding:10px 26px;border-radius:24px;white-space:nowrap">{badge}</span>
|
|
</td>
|
|
</tr></table>
|
|
<div style="height:1px;line-height:1px;font-size:1px;background:{header_rule};margin:30px 0 26px"> </div>
|
|
<div style="font-family:{font};font-size:26px;line-height:32px;font-weight:700;color:#ffffff">{title}</div>
|
|
<div style="padding-top:8px;font-family:{body_font};font-size:14px;line-height:22px;color:{muted}">{meta}</div>
|
|
</td></tr>
|
|
|
|
<tr><td class="px" style="padding:36px 40px 8px">
|
|
{callout}{description}{button}
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:30px 0 32px;border-top:1px solid #d9d9d9"><tr>
|
|
<td valign="middle" width="176" style="width:176px;padding:30px 16px 0 0">{logo}</td>
|
|
<td valign="top" style="padding:30px 0 0 34px;border-left:3px solid {blue_rule}">
|
|
<div style="font-family:{font};font-size:18px;line-height:24px;font-weight:700;color:{signature}">SOC <span style="font-weight:400;color:{muted}">|</span> Security Operation Center<br>ACKnowledge</div>
|
|
<div style="padding-top:14px;font-family:{body_font};font-size:15px;line-height:26px;color:{muted}">w: <a href="https://www.acknowledge.fr" style="color:{blue};text-decoration:none">www.acknowledge.fr</a><br>e: <a href="mailto:soc@acknowledge.fr" style="color:{blue};text-decoration:none">soc@acknowledge.fr</a></div>
|
|
</td>
|
|
</tr></table>
|
|
</td></tr>
|
|
|
|
<tr><td class="px" bgcolor="#232323" style="background:#232323;border-top:3px solid {blue_rule};padding:22px 40px;font-family:{body_font};font-size:12px;line-height:19px;color:#9a9a9a">{footer}</td></tr>
|
|
|
|
</table>
|
|
</td></tr></table>
|
|
</body></html>
|
|
""".format(
|
|
lang=lang, title=esc(subject), outer=OUTER, page=PAGE, header=HEADER, header_rule=HEADER_RULE,
|
|
font=FONT, body_font=BODY_FONT, blue=BLUE, blue_rule=BLUE_RULE, signature=SIGNATURE, muted=MUTED,
|
|
accent=accent, badge=esc(badge_full), meta=meta_html, preheader=preheader,
|
|
callout=callout, description=markdown(description, lang, accent), button=button,
|
|
logo=logo, footer=footer,
|
|
)
|
|
|
|
head = [("[%s] " % badge) + subject]
|
|
head.append(" · ".join([when] + ([case_ref] if case_ref else []) + ([LEVEL_LABELS[lang][severity]] if severity and template != "incident_notification" else [])))
|
|
text = ["\n".join(head)]
|
|
if callout_text:
|
|
text.append(callout_title + "\n" + markdown_text(callout_text, lang))
|
|
text.append(markdown_text(description, lang))
|
|
if button_url:
|
|
text.append((button_label + ": " if button_label else "") + button_url)
|
|
text.append("--\nSOC | Security Operation Center\nACKnowledge\nw: www.acknowledge.fr\ne: soc@acknowledge.fr")
|
|
text.append(footer_text)
|
|
|
|
full_subject = subject
|
|
if case_ref and case_ref not in subject:
|
|
full_subject = "[%s] %s" % (case_ref, subject)
|
|
|
|
return {
|
|
"subject": full_subject,
|
|
"html_body": page,
|
|
"text_body": "\n\n".join(text) + "\n",
|
|
"template": template,
|
|
"lang": lang,
|
|
"audience": audience,
|
|
"html_bytes": len(page.encode("utf-8")),
|
|
}
|
|
|
|
|
|
def main():
|
|
try:
|
|
print(json.dumps(render(_cfg(), _inputs()), ensure_ascii=False))
|
|
except Exception as e:
|
|
print(json.dumps({"error": str(e)}))
|
|
sys.exit(1)
|
|
|
|
|
|
main()
|