From 7636afbe91941c4c3d58b6f8d66d89402c073648 Mon Sep 17 00:00:00 2001 From: HollererJ Date: Sat, 14 Feb 2026 23:09:50 +0100 Subject: [PATCH 1/2] feat: Replace Agent Installers page with Agent Install Instructions page Replace the agent-installers download page with a new Install Instructions page that fetches and displays installation instructions from the agent-controller API. The page supports: - Multi-language (uses current GSA language setting) - Multiple agent controllers (dropdown when more than one available) - Dynamic content from agent-controller's install-instructions endpoint - Copy-to-clipboard functionality for code blocks --- src/web/Routes.tsx | 2 +- .../AgentInstallInstructionsPage.tsx | 296 ++++++++++++++++++ 2 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 src/web/pages/agent-remote-installer/AgentInstallInstructionsPage.tsx diff --git a/src/web/Routes.tsx b/src/web/Routes.tsx index 3a7e227e60..b7693579e3 100644 --- a/src/web/Routes.tsx +++ b/src/web/Routes.tsx @@ -58,7 +58,7 @@ const loggedInRoutes = [ path: 'agent-installers', lazy: async () => ({ Component: ( - await import('web/pages/agent-installers/AgentInstallerListPage') + await import('web/pages/agent-remote-installer/AgentInstallInstructionsPage') ).default, }), }, diff --git a/src/web/pages/agent-remote-installer/AgentInstallInstructionsPage.tsx b/src/web/pages/agent-remote-installer/AgentInstallInstructionsPage.tsx new file mode 100644 index 0000000000..560ade6596 --- /dev/null +++ b/src/web/pages/agent-remote-installer/AgentInstallInstructionsPage.tsx @@ -0,0 +1,296 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {useState, useEffect, useCallback} from 'react'; +import styled from 'styled-components'; +import {Spinner} from '@greenbone/ui-lib'; +import Layout from 'web/components/layout/Layout'; +import PageTitle from 'web/components/layout/PageTitle'; +import useTranslation from 'web/hooks/useTranslation'; +import useLanguage from 'web/hooks/useLanguage'; +import useGmp from 'web/hooks/useGmp'; +import {AGENT_CONTROLLER_SCANNER_TYPE} from 'gmp/models/scanner'; + +const extractStyles = (html: string): string => { + const match = html.match(/]*>([\s\S]*?)<\/style>/i); + return match ? `` : ''; +}; + +const extractBody = (html: string): string => { + const match = html.match(/]*>([\s\S]*)<\/body>/i); + return match ? match[1] : html; +}; + +interface AgentController { + id: string; + name: string; + host: string; + port: number; +} + +const CenteredLayout = styled(Layout)` + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; +`; + +const InstructionsContainer = styled.div` + padding: 16px; + background: white; + border-radius: 8px; + width: 100%; +`; + +const SelectorBar = styled.div` + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: #f5f5f5; + border-radius: 8px; + margin-bottom: 16px; +`; + +const SelectorLabel = styled.label` + font-weight: 500; + white-space: nowrap; +`; + +const SelectorSelect = styled.select` + padding: 6px 12px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 14px; + min-width: 250px; +`; + +const ErrorContainer = styled.div` + padding: 24px; + background: #ffebee; + border: 1px solid #ef5350; + border-radius: 8px; + margin: 16px 0; +`; + +const ErrorTitle = styled.h3` + color: #c62828; + margin: 0 0 8px 0; +`; + +const ErrorMessage = styled.p` + color: #b71c1c; + margin: 0 0 16px 0; +`; + +const RetryButton = styled.button` + background: #4caf50; + color: white; + border: none; + padding: 8px 20px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + + &:hover { + background: #45a049; + } +`; + +const AgentInstallInstructionsPage = () => { + const [_] = useTranslation(); + const [language] = useLanguage(); + const gmp = useGmp(); + const [instructionsHtml, setInstructionsHtml] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [controllers, setControllers] = useState([]); + const [selectedController, setSelectedController] = useState(''); + const [controllersLoading, setControllersLoading] = useState(true); + + // Fetch agent-controller scanners on mount + useEffect(() => { + const fetchScanners = async () => { + try { + const response = await gmp.scanners.getAll(); + const scanners = response?.data ?? []; + const agentControllers: AgentController[] = scanners + .filter( + (s: any) => + String(s.scannerType) === AGENT_CONTROLLER_SCANNER_TYPE, + ) + .map((s: any) => ({ + id: s.id, + name: s.name, + host: s.host, + port: s.port ?? 443, + })) + .sort((a: AgentController, b: AgentController) => { + // Local agent-control first (matches docker hostname) + const aLocal = a.host === 'agentcontrol' ? 0 : 1; + const bLocal = b.host === 'agentcontrol' ? 0 : 1; + if (aLocal !== bLocal) return aLocal - bLocal; + return a.name.localeCompare(b.name); + }); + setControllers(agentControllers); + if (agentControllers.length > 0) { + setSelectedController(agentControllers[0].id); + } + } catch { + // If scanner fetch fails, fall back to local agent-control + setControllers([]); + } finally { + setControllersLoading(false); + } + }; + fetchScanners(); + }, [gmp]); + + const getInstructionsUrl = useCallback( + (langCode: string) => { + const controller = controllers.find(c => c.id === selectedController); + if (controller) { + // Proxy through nginx: /agent-proxy/{host}/{port}/api/v1/... + return `/agent-proxy/${controller.host}/${controller.port}/api/v1/install-instructions?lang=${langCode}`; + } + // Fallback to local agent-control + return `/api/v1/install-instructions?lang=${langCode}`; + }, + [controllers, selectedController], + ); + + const fetchInstructions = useCallback( + async (lang: string) => { + const langCode = lang.split(/[-_]/)[0] || 'en'; + const url = getInstructionsUrl(langCode); + + try { + setLoading(true); + setError(null); + + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const html = await response.text(); + const styles = extractStyles(html); + const body = extractBody(html); + setInstructionsHtml(styles + body); + } catch (err) { + setError( + err instanceof Error ? err.message : _('Unknown error occurred'), + ); + } finally { + setLoading(false); + } + }, + [_, getInstructionsUrl], + ); + + // Fetch instructions when language or selected controller changes + useEffect(() => { + if (language && !controllersLoading) { + fetchInstructions(language); + } + }, [language, selectedController, controllersLoading, fetchInstructions]); + + // Attach click handlers to copy buttons after HTML is rendered + useEffect(() => { + if (!instructionsHtml || loading) return; + + const copyToClipboard = async (btn: HTMLButtonElement) => { + const pre = btn.previousElementSibling; + if (!pre) return; + + const text = pre.textContent || ''; + const originalText = btn.textContent || 'Copy'; + const copiedText = language.startsWith('de') ? 'Kopiert!' : 'Copied!'; + + try { + await navigator.clipboard.writeText(text); + } catch { + // Fallback for older browsers + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.cssText = 'position:fixed;left:-9999px'; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + } + + btn.textContent = copiedText; + btn.classList.add('copied'); + setTimeout(() => { + btn.textContent = originalText; + btn.classList.remove('copied'); + }, 2000); + }; + + const handleClick = (e: MouseEvent) => { + const target = e.target as HTMLElement; + if (target.classList.contains('copy-btn')) { + copyToClipboard(target as HTMLButtonElement); + } + }; + + document.addEventListener('click', handleClick); + return () => document.removeEventListener('click', handleClick); + }, [instructionsHtml, loading, language]); + + const handleControllerChange = (e: React.ChangeEvent) => { + setSelectedController(e.target.value); + }; + + return ( + <> + + + {controllers.length > 1 && ( + + {_('Agent Controller')}: + + {controllers.map(c => ( + + ))} + + + )} + + {(loading || controllersLoading) && ( + + + + )} + + {error && !loading && ( + + {_('Could not load install instructions')} + {error} + fetchInstructions(language)}> + {_('Retry')} + + + )} + + {!loading && !controllersLoading && !error && ( + + )} + + + ); +}; + +export default AgentInstallInstructionsPage; From dc136ddcf972975078571bf649793d5cbdf30a05 Mon Sep 17 00:00:00 2001 From: HollererJ Date: Mon, 16 Feb 2026 16:39:05 +0100 Subject: [PATCH 2/2] feat: Add DOMPurify for sanitizing agent installation instructions Integrate DOMPurify to sanitize dynamically fetched agent installation instructions, improving security against XSS attacks. Additionally: - Enhanced the `Agent Install Instructions` page with better error handling and safety measures. - Introduced language-specific support for "Copy" and "Copied!" buttons. - Refactored and improved loading logic when changing agent controllers or languages. --- package-lock.json | 81 +++++++++++---- package.json | 2 + public/locales/gsa-de.json | 6 ++ public/locales/gsa-en.json | 6 ++ public/locales/gsa-zh_CN.json | 6 ++ public/locales/gsa-zh_TW.json | 6 ++ .../AgentInstallInstructionsPage.tsx | 99 ++++++++++++------- 7 files changed, 153 insertions(+), 53 deletions(-) diff --git a/package-lock.json b/package-lock.json index 156d088516..5efb62dab7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "d3-scale": "^4.0.2", "d3-shape": "^3.2.0", "dayjs": "^1.11.19", + "dompurify": "^3.3.1", "fast-deep-equal": "^3.1.3", "fast-xml-parser": "^5.3.4", "hoist-non-react-statics": "^3.3.2", @@ -61,6 +62,7 @@ "@types/d3-cloud": "^1.2.9", "@types/d3-force": "^3.0.10", "@types/d3-hierarchy": "^3.1.7", + "@types/dompurify": "^3.0.5", "@types/node": "^25.2.0", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", @@ -224,6 +226,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1853,6 +1856,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1876,6 +1880,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -3273,7 +3278,6 @@ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", "dev": true, - "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -3300,6 +3304,7 @@ "resolved": "https://registry.npmjs.org/@mantine/core/-/core-7.17.8.tgz", "integrity": "sha512-42sfdLZSCpsCYmLCjSuntuPcDg3PLbakSmmYfz5Auea8gZYLr+8SS5k647doVu0BRAecqYOytkX2QC5/u/8VHw==", "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/react": "^0.26.28", "clsx": "^2.1.1", @@ -3335,6 +3340,7 @@ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.17.8.tgz", "integrity": "sha512-96qygbkTjRhdkzd5HDU8fMziemN/h758/EwrFu7TlWrEP10Vw076u+Ap/sG6OT4RGPZYYoHrTlT+mkCZblWHuw==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^18.x || ^19.x" } @@ -4374,8 +4380,7 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4545,6 +4550,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4582,6 +4597,7 @@ "integrity": "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4601,6 +4617,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -4612,6 +4629,7 @@ "integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4641,6 +4659,13 @@ "integrity": "sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA==", "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -4692,6 +4717,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -5304,6 +5330,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5365,7 +5392,6 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, - "peer": true, "engines": { "node": ">=8" } @@ -5700,6 +5726,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001737", "electron-to-chromium": "^1.5.211", @@ -5735,8 +5762,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "peer": true + "dev": true }, "node_modules/call-bind": { "version": "1.0.8", @@ -6348,7 +6374,8 @@ "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/debug": { "version": "4.4.3", @@ -6459,8 +6486,7 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "peer": true + "dev": true }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -6471,6 +6497,15 @@ "csstype": "^3.0.2" } }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dot-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", @@ -6774,6 +6809,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6834,6 +6870,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7812,6 +7849,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -8559,6 +8597,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -8793,7 +8832,6 @@ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -9506,6 +9544,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9534,7 +9573,6 @@ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -9549,7 +9587,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, - "peer": true, "engines": { "node": ">=10" }, @@ -9561,8 +9598,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "peer": true + "dev": true }, "node_modules/pretty-ms": { "version": "9.3.0", @@ -9629,6 +9665,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -9640,6 +9677,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -9712,6 +9750,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -9940,7 +9979,8 @@ "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "peer": true }, "node_modules/redux-logger": { "version": "3.0.6", @@ -10133,6 +10173,7 @@ "integrity": "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -10479,7 +10520,6 @@ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, - "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -10490,7 +10530,6 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -10876,8 +10915,7 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "peer": true + "dev": true }, "node_modules/tiny-case": { "version": "1.0.3", @@ -10947,6 +10985,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11165,6 +11204,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11444,6 +11484,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -11583,6 +11624,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11625,6 +11667,7 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", diff --git a/package.json b/package.json index a4113e5c74..033eac19e6 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "d3-scale": "^4.0.2", "d3-shape": "^3.2.0", "dayjs": "^1.11.19", + "dompurify": "^3.3.1", "fast-deep-equal": "^3.1.3", "fast-xml-parser": "^5.3.4", "hoist-non-react-statics": "^3.3.2", @@ -88,6 +89,7 @@ "@types/d3-cloud": "^1.2.9", "@types/d3-force": "^3.0.10", "@types/d3-hierarchy": "^3.1.7", + "@types/dompurify": "^3.0.5", "@types/node": "^25.2.0", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", diff --git a/public/locales/gsa-de.json b/public/locales/gsa-de.json index 45159b49d5..edd26f8b51 100644 --- a/public/locales/gsa-de.json +++ b/public/locales/gsa-de.json @@ -175,6 +175,7 @@ "Agents by Network (Total: {{count}})": "Agenten nach Netzwerk (Gesamt: {{count}})", "Agents by Severity Class (Total: {{count}})": "Agenten nach Schweregradklasse (Gesamt: {{count}})", "Agents Filter": "Agenten-Filter", + "Agents Installation": "Agenten-Installation", "Agents successfully authorized": "Agenten erfolgreich autorisiert", "Agents successfully deleted": "Agenten erfolgreich gelöscht", "Agents successfully revoked": "Agenten erfolgreich widerrufen", @@ -482,6 +483,8 @@ "Content": "Inhalte", "Content Type": "Inhaltstyp", "Contents": "Inhalte", + "Copied!": "Kopiert", + "Copy": "Kopieren", "Copy Agent Installer checksum to clipboard": "Agent-Installationsprogramm-Prüfsumme in die Zwischenablage kopieren", "Corresponding Performance": "Zugehörige Leistungsdaten", "Corresponding Report": "Zugehöriger Bericht", @@ -494,6 +497,7 @@ "Corresponding Vulnerabilities": "Zugehörige Schwachstellen", "Could not connect to server": "Verbindung zum Server konnte nicht hergestellt werden", "Could not load dashboard settings. Reason: {{error}}": "Konnte Dashboardeinstellungen nicht laden. Grund: {{error}}", + "Could not load install instructions": "Installationsanleitung konnte nicht geladen werden", "Count": "Anzahl", "CPE": "CPE", "CPE Filter": "CPE-Filter", @@ -1681,6 +1685,7 @@ "Results with the severity \"Medium\" are currently included.": "Ergebnisse mit dem Schweregrad \"Mittel\" werden derzeit miteinbezogen.", "Resume": "Fortsetzen", "Resume Requested": "Fortsetzen angefragt", + "Retry": "Wiederholen", "Reverse Lookup Only": "Nur Invers-Lookup", "Reverse Lookup Unify": "Invers-Lookup-Vereinheitlichung", "Revision": "Revision", @@ -2143,6 +2148,7 @@ "Unique ID of the application issuing password requests (required)": "Eindeutige ID der Anwendung, die Passwort-Anfragen stellt (erforderlich)", "Unknown": "Unbekannt", "Unknown Error": "Unbekannter Fehler", + "Unknown error occurred": "Unbekannter Fehler aufgetreten", "Unknown error on login.": "Unbekannter Fehler beim Login.", "Unknown scanner type": "Unbekannter Scanner-Typ", "Unknown scanner type ({{type}})": "Unbekannter Scanner-Typ ({{type}})", diff --git a/public/locales/gsa-en.json b/public/locales/gsa-en.json index 1f8a7162b1..9d830fb8a8 100644 --- a/public/locales/gsa-en.json +++ b/public/locales/gsa-en.json @@ -175,6 +175,7 @@ "Agents by Network (Total: {{count}})": "Agents by Network (Total: {{count}})", "Agents by Severity Class (Total: {{count}})": "Agents by Severity Class (Total: {{count}})", "Agents Filter": "Agents Filter", + "Agents Installation": "", "Agents successfully authorized": "Agents successfully authorized", "Agents successfully deleted": "Agents successfully deleted", "Agents successfully revoked": "Agents successfully revoked", @@ -482,6 +483,8 @@ "Content": "Content", "Content Type": "Content Type", "Contents": "Contents", + "Copied!": "", + "Copy": "", "Copy Agent Installer checksum to clipboard": "Copy Agent Installer checksum to clipboard", "Corresponding Performance": "Corresponding Performance", "Corresponding Report": "Corresponding Report", @@ -494,6 +497,7 @@ "Corresponding Vulnerabilities": "Corresponding Vulnerabilities", "Could not connect to server": "Could not connect to server", "Could not load dashboard settings. Reason: {{error}}": "Could not load dashboard settings. Reason: {{error}}", + "Could not load install instructions": "", "Count": "Count", "CPE": "CPE", "CPE Filter": "CPE Filter", @@ -1681,6 +1685,7 @@ "Results with the severity \"Medium\" are currently included.": "Results with the severity \"Medium\" are currently included.", "Resume": "Resume", "Resume Requested": "Resume Requested", + "Retry": "", "Reverse Lookup Only": "Reverse Lookup Only", "Reverse Lookup Unify": "Reverse Lookup Unify", "Revision": "Revision", @@ -2143,6 +2148,7 @@ "Unique ID of the application issuing password requests (required)": "Unique ID of the application issuing password requests (required)", "Unknown": "Unknown", "Unknown Error": "Unknown Error", + "Unknown error occurred": "", "Unknown error on login.": "Unknown error on login.", "Unknown scanner type": "Unknown scanner type", "Unknown scanner type ({{type}})": "Unknown scanner type ({{type}})", diff --git a/public/locales/gsa-zh_CN.json b/public/locales/gsa-zh_CN.json index b91307502a..b920bdb81f 100644 --- a/public/locales/gsa-zh_CN.json +++ b/public/locales/gsa-zh_CN.json @@ -175,6 +175,7 @@ "Agents by Network (Total: {{count}})": "按网络分类的代理(总数:{{count}})", "Agents by Severity Class (Total: {{count}})": "按严重级别分类的代理(总数:{{count}})", "Agents Filter": "代理筛选", + "Agents Installation": "代理安装", "Agents successfully authorized": "代理授权成功", "Agents successfully deleted": "代理删除成功", "Agents successfully revoked": "代理吊销成功", @@ -482,6 +483,8 @@ "Content": "内容", "Content Type": "内容类型", "Contents": "内容", + "Copied!": "已复制!", + "Copy": "复制", "Copy Agent Installer checksum to clipboard": "复制代理安装程序校验和到剪贴板", "Corresponding Performance": "此报告的性能", "Corresponding Report": "所属报告", @@ -494,6 +497,7 @@ "Corresponding Vulnerabilities": "此报告的漏洞", "Could not connect to server": "无法连接到服务器", "Could not load dashboard settings. Reason: {{error}}": "无法加载仪表板设置.原因:{{error}}", + "Could not load install instructions": "无法加载安装说明", "Count": "数量", "CPE": "CPE", "CPE Filter": "CPE筛选", @@ -1681,6 +1685,7 @@ "Results with the severity \"Medium\" are currently included.": "目前包括严重性为\"中危\"的结果.", "Resume": "继续", "Resume Requested": "恢复请求", + "Retry": "重试", "Reverse Lookup Only": "仅反向查找", "Reverse Lookup Unify": "反向查找统一", "Revision": "修订", @@ -2143,6 +2148,7 @@ "Unique ID of the application issuing password requests (required)": "发出密码请求的应用程序的唯一ID(必填)", "Unknown": "未知", "Unknown Error": "未知错误", + "Unknown error occurred": "发生未知错误", "Unknown error on login.": "登录时出现未知错误.", "Unknown scanner type": "未知扫描器类型", "Unknown scanner type ({{type}})": "未知扫描器类型({{type}})", diff --git a/public/locales/gsa-zh_TW.json b/public/locales/gsa-zh_TW.json index f2112095de..5bedb32c88 100644 --- a/public/locales/gsa-zh_TW.json +++ b/public/locales/gsa-zh_TW.json @@ -175,6 +175,7 @@ "Agents by Network (Total: {{count}})": "", "Agents by Severity Class (Total: {{count}})": "", "Agents Filter": "", + "Agents Installation": "代理程式安裝", "Agents successfully authorized": "", "Agents successfully deleted": "", "Agents successfully revoked": "", @@ -482,6 +483,8 @@ "Content": "內容", "Content Type": "內容類型", "Contents": "", + "Copied!": "已複製!", + "Copy": "複製", "Copy Agent Installer checksum to clipboard": "", "Corresponding Performance": "", "Corresponding Report": "", @@ -494,6 +497,7 @@ "Corresponding Vulnerabilities": "", "Could not connect to server": "", "Could not load dashboard settings. Reason: {{error}}": "", + "Could not load install instructions": "無法載入安裝說明", "Count": "", "CPE": "CPE", "CPE Filter": "", @@ -1681,6 +1685,7 @@ "Results with the severity \"Medium\" are currently included.": "", "Resume": "", "Resume Requested": "", + "Retry": "重試", "Reverse Lookup Only": "", "Reverse Lookup Unify": "", "Revision": "", @@ -2143,6 +2148,7 @@ "Unique ID of the application issuing password requests (required)": "", "Unknown": "未知", "Unknown Error": "", + "Unknown error occurred": "發生未知錯誤", "Unknown error on login.": "", "Unknown scanner type": "", "Unknown scanner type ({{type}})": "", diff --git a/src/web/pages/agent-remote-installer/AgentInstallInstructionsPage.tsx b/src/web/pages/agent-remote-installer/AgentInstallInstructionsPage.tsx index 560ade6596..0f39386547 100644 --- a/src/web/pages/agent-remote-installer/AgentInstallInstructionsPage.tsx +++ b/src/web/pages/agent-remote-installer/AgentInstallInstructionsPage.tsx @@ -3,15 +3,32 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import {useState, useEffect, useCallback} from 'react'; -import styled from 'styled-components'; +import { + useState, + useEffect, + useCallback, + useRef, + type ChangeEvent, +} from 'react'; import {Spinner} from '@greenbone/ui-lib'; +import DOMPurify from 'dompurify'; +import styled from 'styled-components'; +import { + type default as Scanner, + AGENT_CONTROLLER_SCANNER_TYPE, +} from 'gmp/models/scanner'; import Layout from 'web/components/layout/Layout'; import PageTitle from 'web/components/layout/PageTitle'; -import useTranslation from 'web/hooks/useTranslation'; -import useLanguage from 'web/hooks/useLanguage'; import useGmp from 'web/hooks/useGmp'; -import {AGENT_CONTROLLER_SCANNER_TYPE} from 'gmp/models/scanner'; +import useLanguage from 'web/hooks/useLanguage'; +import useTranslation from 'web/hooks/useTranslation'; + +interface AgentController { + id: string; + name: string; + host: string; + port: number; +} const extractStyles = (html: string): string => { const match = html.match(/]*>([\s\S]*?)<\/style>/i); @@ -23,13 +40,6 @@ const extractBody = (html: string): string => { return match ? match[1] : html; }; -interface AgentController { - id: string; - name: string; - host: string; - port: number; -} - const CenteredLayout = styled(Layout)` display: flex; align-items: center; @@ -109,6 +119,7 @@ const AgentInstallInstructionsPage = () => { const [controllers, setControllers] = useState([]); const [selectedController, setSelectedController] = useState(''); const [controllersLoading, setControllersLoading] = useState(true); + const instructionsContainerRef = useRef(null); // Fetch agent-controller scanners on mount useEffect(() => { @@ -118,16 +129,19 @@ const AgentInstallInstructionsPage = () => { const scanners = response?.data ?? []; const agentControllers: AgentController[] = scanners .filter( - (s: any) => - String(s.scannerType) === AGENT_CONTROLLER_SCANNER_TYPE, + (s: Scanner) => + String(s.scannerType) === AGENT_CONTROLLER_SCANNER_TYPE && + s.id !== undefined && + s.name !== undefined && + s.host !== undefined, ) - .map((s: any) => ({ - id: s.id, - name: s.name, - host: s.host, + .map((s: Scanner) => ({ + id: s.id as string, + name: s.name as string, + host: s.host as string, port: s.port ?? 443, })) - .sort((a: AgentController, b: AgentController) => { + .sort((a, b) => { // Local agent-control first (matches docker hostname) const aLocal = a.host === 'agentcontrol' ? 0 : 1; const bLocal = b.host === 'agentcontrol' ? 0 : 1; @@ -145,18 +159,21 @@ const AgentInstallInstructionsPage = () => { setControllersLoading(false); } }; - fetchScanners(); + void fetchScanners(); }, [gmp]); const getInstructionsUrl = useCallback( (langCode: string) => { const controller = controllers.find(c => c.id === selectedController); + const encodedLang = encodeURIComponent(langCode); if (controller) { // Proxy through nginx: /agent-proxy/{host}/{port}/api/v1/... - return `/agent-proxy/${controller.host}/${controller.port}/api/v1/install-instructions?lang=${langCode}`; + // Encode host to handle IPv6 addresses and special characters safely + const encodedHost = encodeURIComponent(controller.host); + return `/agent-proxy/${encodedHost}/${controller.port}/api/v1/install-instructions?lang=${encodedLang}`; } // Fallback to local agent-control - return `/api/v1/install-instructions?lang=${langCode}`; + return `/api/v1/install-instructions?lang=${encodedLang}`; }, [controllers, selectedController], ); @@ -179,7 +196,15 @@ const AgentInstallInstructionsPage = () => { const html = await response.text(); const styles = extractStyles(html); const body = extractBody(html); - setInstructionsHtml(styles + body); + // Sanitize only the body HTML to prevent XSS attacks + // Styles are kept as-is (CSS injection is low risk compared to HTML/JS) + const sanitizedBody = DOMPurify.sanitize(body, { + ADD_ATTR: ['class', 'data-clipboard-text'], + FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'style'], + FORBID_ATTR: ['onerror', 'onclick', 'onload', 'onmouseover'], + }); + // Combine styles with sanitized body + setInstructionsHtml(styles + sanitizedBody); } catch (err) { setError( err instanceof Error ? err.message : _('Unknown error occurred'), @@ -194,21 +219,23 @@ const AgentInstallInstructionsPage = () => { // Fetch instructions when language or selected controller changes useEffect(() => { if (language && !controllersLoading) { - fetchInstructions(language); + void fetchInstructions(language); } }, [language, selectedController, controllersLoading, fetchInstructions]); // Attach click handlers to copy buttons after HTML is rendered + // Scoped to the instructions container to avoid global event handling useEffect(() => { - if (!instructionsHtml || loading) return; + const container = instructionsContainerRef.current; + if (!instructionsHtml || loading || !container) return; const copyToClipboard = async (btn: HTMLButtonElement) => { const pre = btn.previousElementSibling; if (!pre) return; const text = pre.textContent || ''; - const originalText = btn.textContent || 'Copy'; - const copiedText = language.startsWith('de') ? 'Kopiert!' : 'Copied!'; + const originalText = btn.textContent || _('Copy'); + const copiedText = _('Copied!'); try { await navigator.clipboard.writeText(text); @@ -234,15 +261,15 @@ const AgentInstallInstructionsPage = () => { const handleClick = (e: MouseEvent) => { const target = e.target as HTMLElement; if (target.classList.contains('copy-btn')) { - copyToClipboard(target as HTMLButtonElement); + void copyToClipboard(target as HTMLButtonElement); } }; - document.addEventListener('click', handleClick); - return () => document.removeEventListener('click', handleClick); - }, [instructionsHtml, loading, language]); + container.addEventListener('click', handleClick); + return () => container.removeEventListener('click', handleClick); + }, [_, instructionsHtml, loading]); - const handleControllerChange = (e: React.ChangeEvent) => { + const handleControllerChange = (e: ChangeEvent) => { setSelectedController(e.target.value); }; @@ -252,11 +279,14 @@ const AgentInstallInstructionsPage = () => { {controllers.length > 1 && ( - {_('Agent Controller')}: + + {_('Agent Controller')}: + {controllers.map(c => (