CVE-2023-35929: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: Tuleap is a free and open source suite to improve management of software development and collaboration. Prior to version 14.10.99.4 of Tuleap Community Edition and prior to versions 14.10-2 and 14.9-5 of Tuleap Enterprise Edition, content displayed in the "card fields" (visible in the kanban and PV2 apps) is not properly escaped. A malicious user with the capability to create an artifact or to edit a field used as a card field could force victim to execute uncontrolled code. Tuleap Community Edition 14.10.99.4, Tuleap Enterprise Edition 14.10-2, and Tuleap Enterprise Edition 14.9-5 contain a fix.
CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 5.4
Published: 2023-07-25T18:15Z
Repository: https://github.com/Enalean/tuleap
Language: JavaScript
Files changed: plugins/agiledashboard/scripts/lib/card-fields/package.json, plugins/agiledashboard/scripts/lib/card-fields/pnpm-lock.yaml, plugins/agiledashboard/scripts/lib/card-fields/src/card-fields-service.js, plugins/agiledashboard/scripts/lib/card-fields/src/card-fields.tpl.html, plugins/agiledashboard/scripts/lib/card-fields/src/highlight-filter-template.test.ts, plugins/agiledashboard/scripts/lib/card-fields/src/highlight-filter-template.ts, plugins/agiledashboard/scripts/lib/card-fields/src/highlight-filter.js, plugins/agiledashboard/scripts/lib/card-fields/src/index.js, plugins/agiledashboard/scripts/lib/card-fields/vite.config.ts
Commit date: 2023-06-22 11:19:52 +0200
Commit message: Fixes request #32629: XSS in the card field of the agile dashboard apps

Tried to keep the changes within the scope of the security issue. The
template engine of hybrids is bit abused but it is still better than
before. A future contribution might want to consider removing angular
completely from @tuleap/plugin-agiledashboard-card-fields.

This change also fixes the display of the legacy colors for selectbox
using them.

Change-Id: I301ed6a72c324054a490605e881646bf317316fb

Diff: diff --git a/plugins/agiledashboard/scripts/lib/card-fields/package.json b/plugins/agiledashboard/scripts/lib/card-fields/package.json
index 78ad35bd79fd..be7196db43fe 100644
--- a/plugins/agiledashboard/scripts/lib/card-fields/package.json
+++ b/plugins/agiledashboard/scripts/lib/card-fields/package.json
@@ -16,23 +16,22 @@
     }
   },
   "dependencies": {
     "@tuleap/tlp-swatch-colors": "workspace:*",
     "dompurify": "^3.0.0",
-    "escape-string-regexp": "^2.0.0"
+    "escape-string-regexp": "^2.0.0",
+    "hybrids": "^8.2.1"
   },
   "peerDependencies": {
     "angular": "^1.8.0",
-    "angular-sanitize": "^1.8.0",
     "moment": "^2.29.1"
   },
   "devDependencies": {
     "@tuleap/build-system-configurator": "workspace:*",
     "@types/dompurify": "^2.4.0",
     "angular": "^1.8.2",
-    "angular-mocks": "^1.8.2",
-    "angular-sanitize": "^1.8.2"
+    "angular-mocks": "^1.8.2"
   },
   "scripts": {
     "build": "vite build",
     "watch": "vite build --watch --mode development --minify false",
     "test": "jest"
diff --git a/plugins/agiledashboard/scripts/lib/card-fields/pnpm-lock.yaml b/plugins/agiledashboard/scripts/lib/card-fields/pnpm-lock.yaml
index 45c6d8c42df7..80d33857218c 100644
--- a/plugins/agiledashboard/scripts/lib/card-fields/pnpm-lock.yaml
+++ b/plugins/agiledashboard/scripts/lib/card-fields/pnpm-lock.yaml
@@ -8,10 +8,13 @@ dependencies:
     specifier: ^3.0.0
     version: 3.0.0
   escape-string-regexp:
     specifier: ^2.0.0
     version: 2.0.0
+  hybrids:
+    specifier: ^8.2.1
+    version: 8.2.1
 
 devDependencies:
   '@tuleap/build-system-configurator':
     specifier: workspace:*
     version: link:../../../../../lib/frontend/build-system-configurator
@@ -22,13 +25,10 @@ devDependencies:
     specifier: ^1.8.2
     version: 1.8.2
   angular-mocks:
     specifier: ^1.8.2
     version: 1.8.2
-  angular-sanitize:
-    specifier: ^1.8.2
-    version: 1.8.2
 
 packages:
 
   /@types/dompurify@2.4.0:
     resolution: {integrity: sha512-IDBwO5IZhrKvHFUl+clZxgf3hn2b/lU6H1KaBShPkQyGJUQ0xwebezIPSuiyGwfz1UzJWQl4M7BDxtHtCCPlTg==}
@@ -42,14 +42,10 @@ packages:
 
   /angular-mocks@1.8.2:
     resolution: {integrity: sha512-I5L3P0l21HPdVsP4A4qWmENt4ePjjbkDFdAzOaM7QiibFySbt14DptPbt2IjeG4vFBr4vSLbhIz8Fk03DISl8Q==}
     dev: true
 
-  /angular-sanitize@1.8.2:
-    resolution: {integrity: sha512-OB6Goa+QN3byf5asQ7XRl7DKZejm/F/ZOqa9z1skqYVOWA2hoBxoCmt9E7+i7T/TbxZP5zYzKxNZVVJNu860Hg==}
-    dev: true
-
   /angular@1.8.2:
     resolution: {integrity: sha512-IauMOej2xEe7/7Ennahkbb5qd/HFADiNuLSESz9Q27inmi32zB0lnAsFeLEWcox3Gd1F6YhNd1CP7/9IukJ0Gw==}
     dev: true
 
   /dompurify@3.0.0:
@@ -58,5 +54,11 @@ packages:
 
   /escape-string-regexp@2.0.0:
     resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==}
     engines: {node: '>=8'}
     dev: false
+
+  /hybrids@8.2.1:
+    resolution: {integrity: sha512-ZIjDLZi6aBOL+Cdry1xtBLJJnMzx99HNwmOCQXEvqdawyYu949z469vxgExyAgq0CA4lSfJsjh1rsiIsGdqXdw==}
+    engines: {node: '>=18.0.0'}
+    hasBin: true
+    dev: false
diff --git a/plugins/agiledashboard/scripts/lib/card-fields/src/card-fields-service.js b/plugins/agiledashboard/scripts/lib/card-fields/src/card-fields-service.js
index bbb5842db418..f372234ad0c9 100644
--- a/plugins/agiledashboard/scripts/lib/card-fields/src/card-fields-service.js
+++ b/plugins/agiledashboard/scripts/lib/card-fields/src/card-fields-service.js
@@ -1,14 +1,13 @@
-import moment from "moment";
+import { html } from "hybrids";
+import { highlightFilterElements } from "./highlight-filter-template";
 
 export default CardFieldsService;
 
-CardFieldsService.$inject = ["$sce", "$filter"];
-
-function CardFieldsService($sce, $filter) {
-    const highlight = $filter("tuleapHighlight");
+CardFieldsService.$inject = ["$sce"];
 
+function CardFieldsService($sce) {
     return {
         cardFieldIsSimpleValue,
         cardFieldIsList,
         cardFieldIsOpenList,
         cardFieldIsText,
@@ -16,11 +15,10 @@ function CardFieldsService($sce, $filter) {
         cardFieldIsFile,
         cardFieldIsCross,
         cardFieldIsPermissions,
         cardFieldIsUser,
         cardFieldIsComputed,
-        getCardFieldDateValue,
         getCardFieldListValues,
         getCardFieldFileValue,
         getCardFieldPermissionsValue,
         getCardFieldUserValue,
         isListBoundToAValueDifferentFromNone,
@@ -97,39 +95,39 @@ function CardFieldsService($sce, $filter) {
             if (value.color) {
                 return getValueRenderedWithColor(value, filter_terms);
             } else if (value.tlp_color) {
                 return getValueRenderedWithTlpColor(value, filter_terms);
             } else if (value.avatar_url) {
-                return getCardFieldUserValue(value, filter_terms);
+                return getCardFieldUserValueTemplate(value, filter_terms);
             }
 
-            return highlight(value.label, filter_terms);
+            return highlightFilterElements(value.label, filter_terms);
         }
 
         function getValueRenderedWithColor(value, filter_terms) {
             const r = parseInt(value.color.r, 10);
             const g = parseInt(value.color.g, 10);
             const b = parseInt(value.color.b, 10);
-            const color = $sce.getTrustedHtml(`<span class="extra-card-field-color"
-                style="background: rgb(${r}, ${g}, ${b})"></span>`);
 
-            return color + highlight(value.label, filter_terms);
+            const styles = {
+                background: `rgb(${r}, ${g}, ${b})`,
+            };
+
+            return html`<span class="extra-card-field-color" style="${styles}"></span
+                >${highlightFilterElements(value.label, filter_terms)}`;
         }
 
         function getValueRenderedWithTlpColor({ label, tlp_color }, filter_terms) {
-            const color = $sce.getTrustedHtml(
-                `<span class="extra-card-field-color card-field-${tlp_color}"></span>`
-            );
+            const classlist = ["extra-card-field-color", `card-field-${tlp_color}`];
 
-            return color + highlight(label, filter_terms);
+            return html`<span class="${classlist}"></span>${highlightFilterElements(
+                    label,
+                    filter_terms
+                )}`;
         }
 
-        return $sce.trustAsHtml(values.map(getValueRendered).join(", "));
-    }
-
-    function getCardFieldDateValue(value) {
-        return moment(value).fromNow();
+        return getHTMLStringFromTemplate(renderListItems(values, getValueRendered));
     }
 
     function getCardFieldFileValue(artifact_id, field_id, file_descriptions, filter_terms) {
         function getFileUrl(file) {
             return (
@@ -139,43 +137,66 @@ function CardFieldsService($sce, $filter) {
                 encodeURIComponent(file.name)
             );
         }
 
         function getFileLink(file) {
-            var file_name = highlight(file.name, filter_terms);
-
-            return (
-                '<a data-nodrag="true" href="' +
-                getFileUrl(file) +
-                '" title="' +
-                file.description +
-                '"><i class="fas fa-paperclip extra-card-field-file-icon"></i>' +
-                file_name +
-                "</a>"
-            );
+            const file_display = html`<i class="fas fa-paperclip extra-card-field-file-icon"></i
+                >${highlightFilterElements(file.name, filter_terms)}`;
+            return html`<a data-nodrag="true" href="${getFileUrl(file)}" title="${file.description}"
+                >${file_display}</a
+            >`;
         }
 
-        return file_descriptions.map(getFileLink).join(", ");
+        return getHTMLStringFromTemplate(renderListItems(file_descriptions, getFileLink));
     }
 
-    function getCardFieldPermissionsValue(values) {
-        return values.join(", ");
+    function getCardFieldPermissionsValue(values, filter_terms) {
+        return getHTMLStringFromTemplate(
+            renderListItems(
+                values,
+                (value) => html`${highlightFilterElements(value, filter_terms)}`
+            )
+        );
     }
 
-    function getCardFieldUserValue(value, filter_terms) {
-        let display_name;
-
+    function getCardFieldUserValueTemplate(value, filter_terms) {
+        const display_name = highlightFilterElements(value.display_name, filter_terms);
         if (value.user_url === null) {
-            display_name = highlight(value.display_name, filter_terms);
-            return `<div class="tlp-avatar-mini"> </div><span>${display_name}</span>`;
+            return html`<div class="tlp-avatar-mini"></div>
+                <span>${display_name}</span>`;
         }
 
-        display_name = highlight(value.display_name, filter_terms);
-        return `<a data-nodrag="true" class="extra-card-field-user" href="${value.user_url}">
-                            <div class="tlp-avatar-mini"><img src="${value.avatar_url}" /></div><span>${display_name}</span>
-                        </a>`;
+        return html`<a data-nodrag="true" class="extra-card-field-user" href="${value.user_url}">
+            <div class="tlp-avatar-mini"><img loading="lazy" src="${value.avatar_url}" /></div>
+            <span>${display_name}</span>
+        </a>`;
+    }
+
+    function getCardFieldUserValue(value, filter_terms) {
+        return getHTMLStringFromTemplate(getCardFieldUserValueTemplate(value, filter_terms));
     }
 
     function isListBoundToAValueDifferentFromNone(values) {
         return values.find((value) => value.id !== null);
     }
+
+    function renderListItems(items, render_item) {
+        let templated_content = html``;
+
+        for (const [i, file] of items.entries()) {
+            if (i === 0) {
+                templated_content = render_item(file);
+            } else {
+                templated_content = html`${templated_content}, ${render_item(file)}`;
+            }
+        }
+
+        return templated_content;
+    }
+
+    function getHTMLStringFromTemplate(template) {
+        const element = document.createElement("div");
+        template({}, element);
+
+        return $sce.trustAsHtml(element.innerHTML);
+    }
 }
diff --git a/plugins/agiledashboard/scripts/lib/card-fields/src/card-fields.tpl.html b/plugins/agiledashboard/scripts/lib/card-fields/src/card-fields.tpl.html
index 0c28abdc49d7..2e2cef5d6b37 100644
--- a/plugins/agiledashboard/scripts/lib/card-fields/src/card-fields.tpl.html
+++ b/plugins/agiledashboard/scripts/lib/card-fields/src/card-fields.tpl.html
@@ -60,11 +60,11 @@
         <div
                 class="extra-card-field-content"
                 ng-if="card_ctrl.cardFieldIsPermissions(card_field.type) && card_field.granted_groups.length > 0"
         >
             <span class="extra-card-field-title">{{ card_field.label }}: </span>
-            <span ng-bind-html="card_ctrl.getCardFieldPermissionsValue(card_field.granted_groups) | tuleapHighlight:card_ctrl.filter_terms"></span>
+            <span ng-bind-html="card_ctrl.getCardFieldPermissionsValue(card_field.granted_groups, card_ctrl.filter_terms)"></span>
         </div>
         <div
                 class="extra-card-field-content"
                 ng-if="card_ctrl.cardFieldIsUser(card_field.type) && card_field.value"
         >
diff --git a/plugins/agiledashboard/scripts/lib/card-fields/src/highlight-filter-template.test.ts b/plugins/agiledashboard/scripts/lib/card-fields/src/highlight-filter-template.test.ts
new file mode 100644
index 000000000000..f9bee09bba5e
--- /dev/null
+++ b/plugins/agiledashboard/scripts/lib/card-fields/src/highlight-filter-template.test.ts
@@ -0,0 +1,205 @@
+/**
+ * Copyright (c) Enalean, 2023-Present. All Rights Reserved.
+ *
+ * This file is a part of Tuleap.
+ *
+ * Tuleap is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Tuleap is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Tuleap. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import { highlightFilterElements } from "./highlight-filter-template";
+import type { UpdateFunctionWithMethods } from "hybrids";
+
+jest.mock("escape-string-regexp", () => ({
+    default: jest.requireActual("escape-string-regexp"),
+}));
+
+const TEST_PHRASE = "Prefix Highlight Suffix";
+
+describe("highlight-filter-template", () => {
+    it("should highlight a matching phrase", () => {
+        expect(getElementFromTemplate(highlightFilterElements(TEST_PHRASE, "highlight")))
+            .toMatchInlineSnapshot(`
+            <div>
+              
+              
+              Prefix 
+              
+              <span
+                class="highlight"
+              >
+                Highlight
+              </span>
+              
+               Suffix
+            </div>
+        `);
+    });
+
+    it("should highlight nothing if no match found", () => {
+        expect(getElementFromTemplate(highlightFilterElements(TEST_PHRASE, "no match")))
+            .toMatchInlineSnapshot(`
+            <div>
+              
+              
+              Prefix Highlight Suffix
+            </div>
+        `);
+    });
+
+    it("should highlight nothing for the undefined filter", () => {
+        expect(getElementFromTemplate(highlightFilterElements(TEST_PHRASE, undefined)))
+            .toMatchInlineSnapshot(`
+            <div>
+              Prefix Highlight Suffix
+            </div>
+        `);
+    });
+
+    it("should work correctly for number filters", () => {
+        expect(getElementFromTemplate(highlightFilterElements("3210123", 0)))
+            .toMatchInlineSnapshot(`
+            <div>
+              
+              
+              321
+              
+              <span
+                class="highlight"
+              >
+                0
+              </span>
+              
+              123
+            </div>
+        `);
+    });
+
+    it("should work when the text context is undefined", () => {
+        expect(getElementFromTemplate(highlightFilterElements(undefined, "nothing")))
+            .toMatchInlineSnapshot(`
+            <div>
+              
+            </div>
+        `);
+    });
+
+    it("should work correctly for number text", () => {
+        expect(getElementFromTemplate(highlightFilterElements(3210123, "0")))
+            .toMatchInlineSnapshot(`
+            <div>
+              
+              
+              321
+              
+              <span
+                class="highlight"
+              >
+                0
+              </span>
+              
+              123
+            </div>
+        `);
+    });
+
+    it("should highlight nothing if empty filter string passed", () => {
+        expect(getElementFromTemplate(highlightFilterElements(TEST_PHRASE, "")))
+            .toMatchInlineSnapshot(`
+            <div>
+              Prefix Highlight Suffix
+            </div>
+        `);
+    });
+
+    it("should highlight more that one element", () => {
+        expect(getElementFromTemplate(highlightFilterElements(TEST_PHRASE, "gh")))
+            .toMatchInlineSnapshot(`
+            <div>
+              
+              
+              Prefix Hi
+              
+              <span
+                class="highlight"
+              >
+                gh
+              </span>
+              
+              li
+              
+              <span
+                class="highlight"
+              >
+                gh
+              </span>
+              
+              t Suffix
+            </div>
+        `);
+    });
+
+    it("highlights each matching search terms", () => {
+        expect(getElementFromTemplate(highlightFilterElements(TEST_PHRASE, "suffix highlight")))
+            .toMatchInlineSnapshot(`
+            <div>
+              
+              
+              Prefix 
+              
+              <span
+                class="highlight"
+              >
+                Highlight
+              </span>
+              
+               
+              
+              <span
+                class="highlight"
+              >
+                Suffix
+              </span>
+            </div>
+        `);
+    });
+
+    it("should escape regexp search terms", () => {
+        expect(
+            getElementFromTemplate(
+                highlightFilterElements("Prefix (Highlight) Suffix", "(Highlight)")
+            )
+        ).toMatchInlineSnapshot(`
+            <div>
+              
+              
+              Prefix 
+              
+              <span
+                class="highlight"
+              >
+                (Highlight)
+              </span>
+              
+               Suffix
+            </div>
+        `);
+    });
+});
+
+function getElementFromTemplate(template: UpdateFunctionWithMethods<unknown>): HTMLElement {
+    const element = document.createElement("div");
+    template(document.body, element);
+
+    return element;
+}
diff --git a/plugins/agiledashboard/scripts/lib/card-fields/src/highlight-filter-template.ts b/plugins/agiledashboard/scripts/lib/card-fields/src/highlight-filter-template.ts
new file mode 100644
index 000000000000..878edf2461ea
--- /dev/null
+++ b/plugins/agiledashboard/scripts/lib/card-fields/src/highlight-filter-template.ts
@@ -0,0 +1,46 @@
+/**
+ * Copyright (c) Enalean, 2023-Present. All Rights Reserved.
+ *
+ * This file is a part of Tuleap.
+ *
+ * Tuleap is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * Tuleap is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Tuleap. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+import type { UpdateFunctionWithMethods } from "hybrids";
+import { html } from "hybrids";
+import { Classifier } from "./highlight/Classifier";
+import { HighlightedText } from "./highlight/HighlightedText";
+export function highlightFilterElements(
+    content: string | number | undefined,
+    search: string | number | undefined
+): UpdateFunctionWithMethods<unknown> {
+    const text_content = content?.toString();
+    if (
+        text_content === "" ||
+        text_content === undefined ||
+        search === "" ||
+        search === undefined
+    ) {
+        return html`${text_content}`;
+    }
+
+    const classifier = Classifier(search.toString());
+    const templates = classifier.classify(text_content).map((highlighted_text) => {
+        if (!HighlightedText.isHighlight(highlighted_text)) {
+            return html`${highlighted_text.content}`;
+        }
+        return html`<span class="highlight">${highlighted_text.content}</span>`;
+    });
+    return html`${templates}`;
+}
diff --git a/plugins/agiledashboard/scripts/lib/card-fields/src/highlight-filter.js b/plugins/agiledashboard/scripts/lib/card-fields/src/highlight-filter.js
index 87aca73a73fa..eea9d2bcb7ae 100644
--- a/plugins/agiledashboard/scripts/lib/card-fields/src/highlight-filter.js
+++ b/plugins/agiledashboard/scripts/lib/card-fields/src/highlight-filter.js
@@ -3,13 +3,11 @@
  * https://github.com/angular-ui/ui-utils/tree/d16cd00d129479eb1cde362bea034781b5bd1ff0/modules/highlight
  *
  * @license MIT
  */
 
-import { isNumber } from "angular";
-import { Classifier } from "./highlight/Classifier";
-import { HighlightedText } from "./highlight/HighlightedText";
+import { highlightFilterElements } from "./highlight-filter-template";
 
 export default TuleapHighlightFilter;
 
 TuleapHighlightFilter.$inject = [];
 
@@ -17,24 +15,19 @@ TuleapHighlightFilter.$inject = [];
  * @param text {string} haystack to search through
  * @param search {string} needle to search for
  * @returns HTML-encoded string
  */
 function TuleapHighlightFilter() {
-    function isTextSearchable(text, search) {
-        return text && (search || isNumber(search));
+    function getHTMLStringFromTemplate(template) {
+        const element = document.createElement("div");
+        template({}, element);
+
+        return element.innerHTML;
     }
 
     return function (text, search) {
-        if (!isTextSearchable(text, search)) {
-            return text ? text.toString() : text;
+        if (text === null) {
+            return null;
         }
-
-        const classifier = Classifier(String(search));
-        const parts = classifier.classify(String(text)).map((highlighted_text) => {
-            if (!HighlightedText.isHighlight(highlighted_text)) {
-                return highlighted_text.content;
-            }
-            return `<span class="highlight">${highlighted_text.content}</span>`;
-        });
-        return parts.join("");
+        return getHTMLStringFromTemplate(highlightFilterElements(text, search));
     };
 }
diff --git a/plugins/agiledashboard/scripts/lib/card-fields/src/index.js b/plugins/agiledashboard/scripts/lib/card-fields/src/index.js
index de316991277a..7721964da20d 100644
--- a/plugins/agiledashboard/scripts/lib/card-fields/src/index.js
+++ b/plugins/agiledashboard/scripts/lib/card-fields/src/index.js
@@ -1,7 +1,6 @@
 import angular from "angular";
-import ngSanitize from "angular-sanitize";
 import "../themes/main.scss";
 
 import HighlightFilter from "./highlight-filter.js";
 import CardFieldsDirective from "./card-fields-directive.js";
 import CardFieldsService from "./card-fields-service.js";
@@ -9,11 +8,11 @@ import tuleapSimpleFieldDirective from "./tuleap-simple-field-directive.js";
 import tuleapCardLinkDirective from "./tuleap-card-link-directive.js";
 import cardComputedFieldDirective from "./card-computed-field/card-computed-field-directive.js";
 import cardTextFieldDirective from "./card-text-field/card-text-field-directive.js";
 
 export default angular
-    .module("card-fields", [ngSanitize])
+    .module("card-fields", [])
     .service("CardFieldsService", CardFieldsService)
     .directive("cardFields", CardFieldsDirective)
     .directive("tuleapSimpleField", tuleapSimpleFieldDirective)
     .directive("tuleapCardLink", tuleapCardLinkDirective)
     .directive("cardComputedField", cardComputedFieldDirective)
diff --git a/plugins/agiledashboard/scripts/lib/card-fields/vite.config.ts b/plugins/agiledashboard/scripts/lib/card-fields/vite.config.ts
index 5775aa3ab111..0c9ea5fb3cf3 100644
--- a/plugins/agiledashboard/scripts/lib/card-fields/vite.config.ts
+++ b/plugins/agiledashboard/scripts/lib/card-fields/vite.config.ts
@@ -25,16 +25,15 @@ export default vite.defineLibConfig({
         lib: {
             entry: path.resolve(__dirname, "src/index.js"),
             name: "PluginAgileDashboardCardFields",
         },
         rollupOptions: {
-            external: ["angular", "angular-sanitize", "moment"],
+            external: ["angular", "hybrids"],
             output: {
                 globals: {
                     angular: "angular",
-                    "angular-sanitize": "angular-sanitize",
-                    moment: "moment",
+                    hybrids: "hybrids",
                 },
             },
         },
     },
 });

Vulnerable code:     "escape-string-regexp": "^2.0.0"
    "angular-sanitize": "^1.8.0",
    "angular-mocks": "^1.8.2",
    "angular-sanitize": "^1.8.2"
  angular-sanitize:
    specifier: ^1.8.2
    version: 1.8.2
  /angular-sanitize@1.8.2:
    resolution: {integrity: sha512-OB6Goa+QN3byf5asQ7XRl7DKZejm/F/ZOqa9z1skqYVOWA2hoBxoCmt9E7+i7T/TbxZP5zYzKxNZVVJNu860Hg==}
    dev: true

import moment from "moment";
CardFieldsService.$inject = ["$sce", "$filter"];

function CardFieldsService($sce, $filter) {
    const highlight = $filter("tuleapHighlight");
        getCardFieldDateValue,
                return getCardFieldUserValue(value, filter_terms);
            return highlight(value.label, filter_terms);
            const color = $sce.getTrustedHtml(`<span class="extra-card-field-color"
                style="background: rgb(${r}, ${g}, ${b})"></span>`);
            return color + highlight(value.label, filter_terms);
            const color = $sce.getTrustedHtml(
                `<span class="extra-card-field-color card-field-${tlp_color}"></span>`
            );
            return color + highlight(label, filter_terms);
        return $sce.trustAsHtml(values.map(getValueRendered).join(", "));
    }

    function getCardFieldDateValue(value) {
        return moment(value).fromNow();
            var file_name = highlight(file.name, filter_terms);

            return (
                '<a data-nodrag="true" href="' +
                getFileUrl(file) +
                '" title="' +
                file.description +
                '"><i class="fas fa-paperclip extra-card-field-file-icon"></i>' +
                file_name +
                "</a>"
            );
        return file_descriptions.map(getFileLink).join(", ");
    function getCardFieldPermissionsValue(values) {
        return values.join(", ");
    function getCardFieldUserValue(value, filter_terms) {
        let display_name;

            display_name = highlight(value.display_name, filter_terms);
            return `<div class="tlp-avatar-mini"> </div><span>${display_name}</span>`;
        display_name = highlight(value.display_name, filter_terms);
        return `<a data-nodrag="true" class="extra-card-field-user" href="${value.user_url}">
                            <div class="tlp-avatar-mini"><img src="${value.avatar_url}" /></div><span>${display_name}</span>
                        </a>`;
            <span ng-bind-html="card_ctrl.getCardFieldPermissionsValue(card_field.granted_groups) | tuleapHighlight:card_ctrl.filter_terms"></span>
import { isNumber } from "angular";
import { Classifier } from "./highlight/Classifier";
import { HighlightedText } from "./highlight/HighlightedText";
    function isTextSearchable(text, search) {
        return text && (search || isNumber(search));
        if (!isTextSearchable(text, search)) {
            return text ? text.toString() : text;

        const classifier = Classifier(String(search));
        const parts = classifier.classify(String(text)).map((highlighted_text) => {
            if (!HighlightedText.isHighlight(highlighted_text)) {
                return highlighted_text.content;
            }
            return `<span class="highlight">${highlighted_text.content}</span>`;
        });
        return parts.join("");
import ngSanitize from "angular-sanitize";
    .module("card-fields", [ngSanitize])
            external: ["angular", "angular-sanitize", "moment"],
                    "angular-sanitize": "angular-sanitize",
                    moment: "moment",
Fixed code:     "escape-string-regexp": "^2.0.0",
    "hybrids": "^8.2.1"
    "angular-mocks": "^1.8.2"
  hybrids:
    specifier: ^8.2.1
    version: 8.2.1

  /hybrids@8.2.1:
    resolution: {integrity: sha512-ZIjDLZi6aBOL+Cdry1xtBLJJnMzx99HNwmOCQXEvqdawyYu949z469vxgExyAgq0CA4lSfJsjh1rsiIsGdqXdw==}
    engines: {node: '>=18.0.0'}
    hasBin: true
    dev: false
import { html } from "hybrids";
import { highlightFilterElements } from "./highlight-filter-template";
CardFieldsService.$inject = ["$sce"];
function CardFieldsService($sce) {
                return getCardFieldUserValueTemplate(value, filter_terms);
            return highlightFilterElements(value.label, filter_terms);
            const styles = {
                background: `rgb(${r}, ${g}, ${b})`,
            };

            return html`<span class="extra-card-field-color" style="${styles}"></span
                >${highlightFilterElements(value.label, filter_terms)}`;
            const classlist = ["extra-card-field-color", `card-field-${tlp_color}`];
            return html`<span class="${classlist}"></span>${highlightFilterElements(
                    label,
                    filter_terms
                )}`;
        return getHTMLStringFromTemplate(renderListItems(values, getValueRendered));
            const file_display = html`<i class="fas fa-paperclip extra-card-field-file-icon"></i
                >${highlightFilterElements(file.name, filter_terms)}`;
            return html`<a data-nodrag="true" href="${getFileUrl(file)}" title="${file.description}"
                >${file_display}</a
            >`;
        return getHTMLStringFromTemplate(renderListItems(file_descriptions, getFileLink));
    function getCardFieldPermissionsValue(values, filter_terms) {
        return getHTMLStringFromTemplate(
            renderListItems(
                values,
                (value) => html`${highlightFilterElements(value, filter_terms)}`
            )
        );
    function getCardFieldUserValueTemplate(value, filter_terms) {
        const display_name = highlightFilterElements(value.display_name, filter_terms);
            return html`<div class="tlp-avatar-mini"></div>
                <span>${display_name}</span>`;
        return html`<a data-nodrag="true" class="extra-card-field-user" href="${value.user_url}">
            <div class="tlp-avatar-mini"><img loading="lazy" src="${value.avatar_url}" /></div>
            <span>${display_name}</span>
        </a>`;
    }

    function getCardFieldUserValue(value, filter_terms) {
        return getHTMLStringFromTemplate(getCardFieldUserValueTemplate(value, filter_terms));

    function renderListItems(items, render_item) {
        let templated_content = html``;

        for (const [i, file] of items.entries()) {
            if (i === 0) {
                templated_content = render_item(file);
            } else {
                templated_content = html`${templated_content}, ${render_item(file)}`;
            }
        }

        return templated_content;
    }

    function getHTMLStringFromTemplate(template) {
        const element = document.createElement("div");
        template({}, element);

        return $sce.trustAsHtml(element.innerHTML);
    }
            <span ng-bind-html="card_ctrl.getCardFieldPermissionsValue(card_field.granted_groups, card_ctrl.filter_terms)"></span>
/**
 * Copyright (c) Enalean, 2023-Present. All Rights Reserved.
 *
 * This file is a part of Tuleap.
 *
 * Tuleap is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * Tuleap is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Tuleap. If not, see <http://www.gnu.org/licenses/>.
 */

import { highlightFilterElements } from "./highlight-filter-template";
import type { UpdateFunctionWithMethods } from "hybrids";

jest.mock("escape-string-regexp", () => ({
    default: jest.requireActual("escape-string-regexp"),
}));

const TEST_PHRASE = "Prefix Highlight Suffix";

describe("highlight-filter-template", () => {
    it("should highlight a matching phrase", () => {
        expect(getElementFromTemplate(highlightFilterElements(TEST_PHRASE, "highlight")))
            .toMatchInlineSnapshot(`
            <div>
              
              
              Prefix 
              
              <span
                class="highlight"
              >
                Highlight
              </span>
              
               Suffix
            </div>
        `);
    });

    it("should highlight nothing if no match found", () => {
        expect(getElementFromTemplate(highlightFilterElements(TEST_PHRASE, "no match")))
            .toMatchInlineSnapshot(`
            <div>
              
              
              Prefix Highlight Suffix
            </div>
        `);
    });

    it("should highlight nothing for the undefined filter", () => {
        expect(getElementFromTemplate(highlightFilterElements(TEST_PHRASE, undefined)))
            .toMatchInlineSnapshot(`
            <div>
              Prefix Highlight Suffix
            </div>
        `);
    });

    it("should work correctly for number filters", () => {
        expect(getElementFromTemplate(highlightFilterElements("3210123", 0)))
            .toMatchInlineSnapshot(`
            <div>
              
              
              321
              
              <span
                class="highlight"
              >
                0
              </span>
              
              123
            </div>
        `);
    });

    it("should work when the text context is undefined", () => {
        expect(getElementFromTemplate(highlightFilterElements(undefined, "nothing")))
            .toMatchInlineSnapshot(`
            <div>
              
            </div>
        `);
    });

    it("should work correctly for number text", () => {
        expect(getElementFromTemplate(highlightFilterElements(3210123, "0")))
            .toMatchInlineSnapshot(`
            <div>
              
              
              321
              
              <span
                class="highlight"
              >
                0
              </span>
              
              123
            </div>
        `);
    });

    it("should highlight nothing if empty filter string passed", () => {
        expect(getElementFromTemplate(highlightFilterElements(TEST_PHRASE, "")))
            .toMatchInlineSnapshot(`
            <div>
              Prefix Highlight Suffix
            </div>
        `);
    });

    it("should highlight more that one element", () => {
        expect(getElementFromTemplate(highlightFilterElements(TEST_PHRASE, "gh")))
            .toMatchInlineSnapshot(`
            <div>
              
              
              Prefix Hi
              
              <span
                class="highlight"
              >
                gh
              </span>
              
              li
              
              <span
                class="highlight"
              >
                gh
              </span>
              
              t Suffix
            </div>
        `);
    });

    it("highlights each matching search terms", () => {
        expect(getElementFromTemplate(highlightFilterElements(TEST_PHRASE, "suffix highlight")))
            .toMatchInlineSnapshot(`
            <div>
              
              
              Prefix 
              
              <span
                class="highlight"
              >
                Highlight
              </span>
              
               
              
              <span
                class="highlight"
              >
                Suffix
              </span>
            </div>
        `);
    });

    it("should escape regexp search terms", () => {
        expect(
            getElementFromTemplate(
                highlightFilterElements("Prefix (Highlight) Suffix", "(Highlight)")
            )
        ).toMatchInlineSnapshot(`
            <div>
              
              
              Prefix 
              
              <span
                class="highlight"
              >
                (Highlight)
              </span>
              
               Suffix
            </div>
        `);
    });
});

function getElementFromTemplate(template: UpdateFunctionWithMethods<unknown>): HTMLElement {
    const element = document.createElement("div");
    template(document.body, element);

    return element;
}
/**
 * Copyright (c) Enalean, 2023-Present. All Rights Reserved.
 *
 * This file is a part of Tuleap.
 *
 * Tuleap is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * Tuleap is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Tuleap. If not, see <http://www.gnu.org/licenses/>.
 */

import type { UpdateFunctionWithMethods } from "hybrids";
import { html } from "hybrids";
import { Classifier } from "./highlight/Classifier";
import { HighlightedText } from "./highlight/HighlightedText";
export function highlightFilterElements(
    content: string | number | undefined,
    search: string | number | undefined
): UpdateFunctionWithMethods<unknown> {
    const text_content = content?.toString();
    if (
        text_content === "" ||
        text_content === undefined ||
        search === "" ||
        search === undefined
    ) {
        return html`${text_content}`;
    }

    const classifier = Classifier(search.toString());
    const templates = classifier.classify(text_content).map((highlighted_text) => {
        if (!HighlightedText.isHighlight(highlighted_text)) {
            return html`${highlighted_text.content}`;
        }
        return html`<span class="highlight">${highlighted_text.content}</span>`;
    });
    return html`${templates}`;
}
import { highlightFilterElements } from "./highlight-filter-template";
    function getHTMLStringFromTemplate(template) {
        const element = document.createElement("div");
        template({}, element);

        return element.innerHTML;
        if (text === null) {
            return null;
        return getHTMLStringFromTemplate(highlightFilterElements(text, search));
    .module("card-fields", [])
            external: ["angular", "hybrids"],
                    hybrids: "hybrids",