Repository: AbsaOSS/pramen (language: python)

Issue:
Escape HTML in exception error messages
## Background
Sometimes, raised exceptions contain HTML. This causes visual effects in email notifications:

![Screenshot 2024-10-17 at 9 13 53](https://github.com/user-attachments/assets/69795ffa-bcb2-4d4f-b963-6367105bba40)

Such exception error messages need escaping.

## Feature
Escape HTML in exception error messages.

## Example
As you can see, even wrapping in `<pre>...</pre>` does not help
```
<pre>
  Caused by za.co.absa.atum.agent.exception.AtumAgentException$HttpException: <html>
<head><title>503 Service Temporarily Unavailable</title></head>
<body>
<center><h1>503 Service Temporarily Unavailable</h1></center>
<hr><center>nginx</center>
</body>
</html>

    za.co.absa.atum.agent.dispatcher.HttpDispatcher.handleResponseBody(HttpDispatcher.scala:80)
    za.co.absa.atum.agent.dispatcher.HttpDispatcher.createPartitioning(HttpDispatcher.scala:54)
    za.co.absa.atum.agent.AtumAgent.getOrCreateAtumContext(AtumAgent.scala:76)
    za.co.absa.atum.agent.AtumAgent.getOrCreateAtumContext$(AtumAgent.scala:72)
    za.co.absa.atum.agent.AtumAgent$.getOrCreateAtumContext(AtumAgent.scala:116)
</pre>
```

## Proposed Solution [Optional]
--


Discussion / hints:

Fix patch:
diff --git a/pramen/core/src/main/scala/za/co/absa/pramen/core/notify/pipeline/PipelineNotificationBuilderHtml.scala b/pramen/core/src/main/scala/za/co/absa/pramen/core/notify/pipeline/PipelineNotificationBuilderHtml.scala
index d5bc35ab4..08fed6edc 100644
--- a/pramen/core/src/main/scala/za/co/absa/pramen/core/notify/pipeline/PipelineNotificationBuilderHtml.scala
+++ b/pramen/core/src/main/scala/za/co/absa/pramen/core/notify/pipeline/PipelineNotificationBuilderHtml.scala
@@ -300,8 +300,8 @@ class PipelineNotificationBuilderHtml(implicit conf: Config) extends PipelineNot
     val errorMessage = ex.getMessage
 
     val errorMessageTruncated = maxReasonLength match {
-      case Some(maxLength) if errorMessage.length > maxLength => errorMessage.substring(0, maxLength) + "..."
-      case _ => errorMessage
+      case Some(maxLength) if errorMessage.length > maxLength =>  StringUtils.escapeHTML(errorMessage.substring(0, maxLength)) + "..."
+      case _ => StringUtils.escapeHTML(errorMessage)
     }
 
     paragraphBuilder
@@ -325,7 +325,7 @@ class PipelineNotificationBuilderHtml(implicit conf: Config) extends PipelineNot
         val stderrMsg = if (stderr.isEmpty) "" else s"""Last <b>stderr</b> lines:\n${stderr.mkString("", EOL, EOL)}"""
         s"$msg\n$stdoutMsg\n$stderrMsg"
       case ex: Throwable                     =>
-        renderThrowable(ex, maximumLength = maxExceptionLength)
+        renderThrowable(ex, maximumLength = maxExceptionLength, escapeHTML = true)
     }
 
     builder.withUnformattedText(text)
@@ -624,10 +624,10 @@ class PipelineNotificationBuilderHtml(implicit conf: Config) extends PipelineNot
         }
     }
 
-    maxReasonLength match {
+    StringUtils.escapeHTML(maxReasonLength match {
       case Some(maxLength) if reason.length > maxLength =>  reason.substring(0, maxLength) + "..."
       case _ => reason
-    }
+    })
   }
 
   private[core] def getFinishTime(task: TaskResult): String = {
diff --git a/pramen/core/src/main/scala/za/co/absa/pramen/core/utils/StringUtils.scala b/pramen/core/src/main/scala/za/co/absa/pramen/core/utils/StringUtils.scala
index 56f456f91..09d81f7e9 100644
--- a/pramen/core/src/main/scala/za/co/absa/pramen/core/utils/StringUtils.scala
+++ b/pramen/core/src/main/scala/za/co/absa/pramen/core/utils/StringUtils.scala
@@ -170,9 +170,10 @@ object StringUtils {
   }
 
   /** Renders an exception as a string */
-  def renderThrowable(ex: Throwable, level: Int = 1, maximumLength: Option[Int] = None): String = {
+  def renderThrowable(ex: Throwable, level: Int = 1, maximumLength: Option[Int] = None, escapeHTML: Boolean = false): String = {
     val prefix = " " * (level * 2)
-    val base = s"""${ex.toString}\n${ex.getStackTrace.map(s => s"$prefix$s").mkString("", EOL, EOL)}"""
+    val errMsg = if (escapeHTML) StringUtils.escapeHTML(ex.toString) else ex.toString
+    val base = s"""$errMsg\n${ex.getStackTrace.map(s => s"$prefix$s").mkString("", EOL, EOL)}"""
     val cause = Option(ex.getCause) match {
       case Some(c) if level < 6 => s"\n${prefix}Caused by " + renderThrowable(c, level + 1)
       case _                    => ""
@@ -346,4 +347,26 @@ object StringUtils {
     output.toString()
   }
 
+  /**
+    * Escapes HTML tags and symbols from a string.
+    * Based on https://stackoverflow.com/a/25228492/1038282
+    *
+    * @param s A string to escape HTML from.
+    * @return An escaped string.
+    */
+  def escapeHTML(s: String): String = {
+    val out = new StringBuilder(Math.max(64, s.length))
+    var i = 0
+    while (i < s.length) {
+      val c = s.charAt(i)
+      if (c == '<' || c == '>' || c == '&') {
+        out.append("&#")
+        out.append(c.toInt)
+        out.append(';')
+      }
+      else out.append(c)
+      i += 1
+    }
+    out.toString
+  }
 }


Test patch:
diff --git a/pramen/core/src/test/scala/za/co/absa/pramen/core/tests/utils/StringUtilsSuite.scala b/pramen/core/src/test/scala/za/co/absa/pramen/core/tests/utils/StringUtilsSuite.scala
index 73cddc6b2..757763046 100644
--- a/pramen/core/src/test/scala/za/co/absa/pramen/core/tests/utils/StringUtilsSuite.scala
+++ b/pramen/core/src/test/scala/za/co/absa/pramen/core/tests/utils/StringUtilsSuite.scala
@@ -188,6 +188,12 @@ class StringUtilsSuite extends AnyWordSpec {
       assert(s.contains("java.lang.RuntimeException: test"))
     }
 
+    "render a throwable with an HTML message" in {
+      val ex = new RuntimeException("test <b>exception</b>")
+      val s = renderThrowable(ex, escapeHTML = true)
+      assert(s.contains("java.lang.RuntimeException: test &#60;b&#62;exception&#60;/b&#62;"))
+    }
+
     "render a throwable with a length limit" in {
       val ex = new RuntimeException("test")
       val s = renderThrowable(ex, maximumLength = Some(4))
@@ -384,4 +390,14 @@ class StringUtilsSuite extends AnyWordSpec {
     }
   }
 
+  "escapeHTML" should {
+    "escape HTML from a string" in {
+      val s = """This <b>is</b> an HTML &123"""
+
+      val actual = StringUtils.escapeHTML(s)
+
+      assert(actual == """This &#60;b&#62;is&#60;/b&#62; an HTML &#38;123""")
+    }
+  }
+
 }