<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.8.7">Jekyll</generator><link href="https://blog.cronn.de/feed.xml" rel="self" type="application/atom+xml" /><link href="https://blog.cronn.de/" rel="alternate" type="text/html" /><updated>2026-04-02T03:18:56-05:00</updated><id>https://blog.cronn.de/feed.xml</id><title type="html">wir bloggen über software_</title><subtitle>Im cronn Blog findet ihr Artikel zu Softwareentwicklung mit den neusten Technologien, zu coolen UI/UX-Designs, automatisierten Tests, aber auch zum Life-Style und Work-Life-Balance - alles &quot;the cronn way&quot;.</subtitle><entry xml:lang="en"><title type="html">Setting Up An ML HPC Server (Part 2 – Driver Setup and Running Language Models with llama.cpp)</title><link href="https://blog.cronn.de/en/machinelearning/2026/01/27/setting-up-ml-hpc-server-2.html" rel="alternate" type="text/html" title="Setting Up An ML HPC Server (Part 2 – Driver Setup and Running Language Models with llama.cpp)" /><published>2026-01-27T00:00:00-06:00</published><updated>2026-01-27T00:00:00-06:00</updated><id>https://blog.cronn.de/en/machinelearning/2026/01/27/setting-up-ml-hpc-server-2</id><content type="html" xml:base="https://blog.cronn.de/en/machinelearning/2026/01/27/setting-up-ml-hpc-server-2.html">&lt;h3 id=&quot;recap&quot;&gt;Recap&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.cronn.de/en/machinelearning/2025/12/03/setting-up-ml-hpc-server-1.html&quot;&gt;In Part 1&lt;/a&gt;, we showed you how we set up the hardware for our new HPC server. In Part 2, we will now continue with the software.&lt;/p&gt;

&lt;h3 id=&quot;installing-the-cuda-toolkit-and-nvidia-drivers&quot;&gt;Installing the CUDA Toolkit and NVIDIA Drivers&lt;/h3&gt;
&lt;p&gt;While NVIDIA drivers are provided in the non-free (restricted) Ubuntu repository, they turn out to be outdated. We therefore take the current drivers from NVIDIA:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;NVIDIA provides a package repository for Ubuntu. Installation is thus possible without any special effort.&lt;/li&gt;
  &lt;li&gt;For CUDA, NVIDIA ensures a high degree of backward compatibility. The CUDA version 12.8, which was the most up-to-date at the time, supports the much older GPU architecture “Pascal” of the Tesla P40 GPUs.&lt;/li&gt;
  &lt;li&gt;An up-to-date version of CUDA and all required drivers is a prerequisite for working with the latest AI tools.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href=&quot;https://docs.nvidia.com/cuda/cuda-installation-guide-linux/index.html&quot;&gt;NVIDIA Installation Guide&lt;/a&gt; for the CUDA toolkit lists various possibilities and paths:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;em&gt;Package Manager vs. Runfile Installation:&lt;/em&gt; Installation via the Package Manager is more convenient and has better system integration.&lt;/li&gt;
  &lt;li&gt;&lt;em&gt;Local Repo Installation vs. Network Repo Installation:&lt;/em&gt; As long as the machine has internet access, the network repo option is better. This gives us the latest updates from NVIDIA via apt upgrade.&lt;/li&gt;
  &lt;li&gt;We choose the &lt;em&gt;proprietary packages&lt;/em&gt; and not the &lt;em&gt;open source packages&lt;/em&gt;, because in many respects the open source implementation is still lagging behind.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our first step should be to check whether any packages are already installed which could lead to conflicts:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ dpkg -l | grep nvidia
$ dpkg -l | grep cuda
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This output should be empty. If it is not, any existing packages must be uninstalled.&lt;/p&gt;

&lt;p&gt;According to &lt;a href=&quot;https://docs.nvidia.com/cuda/cuda-installation-guide-linux/index.html#network-repo-installation-for-ubuntu&quot;&gt;section 3.8.3&lt;/a&gt; from the installation guide, the GPG key and the repository can both be set up by installing a deb package. (For $UBUNTU_VERSION, the respective version is followed by the pattern &lt;code class=&quot;highlighter-rouge&quot;&gt;&quot;ubuntu2404&quot;&lt;/code&gt;.)&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ wget https://developer.download.nvidia.com/compute/cuda/repos/$UBUNTU_VERSION/x86_64/cuda-keyring_1.1-1_all.deb

# dpkg -i cuda-keyring_1.1-1_all.deb
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The repository is then automatically created under &lt;code class=&quot;highlighter-rouge&quot;&gt;/etc/apt/sources.list.d/cuda-ubuntu2404-x86_64.list&lt;/code&gt;.
After an &lt;code class=&quot;highlighter-rouge&quot;&gt;apt update&lt;/code&gt;, the only requirement to install the CUDA toolkit is first installing a meta-package:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# apt install cuda-toolkit
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The second step is to install the kernel modules. NVIDIA provides &lt;a href=&quot;https://docs.nvidia.com/datacenter/tesla/driver-installation-guide/index.html&quot;&gt;another guide&lt;/a&gt; for this. The repository is already set up, the meta package can be installed directly:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# apt install cuda-drivers
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;This also installs a whole range of packages that are only needed for desktops with displays. At the moment a compute-only variant is offered for Fedora, Suse and Debian, but not yet for Ubuntu.
That’s it. After a reboot, all drivers should be set up.&lt;/p&gt;

&lt;p&gt;The CUDA binaries are located in /usr/local/cuda-12.8/bin and should &lt;a href=&quot;https://docs.nvidia.com/cuda/cuda-installation-guide-linux/index.html#environment-setup&quot;&gt;be included in the PATH&lt;/a&gt; as described in Section 10.1.1 Environment Setup.
An extension of &lt;code class=&quot;highlighter-rouge&quot;&gt;LD_LIBRARY_PATH&lt;/code&gt; should not be necessary, as the configuration has already been done by the corresponding Ubuntu package &lt;code class=&quot;highlighter-rouge&quot;&gt;(/etc/ld.so.conf.d/988_cuda-12.conf)&lt;/code&gt;.&lt;/p&gt;

&lt;h3 id=&quot;verification&quot;&gt;Verification&lt;/h3&gt;

&lt;p&gt;Below you will find a few useful commands which can be used to verify the installation.
To verify that the desired driver version has been loaded:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ cat /proc/driver/nvidia/version

NVRM version: NVIDIA UNIX x86_64 Kernel Module  570.133.20  Sun Apr 13 04:50:56 UTC 2025
GCC version:  gcc version 13.3.0 (Ubuntu 13.3.0-6ubuntu2~24.04)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;To verify that the CUDA-Complier has been installed:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ nvcc –version
nvcc: NVIDIA (R) Cuda compiler driver Copyright (c) 2005-2025 NVIDIA Corporation Built on Fri_Feb_21_20:23:50_PST_2025 Cuda compilation tools, release 12.8, V12.8.93 Build cuda_12.8.r12.8/compiler.35583870_0
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;To access the NVIDIA &lt;strong&gt;System Management Interface (SMI)&lt;/strong&gt;:&lt;/p&gt;
&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ nvidia-smi`
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 570.124.06             Driver Version: 570.124.06     CUDA Version: 12.8     |
|-----------------------------------------+------------------------+----------------------+
| GPU  Name                 Persistence-M | Bus-Id          Disp.A | Volatile Uncorr. ECC |
| Fan  Temp   Perf          Pwr:Usage/Cap |           Memory-Usage | GPU-Util  Compute M. |
|                                         |                        |               MIG M. |
|=========================================+========================+======================|
|   0  Tesla P40                      Off |   00000000:04:00.0 Off |                  Off |
| N/A   23C    P8              9W /  250W |       5MiB /  24576MiB |      0%      Default |
|                                         |                        |                  N/A |
+-----------------------------------------+------------------------+----------------------+
|   1  Tesla P40                      Off |   00000000:05:00.0 Off |                  Off |
| N/A   24C    P8              9W /  250W |       5MiB /  24576MiB |      0%      Default |
|                                         |                        |                  N/A |
+-----------------------------------------+------------------------+----------------------+
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;getting-started&quot;&gt;Getting Started&lt;/h3&gt;

&lt;p&gt;For a stress test of the graphics cards, we use &lt;code class=&quot;highlighter-rouge&quot;&gt;gpu-burn&lt;/code&gt;. As the name implies, this pushes the power consumption of the GPUs almost to the limit of 250 watts per unit.
In the iDRAC you can see the impressive thermal effects this has: The temperature in the chassis (which has a volume of approx. 16 liters) rapidly rises to 60 °C.  You don’t need maintenance software to notice this: The fans become noticeably louder, and it begins to smell iffy.&lt;/p&gt;

&lt;h3 id=&quot;inference-with-llamacpp&quot;&gt;Inference with llama.cpp&lt;/h3&gt;

&lt;p&gt;There is a number of software options which run current open source language models. Examples include KoboldCpp and ollama, both of which have one thing in common: they rely on the library llama.cpp, which does all the “hard work” in the background.&lt;/p&gt;

&lt;p&gt;For initial tests, it is a good idea to work directly with llama.cpp – for several reasons:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Maximum control during configuration and optimization&lt;/li&gt;
  &lt;li&gt;Detailed output of parameters and hardware properties&lt;/li&gt;
  &lt;li&gt;New models often require up-to-date features – which usually are added to &lt;strong&gt;llama.cpp&lt;/strong&gt; first&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is recommended to compile llama.cpp directly from the sources:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ cmake -B build -DGGML_CUDA=ON -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/opt/llama-cpp -DLLAMA_BUILD_EXAMPLES=ON -DLLAMA_BUILD_SERVER=ON
$ cmake --build build --config Release -j 16
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Optionally, install the binaries according to &lt;code class=&quot;highlighter-rouge&quot;&gt;/opt/llama-cpp&lt;/code&gt;:&lt;/p&gt;
&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ sudo cmake --install build
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;Compiling from sources&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;blockquote&gt;
  &lt;p&gt;When compiling from sources, it is not uncommon to be confronted by cryptic errors. However, this is not a reason for concern.&lt;/p&gt;
  &lt;ul&gt;
    &lt;li&gt;The basic prerequisite is development tools such as cmake and g++, which must be installed on the system. The build-essential meta-package, which bundles the most important tools, is recommended.&lt;/li&gt;
    &lt;li&gt;More often than not so-called dev packages for required libraries are also missing. Unfortunately, it is often not specified directly which package has to be installed – this information must be &amp;gt; derived from the error message.
For example, &lt;code class=&quot;highlighter-rouge&quot;&gt;llama.cpp&lt;/code&gt; expects the &lt;code class=&quot;highlighter-rouge&quot;&gt;curl&lt;/code&gt; development files that are included in the &lt;code class=&quot;highlighter-rouge&quot;&gt;libcurl4-openssl-dev&lt;/code&gt; package.&lt;/li&gt;
  &lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;download-the-models&quot;&gt;Download the models&lt;/h3&gt;
&lt;p&gt;Language models can be found on &lt;a href=&quot;https://huggingface.co/&quot;&gt;https://huggingface.co/&lt;/a&gt;. To use a model in &lt;code class=&quot;highlighter-rouge&quot;&gt;llama.cpp&lt;/code&gt;, it must be in gguf format.
You can easily do the conversion yourself. For this purpose, &lt;code class=&quot;highlighter-rouge&quot;&gt;llama.cpp&lt;/code&gt; includes the tools &lt;code class=&quot;highlighter-rouge&quot;&gt;convert_hf_to_gguf.py&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;llama-quantize&lt;/code&gt;. For popular models, however, you can often find pre-converted models in gguf format on Huggingface.&lt;/p&gt;

&lt;p&gt;Next there is the matter of deciding on quantization. With smaller quantization, the model consumes less vRAM, execution becomes faster, but this comes at a price of reduced performance. You can start with the largest version that fits into the vRAM and then reduce it if the speed is not sufficient. A &lt;strong&gt;4-bit quantization&lt;/strong&gt; is usually a good compromise: the &lt;strong&gt;vRAM&lt;/strong&gt; is used efficiently while the losses from performance remain low.&lt;/p&gt;

&lt;p&gt;We can load finished gguf files with the Hugging Face CLI tool, which can then be installed with the following command:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ pip install -U &quot;huggingface_hub&quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Alternatively, one can use uvx  to run the most current version of the tool directly. A corresponding alias might look something like this:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ alias hf=&quot;uvx --from huggingface_hub hf&quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The actual download to the current directory would look something like this:&lt;/p&gt;
&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ hf download bartowski/Qwen_Qwen3-30B-A3B-GGUF --include &quot;Qwen_Qwen3-30B-A3B-Q4_K_M.gguf&quot; --local-dir .
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;chat-in-the-command-line&quot;&gt;Chat in the command line&lt;/h3&gt;
&lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;llama.cpp&lt;/code&gt; provides a number of command line tools. With llama-cli you can directly start a chat:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ llama-cli -m ~/models/bartowski/Qwen_Qwen3-30B-A3B-GGUF/Qwen_Qwen3-30B-A3B-Q4_K_M.gguf -co -cnv -fa -ngl 99
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;ul&gt;
  &lt;li&gt;-m: Path to the GGUF model file.&lt;/li&gt;
  &lt;li&gt;-co: Colored output for better differentiation of input and output.&lt;/li&gt;
  &lt;li&gt;-cnv: Activates Conversation Mode.&lt;/li&gt;
  &lt;li&gt;-fa: Turns on Flash Attention (if supported).&lt;/li&gt;
  &lt;li&gt;-ngl 99: Allows up to 99 model layers to be computed on the GPU.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The -ngl 99 parameter  tells &lt;code class=&quot;highlighter-rouge&quot;&gt;llama.cpp&lt;/code&gt; to handle up to 99 model layers on the GPU. In practice, this means that all layers are offloaded – as the output also confirms:&lt;/p&gt;
&lt;blockquote&gt;
  &lt;p&gt;offloaded 49/49 layers to GPU
If not all layers fit on the GPU, the remaining ones are processed on the CPU – which, as is to be expected, leads to significant performance losses.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;benchmarks&quot;&gt;Benchmarks&lt;/h3&gt;
&lt;p&gt;A main goal of this setup is to get a sufficient amount of vRAM at a reasonable price. But at the end of the day, we don’t just want to load huge models, we also want to execute them quickly. The rack should stand up to comparisons with what friends and colleagues put on their desks in terms of consumer hardware. A front-runner is the Apple Silicon M series, with its unified memory architecture.
We use a detailed &lt;a href=&quot;https://github.com/ggml-org/llama.cpp/discussions/4167#user-content-fn-1-edf54c51be358599387d05c7ba885b49&quot;&gt;community benchmark&lt;/a&gt; of the various Apple products as our reference. Below is a test of a smaller model, Llama 7B v2. It can be run parallel multiple times on our setup, but the maximum speed is still crucial.
With the tool llama-bench we determine two speeds:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;pp (prompt parsing):&lt;/strong&gt; Reading the question/prompt&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;tg (text generation):&lt;/strong&gt; Generating the response&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ llama-bench -m llama-2-7b. Q8_0.gguf -m llama-2-7b. Q4_0.gguf -p 512 -n 128 -ngl 99 -fa 1
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;model&lt;/th&gt;
      &lt;th&gt;size&lt;/th&gt;
      &lt;th&gt;params&lt;/th&gt;
      &lt;th&gt;backend&lt;/th&gt;
      &lt;th&gt;ngl&lt;/th&gt;
      &lt;th&gt;fa&lt;/th&gt;
      &lt;th&gt;test&lt;/th&gt;
      &lt;th&gt;t/s&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;llama 7B Q8_0&lt;/td&gt;
      &lt;td&gt;6.67 GiB&lt;/td&gt;
      &lt;td&gt;6.74 B&lt;/td&gt;
      &lt;td&gt;CUDA&lt;/td&gt;
      &lt;td&gt;99&lt;/td&gt;
      &lt;td&gt;1&lt;/td&gt;
      &lt;td&gt;pp512&lt;/td&gt;
      &lt;td&gt;1024.05 ± 0.74&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;llama 7B Q8_0&lt;/td&gt;
      &lt;td&gt;6.67 GiB&lt;/td&gt;
      &lt;td&gt;6.74 B&lt;/td&gt;
      &lt;td&gt;CUDA&lt;/td&gt;
      &lt;td&gt;99&lt;/td&gt;
      &lt;td&gt;1&lt;/td&gt;
      &lt;td&gt;tg128&lt;/td&gt;
      &lt;td&gt;36.18 ± 0.01&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;llama 7B Q4_0&lt;/td&gt;
      &lt;td&gt;3.56 GiB&lt;/td&gt;
      &lt;td&gt;6.74 B&lt;/td&gt;
      &lt;td&gt;CUDA&lt;/td&gt;
      &lt;td&gt;99&lt;/td&gt;
      &lt;td&gt;1&lt;/td&gt;
      &lt;td&gt;pp512&lt;/td&gt;
      &lt;td&gt;1073.58 ± 0.35&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;llama 7B Q4_0&lt;/td&gt;
      &lt;td&gt;3.56 GiB&lt;/td&gt;
      &lt;td&gt;6.74 B&lt;/td&gt;
      &lt;td&gt;CUDA&lt;/td&gt;
      &lt;td&gt;99&lt;/td&gt;
      &lt;td&gt;1&lt;/td&gt;
      &lt;td&gt;tg128&lt;/td&gt;
      &lt;td&gt;55.80 ± 0.83&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;build: 578754b3 (5117)&lt;/p&gt;

&lt;figure&gt;
    &lt;img data-src=&quot;/img/posts/einrichtung-ml-hpc-server-2/img11.png&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;&quot; /&gt;
    &lt;figcaption class=&quot;long-fig-caption&quot;&gt; Prompt parsing speed of the P40 compared to Apple Silicon M‑series products for the Llama 7B v2 model in 8‑bit quantization (in tokens per second)
&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;figure&gt;
    &lt;img data-src=&quot;/img/posts/einrichtung-ml-hpc-server-2/img12.png&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;&quot; /&gt;
    &lt;figcaption class=&quot;long-fig-caption&quot;&gt; Text generation comparison (Llama 7B v2 model in 8‑bit quantization)
&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;figure&gt;
    &lt;img data-src=&quot;/img/posts/einrichtung-ml-hpc-server-2/img21.png&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;&quot; /&gt;
    &lt;figcaption class=&quot;long-fig-caption&quot;&gt; Prompt parsing comparison (Llama 7B v2 model in 4‑bit quantization)
&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;figure&gt;
    &lt;img data-src=&quot;/img/posts/einrichtung-ml-hpc-server-2/img22.png&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;&quot; /&gt;
    &lt;figcaption class=&quot;long-fig-caption&quot;&gt; Text generation comparison (Llama 7B v2 model in 4‑bit quantization)
&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;The P40 performs well in the area of prompt parsing. In text production, it is above the Pro series and in the lower range of the Max series.&lt;/p&gt;

&lt;h3 id=&quot;result&quot;&gt;Result&lt;/h3&gt;
&lt;p&gt;We have built a powerful HPC server with a total of 96 GB of vRAM, creating a solid basis for demanding AI and data processing projects – all without blowing the bank.&lt;/p&gt;

&lt;h4 id=&quot;rental-server-alternatives&quot;&gt;Rental server alternatives&lt;/h4&gt;
&lt;p&gt;Depending on the scenario, renting AI GPU servers might be a viable option. If you only need selective training phases or want to test prototypes on short notice, you can benefit from hour-based rental offers. However, long-term, continuous use requires dedicated resources with full access, and then the costs can add up quickly. It is not uncommon to see setups costing up to &lt;strong&gt;EUR 1,000&lt;/strong&gt; per month, which makes setting up your own server more economically attractive in the long run.&lt;/p&gt;

&lt;h3 id=&quot;practical-framework&quot;&gt;Practical framework&lt;/h3&gt;
&lt;p&gt;However, while operating a high-performance server has its advantages, it also has its infrastructural requirements:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Space &amp;amp; volume:&lt;/strong&gt; Under full load, a suitable server room with sufficient ventilation is required.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Energy requirements:&lt;/strong&gt; Continuous occupancy can result in electricity costs in the range of EUR 100 per month or more.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Maintenance &amp;amp; updates:&lt;/strong&gt; You are responsible for driver and software updates, as well as repair and replacement of defective hardware.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Limited driver/software compatibility of used hardware:&lt;/strong&gt; In our case, the P40’s “Pascal” GPU architecture was still supported well enough for us to run current models. However, we are already encountering deprecation warnings. Before purchasing, the current support for drivers (CUDA) and the important libraries and frameworks (especially PyTorch) should be researched.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;added-value-for-the-team&quot;&gt;Added value for the team&lt;/h3&gt;
&lt;p&gt;For our team, the current setup is a real asset:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Free experimentation:&lt;/strong&gt; No approval processes or time pressure from expensive rental hours.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Data sovereignty:&lt;/strong&gt; Local LLMs and AI models make it possible to securely process confidential data.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Full control:&lt;/strong&gt; We determine the hardware, the software and the access rights. Anything goes, no matter how obscure.&lt;/li&gt;
&lt;/ul&gt;</content><author><name>paulFieteHartmann</name></author><summary type="html">This post demonstrates how to set up a high-performance server for machine learning on a small budget.</summary></entry><entry xml:lang="de"><title type="html">Einrichtung eines ML-HPC-Servers (Teil 2 - Treibereinrichtung und Sprachmodelle)</title><link href="https://blog.cronn.de/de/machinelearning/2026/01/27/einrichtung-ml-hpc-server-2.html" rel="alternate" type="text/html" title="Einrichtung eines ML-HPC-Servers (Teil 2 - Treibereinrichtung und Sprachmodelle)" /><published>2026-01-27T00:00:00-06:00</published><updated>2026-01-27T00:00:00-06:00</updated><id>https://blog.cronn.de/de/machinelearning/2026/01/27/einrichtung-ml-hpc-server-2</id><content type="html" xml:base="https://blog.cronn.de/de/machinelearning/2026/01/27/einrichtung-ml-hpc-server-2.html">&lt;h3 id=&quot;rückblick&quot;&gt;Rückblick&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.cronn.de/de/machinelearning/2025/12/02/einrichtung-ml-hpc-server-1.html&quot;&gt;In Teil 1&lt;/a&gt; haben wir gezeigt, wie wir die Hardware unseres neuen HPC-Servers eingerichtet haben. Im zweiten Teil geht es jetzt mit der Software weiter.&lt;/p&gt;

&lt;h3 id=&quot;installation-des-cuda-toolkits-und-der-nvidia-treiber&quot;&gt;Installation des CUDA-Toolkits und der NVIDIA Treiber&lt;/h3&gt;
&lt;p&gt;Ubuntu stellt im &lt;em&gt;non-free (restricted)&lt;/em&gt; Repository NVIDIA-Treiber bereit, die sich aber als veraltet herausstellen. Wir nehmen daher die aktuellen Treiber von NVIDIA:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;NVIDIA stellt ein Package-Repository für Ubuntu bereit. Die Installation ist damit ohne besonderen Aufwand möglich.&lt;/li&gt;
  &lt;li&gt;Für CUDA stellt NVIDIA ein hohes Maß an Abwärtskompatibilität sicher. Die zu der Zeit aktuelle CUDA-Version 12.8 unterstützt die deutlich ältere GPU-Architektur „Pascal“ der Tesla P40 GPUs.&lt;/li&gt;
  &lt;li&gt;Eine aktuelle Version von CUDA und passenden Treibern ist Voraussetzung, um mit aktuellem KI-Tooling arbeiten zu können.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Der &lt;a href=&quot;https://docs.nvidia.com/cuda/cuda-installation-guide-linux/index.html&quot;&gt;NVIDIA-Installationsguide&lt;/a&gt; für das CUDA-Toolkit führt diverse Möglichkeiten und Pfade auf:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;em&gt;Package Manager vs. Runfile Installation&lt;/em&gt;: Die Installation über den Package Manager ist bequemer und hat bessere Systemintegration.&lt;/li&gt;
  &lt;li&gt;&lt;em&gt;Local Repo Installation vs. Network Repo Installation&lt;/em&gt;: Solange die Maschine Internetzugang hat, ist die Network-Repo-Option besser. Damit erhalten wir per apt upgrade die aktuellen Updates von NVIDIA.&lt;/li&gt;
  &lt;li&gt;Wir wählen die &lt;em&gt;proprietären Pakete&lt;/em&gt; und nicht die &lt;em&gt;Open-Source-Pakete&lt;/em&gt;, da die Open-Source-Implementation in vielen Punkten noch deutlich zurücksteht.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Im ersten Schritt sollte geprüft werden, ob bereits Pakete installiert sind, die zu Konflikten führen könnten:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ dpkg -l | grep nvidia
$ dpkg -l | grep cuda
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Diese Ausgabe sollte leer sein. Falls nicht, können bestehende Pakte deinstalliert werden.&lt;/p&gt;

&lt;p&gt;Entsprechend dem &lt;a href=&quot;https://docs.nvidia.com/cuda/cuda-installation-guide-linux/index.html#network-repo-installation-for-ubuntu&quot;&gt;Abschnitt 3.8.3&lt;/a&gt; aus dem Installationsguide lässt sich der GPG Key und das Repository durch die Installation eines deb-Packages einrichten. (Für $UBUNTU_VERSION wird die jeweilige Version nach dem Muster &lt;code class=&quot;highlighter-rouge&quot;&gt;„ubuntu2404“&lt;/code&gt; eingesetzt.)&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ wget https://developer.download.nvidia.com/compute/cuda/repos/$UBUNTU_VERSION/x86_64/cuda-keyring_1.1-1_all.deb

# dpkg -i cuda-keyring_1.1-1_all.deb
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Das Repository wird damit automatisch unter &lt;code class=&quot;highlighter-rouge&quot;&gt;/etc/apt/sources.list.d/cuda-ubuntu2404-x86_64.list&lt;/code&gt; angelegt.&lt;/p&gt;

&lt;p&gt;Nach einem &lt;code class=&quot;highlighter-rouge&quot;&gt;apt update&lt;/code&gt; muss für die Installation des CUDA-Toolkits nur noch ein Meta-Package installiert werden:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# apt install cuda-toolkit
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Der zweite Schritt ist die Installation der Kernel-Module. Dafür stellt NVIDIA eine weitere Anleitung bereit. Das Repository ist schon eingerichtet, es kann direkt das Meta-Package installiert werden:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# apt install cuda-drivers
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Dies installiert auch eine ganze Reihe an Paketen, die eigentlich nur für Desktops mit Display benötigt werden. Eine &lt;code class=&quot;highlighter-rouge&quot;&gt;compute-only&lt;/code&gt;-Variante wird für Fedora, Suse und Debian angeboten, aber zum aktuellen Zeitpunkt nicht für Ubuntu.
Das war’s auch schon. Nach einem Reboot sollten alle Treiber eingerichtet sein.&lt;/p&gt;

&lt;p&gt;Die CUDA Binaries befinden sich in &lt;code class=&quot;highlighter-rouge&quot;&gt;/usr/local/cuda-12.8/bin&lt;/code&gt; und sollten wie im &lt;a href=&quot;https://docs.nvidia.com/cuda/cuda-installation-guide-linux/index.html#environment-setup&quot;&gt;Abschnitt 10.1.1.&lt;/a&gt; Environment Setup beschrieben in den PATH aufgenommen werden.
Eine Erweiterung von &lt;code class=&quot;highlighter-rouge&quot;&gt;LD_LIBRARY_PATH&lt;/code&gt; sollte nicht nötig sein, da die Konfiguration durch das entsprechende Ubuntu-Package schon erfolgt ist &lt;code class=&quot;highlighter-rouge&quot;&gt;(/etc/ld.so.conf.d/988_cuda-12.conf)&lt;/code&gt;.&lt;/p&gt;

&lt;h3 id=&quot;verifikation&quot;&gt;Verifikation&lt;/h3&gt;
&lt;p&gt;Um die Installation zu verifizieren, führen wir hier ein paar nützliche Befehle auf.&lt;/p&gt;

&lt;p&gt;Prüfen, ob die gewünschte &lt;strong&gt;Treiberversion&lt;/strong&gt; geladen wurde:&lt;/p&gt;
&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ cat /proc/driver/nvidia/version
NVRM version: NVIDIA UNIX x86_64 Kernel Module  570.133.20  Sun Apr 13 04:50:56 UTC 2025
GCC version:  gcc version 13.3.0 (Ubuntu 13.3.0-6ubuntu2~24.04)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Die Installation des CUDA-Compilers NVCC verifizieren:&lt;/p&gt;
&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ nvcc –version
nvcc: NVIDIA (R) Cuda compiler driver Copyright (c) 2005-2025 NVIDIA Corporation Built on Fri_Feb_21_20:23:50_PST_2025 Cuda compilation tools, release 12.8, V12.8.93 Build cuda_12.8.r12.8/compiler.35583870_0
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Das NVIDIA &lt;strong&gt;System Management Interface (SMI)&lt;/strong&gt; aufrufen:&lt;/p&gt;
&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ nvidia-smi
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 570.124.06             Driver Version: 570.124.06     CUDA Version: 12.8     |
|-----------------------------------------+------------------------+----------------------+
| GPU  Name                 Persistence-M | Bus-Id          Disp.A | Volatile Uncorr. ECC |
| Fan  Temp   Perf          Pwr:Usage/Cap |           Memory-Usage | GPU-Util  Compute M. |
|                                         |                        |               MIG M. |
|=========================================+========================+======================|
|   0  Tesla P40                      Off |   00000000:04:00.0 Off |                  Off |
| N/A   23C    P8              9W /  250W |       5MiB /  24576MiB |      0%      Default |
|                                         |                        |                  N/A |
+-----------------------------------------+------------------------+----------------------+
|   1  Tesla P40                      Off |   00000000:05:00.0 Off |                  Off |
| N/A   24C    P8              9W /  250W |       5MiB /  24576MiB |      0%      Default |
|                                         |                        |                  N/A |
+-----------------------------------------+------------------------+----------------------+
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;erste-schritte&quot;&gt;Erste Schritte&lt;/h3&gt;
&lt;p&gt;Für einen Belastungstest der Grafikkarten nehmen wir &lt;code class=&quot;highlighter-rouge&quot;&gt;gpu-burn&lt;/code&gt;. Wie der Name bereits vermuten lässt, treibt dies den Stromverbrauch der GPUs fast ans Limit von 250 Watt pro Einheit.
Im iDRAC sieht man sehr eindrücklich, welche thermischen Auswirkungen das hat: Die Temperatur im Chassis (Volumen ca. 16 Liter) steigt in kürzester Zeit auf 60 °C. Um dies zu bemerken, braucht man allerdings keine Wartungssoftware: Die Lüfter werden markant lauter und schriller und es riecht leicht brenzlig.&lt;/p&gt;

&lt;h3 id=&quot;inferenz-mit-llamacpp&quot;&gt;Inferenz mit llama.cpp&lt;/h3&gt;
&lt;p&gt;Um aktuelle Open-Source-Sprachmodelle auszuführen, gibt es eine Reihe populärer Software wie KoboldCpp und ollama, die eins gemeinsam haben: Sie setzen auf die Bibliothek llama.cpp, die im Hintergrund die eigentliche „harte Arbeit“ übernimmt.
Für erste Tests bietet es sich an, direkt mit llama.cpp zu arbeiten – und das aus mehreren Gründen:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;maximale Kontrolle bei der Konfiguration und Optimierung&lt;/li&gt;
  &lt;li&gt;detaillierte Ausgabe von Parametern und Hardwareeigenschaften&lt;/li&gt;
  &lt;li&gt;neue Modelle erfordern oft aktuelle Features – und die landen meist zuerst in &lt;strong&gt;llama.cpp&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Es empfiehlt sich, llama.cpp direkt aus den Quellen zu kompilieren:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ cmake -B build -DGGML_CUDA=ON -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/opt/llama-cpp -DLLAMA_BUILD_EXAMPLES=ON -DLLAMA_BUILD_SERVER=ON
$ cmake --build build --config Release -j 16
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Optional, Installation der Binaries nach &lt;code class=&quot;highlighter-rouge&quot;&gt;/opt/llama-cpp&lt;/code&gt;:&lt;/p&gt;
&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ sudo cmake --install build
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;Kompilieren aus Quellen&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;blockquote&gt;
  &lt;p&gt;Beim Kompilieren aus den Quellen wird man nicht selten erstmal mit kryptischen Fehlern konfrontiert. Davon sollte man sich jedoch nicht verunsichern lassen.&lt;/p&gt;
  &lt;ul&gt;
    &lt;li&gt;Grundvoraussetzung sind Entwicklungswerkzeuge wie &lt;code class=&quot;highlighter-rouge&quot;&gt;cmake&lt;/code&gt; und &lt;code class=&quot;highlighter-rouge&quot;&gt;g++&lt;/code&gt;, die auf dem System installiert sein müssen. Empfehlenswert ist das Meta-Paket &lt;code class=&quot;highlighter-rouge&quot;&gt;build-essential&lt;/code&gt;, das die wichtigsten Tools bündelt.&lt;/li&gt;
    &lt;li&gt;In der Regel fehlen zusätzlich sogenannte Dev-Pakete für benötigte Bibliotheken. Leider wird dabei oft nicht direkt angegeben, welches Paket konkret installiert werden muss – diese Information muss man aus der Fehlermeldung ableiten.
So erwartet beispielsweise &lt;code class=&quot;highlighter-rouge&quot;&gt;llama.cpp&lt;/code&gt; die Entwicklungsdateien für &lt;code class=&quot;highlighter-rouge&quot;&gt;curl&lt;/code&gt;, die im Paket &lt;code class=&quot;highlighter-rouge&quot;&gt;libcurl4-openssl-dev&lt;/code&gt; enthalten sind.&lt;/li&gt;
  &lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;download-der-modelle&quot;&gt;Download der Modelle&lt;/h3&gt;
&lt;p&gt;Sprachmodelle findet man auf &lt;a href=&quot;https://huggingface.co/&quot;&gt;https://huggingface.co/&lt;/a&gt;. Um ein Model in &lt;code class=&quot;highlighter-rouge&quot;&gt;llama.cpp&lt;/code&gt; zu nutzen, muss es im gguf-Format vorliegen.
Die Konvertierung kann man problemlos selbst durchführen. Dazu liefert &lt;code class=&quot;highlighter-rouge&quot;&gt;llama.cpp&lt;/code&gt; die Tools &lt;code class=&quot;highlighter-rouge&quot;&gt;convert_hf_to_gguf.py&lt;/code&gt; und &lt;code class=&quot;highlighter-rouge&quot;&gt;llama-quantize&lt;/code&gt; mit. Für populäre Modelle findet man aber oft schon vorkonvertierte Modelle im gguf-Format auf Huggingface.&lt;/p&gt;

&lt;p&gt;Jetzt gilt es noch, sich für eine Quantisierung zu entscheiden. Mit kleinerer Quantisierung verbraucht das Modell weniger vRAM, die Ausführung wird schneller, aber die Leistungsfähigkeit ist vermindert. Starten kann man mit der größten Ausführung, die noch in den &lt;strong&gt;vRAM&lt;/strong&gt; passt und dann verringern, falls die Geschwindigkeit nicht ausreichend ist. Eine &lt;strong&gt;4-Bit-Quantisierung&lt;/strong&gt; ist in der Regel ein guter Kompromiss: Der vRAM wird effizient genutzt, während die Leistungseinbußen gering bleiben.&lt;/p&gt;

&lt;p&gt;Fertige gguf-Files können wir mit dem Hugging Face CLI-Tool laden, das sich mit folgendem Befehl installieren lässt:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ pip install -U &quot;huggingface_hub&quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Alternativ kann die aktuelle Version des Tools auch ohne Installation mit uvx direkt ausgeführt werden. Ein entsprechender Alias könnte so aussehen:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ alias hf=&quot;uvx --from huggingface_hub hf&quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Der eigentliche Download in das aktuelle Verzeichnis sieht dann zum Beispiel so aus:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ hf download bartowski/Qwen_Qwen3-30B-A3B-GGUF --include &quot;Qwen_Qwen3-30B-A3B-Q4_K_M.gguf&quot; --local-dir .
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;chat-in-der-commandozeile&quot;&gt;Chat in der Commandozeile&lt;/h3&gt;
&lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;llama.cpp&lt;/code&gt; stellt eine Reihe von Kommandozeilen-Tools zur Verfügung. Mit &lt;code class=&quot;highlighter-rouge&quot;&gt;llama-cli&lt;/code&gt; lässt sich direkt ein Chat starten:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ llama-cli -m ~/models/bartowski/Qwen_Qwen3-30B-A3B-GGUF/Qwen_Qwen3-30B-A3B-Q4_K_M.gguf -co -cnv -fa -ngl 99
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;Die Bedeutung der Commandozeilenparameter:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;-m: Pfad zur GGUF-Modell-Datei.&lt;/li&gt;
  &lt;li&gt;-co: Farbige Ausgabe zur besseren Unterscheidung von Eingaben und Antworten.&lt;/li&gt;
  &lt;li&gt;-cnv: Aktiviert den Gesprächsmodus (Conversation Mode).&lt;/li&gt;
  &lt;li&gt;-fa: Schaltet Flash Attention ein (wenn vom Modell unterstützt).&lt;/li&gt;
  &lt;li&gt;-ngl 99: Lässt bis zu 99 Modell-Layer auf der GPU berechnen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Der Parameter -ngl 99 weist &lt;code class=&quot;highlighter-rouge&quot;&gt;llama.cpp&lt;/code&gt; an, bis zu 99 Modell-Layer auf der GPU zu verarbeiten. In der Praxis bedeutet es, dass sämtliche Layer ausgelagert werden – wie auch die Ausgabe bestätigt:&lt;/p&gt;
&lt;blockquote&gt;
  &lt;p&gt;offloaded 49/49 layers to GPU&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Falls nicht alle Layer auf die GPU passen, werden die verbleibenden auf der CPU verarbeitet – was erwartungsgemäß zu deutlichen Performanceeinbußen führt.&lt;/p&gt;

&lt;h3 id=&quot;benchmarks&quot;&gt;Benchmarks&lt;/h3&gt;
&lt;p&gt;Ein Hauptziel dieses Setups ist es, eine ausreichende Menge vRAM zu einem angemessenen Preis zu bekommen. Aber am Ende des Tages möchten wir nicht nur riesige Modelle laden, sondern diese auch zügig ausführen. Das Rack sollte sich an dem messen lassen, was sich Freunde und Kollegen an Consumer-Hardware so auf den Schreibtisch stellen. Ein Spitzenreiter ist dabei die Apple-Silicon-M-Serie, mit ihrer Unified Memory Architecture.&lt;/p&gt;

&lt;p&gt;Als Referenz nutzen wir ein ausführliches &lt;a href=&quot;https://github.com/ggml-org/llama.cpp/discussions/4167#user-content-fn-1-edf54c51be358599387d05c7ba885b49&quot;&gt;Community-Benchmark&lt;/a&gt; der verschiedenen Apple-Produkte. Hier wird ein kleineres Modell (Llama 7B v2) getestet. Es lässt sich auf unserem Setup vielfach parallel ausführen, aber dennoch ist auch die maximale Geschwindigkeit entscheidend.&lt;/p&gt;

&lt;p&gt;Mit dem Tool llama-bench ermitteln wir zwei Geschwindigkeiten&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;pp (prompt parsing)&lt;/strong&gt;: Lesen der Frage/des Prompts, was ja auch mal länger sein kann&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;tg (text generation)&lt;/strong&gt;: Erzeugen der Antwort&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ llama-bench -m llama-2-7b.Q8_0.gguf -m llama-2-7b.Q4_0.gguf -p 512 -n 128 -ngl 99 -fa 1
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;model&lt;/th&gt;
      &lt;th&gt;size&lt;/th&gt;
      &lt;th&gt;params&lt;/th&gt;
      &lt;th&gt;backend&lt;/th&gt;
      &lt;th&gt;ngl&lt;/th&gt;
      &lt;th&gt;fa&lt;/th&gt;
      &lt;th&gt;test&lt;/th&gt;
      &lt;th&gt;t/s&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;llama 7B Q8_0&lt;/td&gt;
      &lt;td&gt;6.67 GiB&lt;/td&gt;
      &lt;td&gt;6.74 B&lt;/td&gt;
      &lt;td&gt;CUDA&lt;/td&gt;
      &lt;td&gt;99&lt;/td&gt;
      &lt;td&gt;1&lt;/td&gt;
      &lt;td&gt;pp512&lt;/td&gt;
      &lt;td&gt;1024.05 ± 0.74&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;llama 7B Q8_0&lt;/td&gt;
      &lt;td&gt;6.67 GiB&lt;/td&gt;
      &lt;td&gt;6.74 B&lt;/td&gt;
      &lt;td&gt;CUDA&lt;/td&gt;
      &lt;td&gt;99&lt;/td&gt;
      &lt;td&gt;1&lt;/td&gt;
      &lt;td&gt;tg128&lt;/td&gt;
      &lt;td&gt;36.18 ± 0.01&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;llama 7B Q4_0&lt;/td&gt;
      &lt;td&gt;3.56 GiB&lt;/td&gt;
      &lt;td&gt;6.74 B&lt;/td&gt;
      &lt;td&gt;CUDA&lt;/td&gt;
      &lt;td&gt;99&lt;/td&gt;
      &lt;td&gt;1&lt;/td&gt;
      &lt;td&gt;pp512&lt;/td&gt;
      &lt;td&gt;1073.58 ± 0.35&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;llama 7B Q4_0&lt;/td&gt;
      &lt;td&gt;3.56 GiB&lt;/td&gt;
      &lt;td&gt;6.74 B&lt;/td&gt;
      &lt;td&gt;CUDA&lt;/td&gt;
      &lt;td&gt;99&lt;/td&gt;
      &lt;td&gt;1&lt;/td&gt;
      &lt;td&gt;tg128&lt;/td&gt;
      &lt;td&gt;55.80 ± 0.83&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;build: 578754b3 (5117)&lt;/p&gt;

&lt;figure&gt;
    &lt;img data-src=&quot;/img/posts/einrichtung-ml-hpc-server-2/img11.png&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;&quot; /&gt;
    &lt;figcaption class=&quot;long-fig-caption&quot;&gt; Prompt-Parsing Geschwindigkeit des P40 im Vergleich zu Produkten der Apple-Silicon-M-Serie für das Llama 7B v2 Modell in 8-Bit Quantisierung (in Token pro Sekunde)
&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;figure&gt;
    &lt;img data-src=&quot;/img/posts/einrichtung-ml-hpc-server-2/img12.png&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;&quot; /&gt;
    &lt;figcaption class=&quot;long-fig-caption&quot;&gt; Text-Generierung im Vergleich (Llama 7B v2 Modell in 8-Bit Quantisierung)
&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;figure&gt;
    &lt;img data-src=&quot;/img/posts/einrichtung-ml-hpc-server-2/img21.png&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;&quot; /&gt;
    &lt;figcaption class=&quot;long-fig-caption&quot;&gt; Prompt-Parsing im Vergleich (Llama 7B v2 Modell in 4-Bit Quantisierung)
&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;figure&gt;
    &lt;img data-src=&quot;/img/posts/einrichtung-ml-hpc-server-2/img22.png&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;&quot; /&gt;
    &lt;figcaption class=&quot;long-fig-caption&quot;&gt; Text-Generierung im Vergleich (Llama 7B v2 Modell in 4-Bit Quantisierung)
&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Im Bereich Prompt-Parsing schlägt sich der P40 hervorragend. Bei der Textproduktion liegt er oberhalb der Pro-Reihe und im unteren Bereich der Max-Reihe.&lt;/p&gt;

&lt;h3 id=&quot;fazit&quot;&gt;Fazit&lt;/h3&gt;
&lt;p&gt;Mit einem überschaubaren Budget haben wir einen leistungsfähigen HPC-Server mit insgesamt 96 GB vRAM aufgebaut und damit eine solide Basis für anspruchsvolle KI- und Datenverarbeitungsprojekte geschaffen.&lt;/p&gt;

&lt;h4 id=&quot;mietserveralternativen&quot;&gt;Mietserveralternativen&lt;/h4&gt;
&lt;p&gt;Der Einsatz gemieteter KI-GPU-Server kann je nach Szenario eine sinnvolle Option sein. Wer lediglich punktuell Trainingsphasen benötigt oder kurzfristig Prototypen testen möchte, profitiert von stundenbasierten Mietangeboten. Bei langfristigem, kontinuierlichem Einsatz, insbesondere bei dedizierten Ressourcen mit vollem Zugriff, summieren sich die Kosten jedoch schnell. Im Bereich von &lt;strong&gt;1.000 EUR pro Monat&lt;/strong&gt; sind solche Setups keine Seltenheit, was den eigenen Betrieb auf Dauer wirtschaftlich attraktiver macht.&lt;/p&gt;

&lt;h4 id=&quot;praktische-rahmenbedingungen&quot;&gt;Praktische Rahmenbedingungen&lt;/h4&gt;
&lt;p&gt;Der Betrieb eines leistungsstarken Servers bringt jedoch nicht nur Vorteile, sondern auch infrastrukturelle Anforderungen mit sich:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Platz &amp;amp; Lautstärke:&lt;/strong&gt; Unter Volllast ist ein geeigneter Serverraum mit ausreichender Belüftung notwendig.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Energiebedarf:&lt;/strong&gt; Dauerhafte Auslastung kann Stromkosten im Bereich von 100 EUR pro Monat oder mehr verursachen.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Wartung &amp;amp; Updates:&lt;/strong&gt; Treiber- und Softwareupdates, Reparatur und Erneuerung defekter Hardware liegen in der eigenen Verantwortung.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Begrenzte Treiber-/Softwarekompatibilität gebrauchter Hardware:&lt;/strong&gt; In unserem Fall wurde die GPU-Architektur „Pascal“ des P40 noch gut unterstützt und wir können aktuelle Modelle betreiben. Wir sind hier aber gerade an der Grenze und an einigen Stellen gibt es schon Deprecation-Warnungen. Vor der Anschaffung sollte der aktuelle Support für Treiber (CUDA) und den wichtigen Bibliotheken und Frameworks (insbesondere PyTorch) recherchiert werden.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4 id=&quot;mehrwert-für-das-team&quot;&gt;Mehrwert für das Team&lt;/h4&gt;
&lt;p&gt;Für unser Team ist der aktuelle Aufbau ein echter Gewinn:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Freies Experimentieren:&lt;/strong&gt; Keine Genehmigungsprozesse oder Zeitdruck durch teure Mietstunden.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Datensouveränität:&lt;/strong&gt; Lokale LLMs und KI-Modelle ermöglichen es, auch vertrauliche Daten sicher zu verarbeiten.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Volle Kontrolle:&lt;/strong&gt; Wir bestimmen über Hardware, Software und Zugriffsrechte. Alles kann ausprobiert werden, egal wie obskur.&lt;/li&gt;
&lt;/ul&gt;</content><author><name>paulFieteHartmann</name></author><summary type="html">Im zweiten Teil zeigen wir die Treibereinrichtung und Sprachmodelle für den neuen ML-HPC-Server.</summary></entry><entry xml:lang="en"><title type="html">Setting Up an ML HPC Server (Part 1 - Hardware)</title><link href="https://blog.cronn.de/en/machinelearning/2025/12/03/setting-up-ml-hpc-server-1.html" rel="alternate" type="text/html" title="Setting Up an ML HPC Server (Part 1 - Hardware)" /><published>2025-12-03T00:00:00-06:00</published><updated>2025-12-03T00:00:00-06:00</updated><id>https://blog.cronn.de/en/machinelearning/2025/12/03/setting-up-ml-hpc-server-1</id><content type="html" xml:base="https://blog.cronn.de/en/machinelearning/2025/12/03/setting-up-ml-hpc-server-1.html">&lt;h3 id=&quot;motivation&quot;&gt;Motivation&lt;/h3&gt;
&lt;p&gt;Many powerful AI models such as &lt;em&gt;gpt-oss&lt;/em&gt; or &lt;em&gt;DeepSeek&lt;/em&gt; are now published as open source. Powerful graphics cards (GPUs) are required in order to operate current and larger models at high performance. The decisive criterion here is the available graphics memory (vRAM).
High-end gaming GPUs are equipped with up to 24GB of vRAM. However, this is not sufficient for larger language models. Professional cards such as the NVIDIA H100 Tensor Core GPU have 80 GB of vRAM, but currently cost around €30,000.
Our goal was to build a machine learning computer on which medium-sized models could be operated locally without using cloud providers, which would be as powerful as possible, but on a manageable budget.
The choice fell on a Dell PowerEdge C4130 rack server with two Nvidia Tesla P40 GPUs, 64 Xeon cores, 128GB RAM and 800GB hot-swap disks. The acquisition costs for the used hardware amounted to a total of 1550 €.
In 2020 the P40 GPUs were in the upper performance class and continue to be provided with driver updates by Nvidia. How their performance has stood the test of time is revealed in the benchmarks in the second part of the article. For now we’ll describe the structure of the basic system without starting up the GPUs. The goal is to create a working environment that can be operated completely without physical access. The server hardware has some interesting featutres which allow such access, and we will now take a closer look at these features.&lt;/p&gt;

&lt;h3 id=&quot;initial-assessment&quot;&gt;Initial assessment&lt;/h3&gt;
&lt;p&gt;The chassis of the C4130 is designed for mounting in a 19” rack, with a height unit (1U) and a depth of almost 90cm. It has 2 redundant 2 kW power supplies, one of which unfortunately suffered damage during shipping.&lt;/p&gt;

&lt;figure&gt;
&lt;img data-src=&quot;/img/posts/einrichtung-ml-hpc-server-1/Lieferschaden.jpg&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;Photo of the delivery damage to the power supply.&quot; /&gt;
&lt;figcaption class=&quot;long-fig-caption&quot;&gt;Delivery damage to the power supply.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;While we had no issues with the &lt;a href=&quot;https://www.bargainhardware.co.uk/&quot;&gt;seller&lt;/a&gt; exchanging the damaged goods, the matching C19 power cables were not included and had to be reordered.
The machine is completely designed for remote maintenance, so it usually no longer requires on-site presence after installation in the data center. It also has 2 Gigabit Ethernet ports and a maintenance port. It can be accessed via VGA and USB, but we do not use this due to the lack of a suitable VGA adapter. The handbook documents the various access routes.
When switched on for the first time, the LEDs on the front and back of the chassis flash orange. Ideally they should be solid blue, so the system doesn’t feel completely healthy.
The maintenance access (iDRAC) has a somewhat old-fashioned web interface on the factory-set IP &lt;code class=&quot;highlighter-rouge&quot;&gt;192.168.0.120.&lt;/code&gt; Commendably, you can use the maintenance port on a switch as well as on a laptop (auto-sense), for which you have to manually select an IP address on the same LAN as the laptop.
The iDRAC is completely independent of the main system and can be accessed as soon as the chassis receives power. In the diagnostics area, the condition of all components is visible. In our case, as expected, the removed power supply is flagged, and a fan is also defective, which is why the status LEDs flash orange.
Speaking of fans: There are 8 built-in cooling units, each with 2 fans. Due to the low height (1U is about 4.5cm), they already spin at idle at 8,000 rpm. The limit is about 20,000 rpm, which is unpleasantly loud. Colleagues present in the room quickly left after it had been switched on.
Other interior features: a 128 GB main memory, 64 cores in 2 Xeon E5-2697A processors, and two 800 GB hot-swappable SSDs (1.8” uSATA).
When you remove the lid of the chassis, your eye is immediately caught by the 4 GPU bays directly in front of the fans. There are several slots free for more main memory, and there is still room for more hard drives at the back. The opening and reclosing of the chassis is logged by the iDRAC, even when it is switched off.
In the iDRAC there is a VNC console which allows access to the BIOS and other diagnostic tools. We performed a detailed memory test, which ended after several hours without returning any errors.&lt;/p&gt;

&lt;figure&gt;
&lt;img data-src=&quot;/img/posts/einrichtung-ml-hpc-server-1/idrac-oberfläche.jpg&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;Screenshot of the iDRAC interface.&quot; /&gt;
&lt;figcaption class=&quot;long-fig-caption&quot;&gt;iDRAC interface has that look and feel of the 90s.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Before the first boot of the main system, we change the boot order in the BIOS and disable the default network start (PXE). Thanks to this we avoid long pauses at startup.&lt;/p&gt;

&lt;p&gt;Before you can turn your attention to GPUs, a basic operating system is required. The choice fell on Ubuntu because it is both commonly used and supplied by Nvidia with current GPU drivers and libraries. We are looking for:
Encryption on both SSDs (cryptsetup + LUKS);
LVM with 2 physical volumes;
and within it logical partitions for &lt;code class=&quot;highlighter-rouge&quot;&gt;/&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;/var&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;/home&lt;/code&gt;.
We decide against RAID1 on the hot-swappable disks in favor of more usable space for our AI models.
We start the Ubuntu server installer from a USB stick and access it via the VNC console in iDRAC. Caution is advised when entering passwords during installation: The keyboard layout of the VNC viewer in the iDRAC console is neither German nor English, but instead a wild mixture.
We noticed that the VNC console didn’t not run stable, with the connection not always working. A &lt;a href=&quot;https://www.dell.com/support/contents/en-uk/videos/videoplayer/how-to-reset-and-drain-power-of-dell-poweredge-server/6301449860001&quot;&gt;cold start&lt;/a&gt; might help.
The Ubuntu installer is somewhat overwhelmed with our partitioning requests: it apparently fails because the two encrypted disks are to be combined into one LVM volume (LVM = Logical Volume Manager). We work around the problem by initially setting up only an encrypted SSD with an LVM root volume. This means that the initial installation is complete within 5 minutes after a reboot.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Logical_Volume_Manager_(Linux)&quot;&gt;LVM&lt;/a&gt; allows us to change volume sizes in the file system relatively easily afterwards, as well as to include additional disks. The necessary connections are already available in the chassis.&lt;/p&gt;

&lt;p&gt;Manual setup of the second hard drive
We would like to have &lt;code class=&quot;highlighter-rouge&quot;&gt;/home&lt;/code&gt; on the second (still unformatted) disk &lt;code class=&quot;highlighter-rouge&quot;&gt;/dev/sdb&lt;/code&gt;, as we want to have plenty of room for our AI models. To do this, we create an encrypted partition:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# parted /dev/sdb mklabel gpt
# parted -a optimal /dev/sdb mkpart primary 0% 100%
# cryptsetup luxFormat /dev/sdb1
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;To be able to unlock both disks with the same password, we use the script &lt;code class=&quot;highlighter-rouge&quot;&gt;decrypt_keyctl&lt;/code&gt; (included in &lt;code class=&quot;highlighter-rouge&quot;&gt;cryptsetup&lt;/code&gt;). It takes &lt;code class=&quot;highlighter-rouge&quot;&gt;keyctl&lt;/code&gt; from the keyutils package, which we however still need to install manually. Then it is entered in &lt;code class=&quot;highlighter-rouge&quot;&gt;/etc/crypttab&lt;/code&gt; for both disks:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# apt install keyutils
# cat /etc/crypttab
dm_crypt-0 UUID=035c6de5-99df-4e81-ba49-578d6b97c4cf none luks,keyscript=decrypt_keyctl
crypt_sdb1 UUID=97675b26-983a-42f8-8e2c-a5edb0fb051f none luks,keyscript=decrypt_keyctl
# update-initramfs -u
# reboot
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The next time the machine is restarted, both disks are decoded as planned. We occupy the now available space entirely with /home in another physical LVM volume. In theory, LVM could be dispensed with for a single partition, however it allows us to change the distribution of the disks later if necessary.&lt;/p&gt;
&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# pvcreate /dev/mapper/crypt_sdb1
# vgcreate data-vg /dev/mapper/crypt_sdb1
# lvcreate -n data-home -l 100%FREE data-vg
# mkfs.ext4 /dev/data-vg/data-home
# cat /etc/fstab
...
/dev/disk/by-uuid/8209347b-0ddd-47f8-a5ba-b505cb822085 /home ext4 defaults 0 1
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Normally, the password for encrypted hard drives is required at system startup. However, this will no longer be accessible as soon as the machine is placed in the rack. We therefore install &lt;code class=&quot;highlighter-rouge&quot;&gt;dropbear-initramfs&lt;/code&gt; to be able to unlock the disks via SSH.
Deviating from usual procedure, we convert the existing OpenSSH host keys to Dropbear format and install them in &lt;code class=&quot;highlighter-rouge&quot;&gt;initramfs&lt;/code&gt;, so that we can use the normal SSH port (22) for unlocking without causing any key conflicts.&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# /usr/lib/dropbear/dropbearconvert openssh dropbear \ /etc/ssh/ssh_host_ecdsa_key \ /etc/dropbear/initramfs/dropbear_ecdsa_host_key
# /usr/lib/dropbear/dropbearconvert openssh dropbear \ /etc/ssh/ssh_host_ed25519_key \ /etc/dropbear/initramfs/dropbear_ed25519_host_key
# /usr/lib/dropbear/dropbearconvert openssh dropbear \ /etc/ssh/ssh_host_rsa_key \ /etc/dropbear/initramfs/dropbear_rsa_host_key
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Finally, the public keys of all administrators are entered in /etc/dropbear/initramfs/authorized_keys and the ramdisk is updated:&lt;/p&gt;
&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# update-initramfs -u
# reboot
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Et voilà, after a reboot, the disks can be unlocked via SSH.&lt;/p&gt;

&lt;h3 id=&quot;compulsory-reworking&quot;&gt;Compulsory reworking&lt;/h3&gt;
&lt;p&gt;During the final system cleanup, we stupidly overlooked the fact that &lt;code class=&quot;highlighter-rouge&quot;&gt;cryptsetup-initramfs&lt;/code&gt; is not a manually selected package and it is automatically uninstalled. As a result, the system no longer boots because the root partition cannot be decrypted.
Luckily a rescue system is hidden in the help menu of the Ubuntu installer. From there, we manually mount the installed filesystem and reinstall &lt;code class=&quot;highlighter-rouge&quot;&gt;cryptsetup-initramfs&lt;/code&gt; in the &lt;code class=&quot;highlighter-rouge&quot;&gt;chroot&lt;/code&gt;. Now the machine starts again.&lt;/p&gt;</content><author><name>danielKnauth</name></author><summary type="html">This post demonstrates how to set up a high-performance server for machine learning on a small budget.</summary></entry><entry xml:lang="de"><title type="html">Einrichtung eines ML-HPC-Servers (Teil 1 - Hardware)</title><link href="https://blog.cronn.de/de/machinelearning/2025/12/02/einrichtung-ml-hpc-server-1.html" rel="alternate" type="text/html" title="Einrichtung eines ML-HPC-Servers (Teil 1 - Hardware)" /><published>2025-12-02T00:00:00-06:00</published><updated>2025-12-02T00:00:00-06:00</updated><id>https://blog.cronn.de/de/machinelearning/2025/12/02/einrichtung-ml-hpc-server-1</id><content type="html" xml:base="https://blog.cronn.de/de/machinelearning/2025/12/02/einrichtung-ml-hpc-server-1.html">&lt;h3 id=&quot;motivation&quot;&gt;Motivation&lt;/h3&gt;
&lt;p&gt;Viele mächtige KI-Modelle wie &lt;em&gt;gpt-oss&lt;/em&gt; oder &lt;em&gt;DeepSeek&lt;/em&gt; werden mittlerweile als Open Source veröffentlicht. Um aktuelle und größere Modelle performant zu betreiben, werden leistungsfähige Grafikkarten (GPUs) benötigt. Ein maßgebliches Kriterium ist dabei der verfügbare Grafikspeicher (vRAM).&lt;/p&gt;

&lt;p&gt;Gaming-GPUs der oberen Preisklasse sind mit bis zu 24 GB vRAM ausgestattet. Das ist für größere Sprachmodelle jedoch nicht ausreichend. Professionelle Karten wie die NVIDIA H100 Tensor Core GPU haben 80 GB vRAM, kosten aber derzeit ca. 30.000 €. Unser Ziel war es, mit überschaubarem Budget einen möglichst leistungsfähigen Rechner für Machine-Learning aufzubauen, auf dem mittelgroße Modelle lokal betrieben werden können, ohne Nutzung von Cloud-Anbietern.&lt;/p&gt;

&lt;p&gt;Die Wahl fiel auf einen Dell PowerEdge C4130 Rack Server mit zwei Nvidia Tesla P40 GPUs, 64 Xeon-Kernen, 128GB RAM und 800GB Hot-Swap Platten. Die Anschaffungskosten für die gebrauchte Hardware betragen in Summe 1550 €.
Die P40-GPUs waren um 2020 in der oberen Leistungsklasse und werden weiterhin von Nvidia mit Treiber-Updates versorgt. Was man damit heute noch anfangen kann, verraten die Benchmarks im zweiten Teil des Artikels.&lt;/p&gt;

&lt;p&gt;Der erste Teil beschreibt den Aufbau des Grundsystems ohne Inbetriebnahme der GPUs. Das Ziel ist, eine lauffähige Umgebung zu bekommen, die komplett ohne physischen Zugang betreibbar ist. Dafür hat die bestellte Server-Hardware einige interessante Eigenheiten, die wir näher betrachten.&lt;/p&gt;

&lt;h3 id=&quot;erstbegutachtung&quot;&gt;Erstbegutachtung&lt;/h3&gt;
&lt;p&gt;Das Chassis des C4130 ist für Montage in einem 19” Rack bestimmt, es hat eine Höheneinheit (1U) und eine Tiefe von fast 90cm. Es besitzt 2 redundante 2 kW-Netzteile, von denen eines leider einen unübersehbaren Transportschaden hat.&lt;/p&gt;

&lt;figure&gt;
&lt;img data-src=&quot;/img/posts/einrichtung-ml-hpc-server-1/Lieferschaden.jpg&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;Bild, das den Lieferschaden am Netzteil zeigt.&quot; /&gt;
&lt;figcaption class=&quot;long-fig-caption&quot;&gt;Lieferschaden am Netzteil.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Ein Austausch durch den &lt;a href=&quot;https://www.bargainhardware.co.uk/&quot;&gt;Händler&lt;/a&gt; erfolgt problemlos. Die passenden C19-Stromkabel liegen dummerweise nicht bei und müssen ebenfalls nachbestellt werden.
Die Maschine ist komplett für Fernwartung ausgelegt, also erfordert sie nach Einbau im Rechenzentrum (RZ) normalerweise keine Präsenz mehr vor Ort. Dazu hat sie 2 Gigabit Ethernet-Anschlüsse und einen Wartungs-Port. Man kann auch über VGA und USB darauf zugreifen, worauf wir mangels passendem VGA-Adapter jedoch verzichten. Im Handbuch sind die verschiedenen Zugangswege dokumentiert.
Beim erstmaligen Einschalten fallen die orange blinkenden LEDs an Vorder- und Rückseite des Chassis auf. Normalerweise sollten sie konstant blau leuchten, das System fühlt sich also nicht völlig gesund.&lt;/p&gt;

&lt;p&gt;Der Wartungszugang (iDRAC) hat eine etwas altbackene Weboberfläche auf der werksseitig eingestellten &lt;code class=&quot;highlighter-rouge&quot;&gt;IP 192.168.0.120&lt;/code&gt;. Löblicherweise kann man den Wartungs-Port sowohl an einem Switch als auch an einem Laptop benutzen (auto-sense), wofür am Laptop manuell eine IP im selben LAN gewählt werden muss.&lt;/p&gt;

&lt;p&gt;Das iDRAC ist komplett unabhängig vom Hauptsystem und erreichbar, sobald das Chassis Strom bekommt. Im Diagnosebereich ist der Zustand aller Komponenten sichtbar, in unserem Fall wird erwartungsgemäß das ausgebaute Netzteil beanstandet, außerdem ist ein Lüfter defekt, weswegen die Status-LEDs orange blinken. Apropos Lüfter: Eingebaut sind 8 Stück mit jeweils 2 Ventilatoren. Aufgrund der geringen Bauhöhe (1U sind ca. 4,5cm) drehen diese schon im Leerlauf mit 8.000 U/min, das Limit sind ca. 20.000 U/min, also richtig unangenehm laut. Anwesende Kollegen verließen nach dem Einschalten zügig den Raum.&lt;/p&gt;

&lt;p&gt;Weitere Innenausstattung: 128 GB Hauptspeicher, 64 Kerne in 2 Xeon E5-2697A-Prozessoren, zwei 800 GB hot-Swap-fähige SSDs (1,8” uSATA).
Wenn man den Deckel des Chassis abnimmt, fallen sofort die 4 GPU-Einschübe direkt vor den Lüftern ins Auge. Für mehr Hauptspeicher sind etliche Steckplätze frei, hinten ist noch Platz für weitere Festplatten. Das Öffnen und Wiederverschließen des Chassis wird vom iDRAC protokolliert, auch in ausgeschaltetem Zustand.
Im iDRAC gibt es eine VNC-Konsole, die u.a. Zugriff auf das BIOS und weitere Diagnose-Werkzeuge erlaubt. Wir machen einen ausführlichen Speichertest, der nach mehreren Stunden ohne Fehler endet.&lt;/p&gt;

&lt;figure&gt;
&lt;img data-src=&quot;/img/posts/einrichtung-ml-hpc-server-1/idrac-oberfläche.jpg&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;Screenshot der iDRAC-Oberfläche.&quot; /&gt;
&lt;figcaption class=&quot;long-fig-caption&quot;&gt;iDRAC-Oberfläche im Look&amp;amp;Feel der 90er Jahre.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Vor dem ersten Start des Hauptsystems ändern wir noch die Boot-Reihenfolge im BIOS, denn dort ist Netzwerkstart (PXE) voreingestellt. Wir deaktivieren es, um lange Pausen beim Start zu vermeiden.&lt;/p&gt;

&lt;h3 id=&quot;linux-basisinstallation&quot;&gt;Linux-Basisinstallation&lt;/h3&gt;
&lt;p&gt;Bevor man sich den GPUs zuwenden kann, wird ein Basis-Betriebssystem benötigt. Die Wahl fiel auf Ubuntu, weil es gängig ist und von Nvidia mit aktuellen GPU-Treibern und –Bibliotheken versorgt wird.&lt;/p&gt;

&lt;p&gt;Wir hätten gerne:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Verschlüsselung auf beiden SSDs (cryptsetup + LUKS),&lt;/li&gt;
  &lt;li&gt;darüber LVM mit 2 physischen Volumes,&lt;/li&gt;
  &lt;li&gt;und darin logische Partitionen für /, /var und /home.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Auf ein RAID1 der Hot-Swap-Platten verzichten wir zugunsten von mehr nutzbarem Platz für KI-Modelle.
Wir starten den Ubuntu-Server-Installer von einem USB-Stick und greifen über die VNC-Konsole im iDRAC darauf zu. Bei der Eingabe von Kennworten während der Installation ist Vorsicht geboten: Die Tastaturbelegung des VNC-Viewers in der iDRAC-Konsole ist eigenwillig, weder deutsch noch englisch, sondern eine wilde Mixtur.&lt;/p&gt;

&lt;p&gt;Uns fällt auf, dass die VNC-Konsole nicht ganz stabil läuft, manchmal funktioniert der Verbindungsaufbau nicht. Ein &lt;a href=&quot;https://www.dell.com/support/contents/de-de/videos/videoplayer/anleitung-zum-zur%C3%BCcksetzen-und-entladen-des-reststroms-eines-dell-poweredge-servers/6301449860001&quot;&gt;Kaltstart&lt;/a&gt; kann weiterhelfen.&lt;/p&gt;

&lt;p&gt;Der Ubuntu-Installer ist mit unseren Partitionierungswünschen etwas überfordert, es scheitert offenbar an den zwei verschlüsselten Platten, die zu einem LVM-Volume (LVM = Logical Volume Manager) zusammengefasst werden sollen. Wir umgehen das Problem, indem wir zunächst nur eine verschlüsselte SSD mit einem LVM Root-Volume einrichten. Damit ist die Erstinstallation in 5 Minuten nach einem Neustart abgeschlossen.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Logical_Volume_Manager_(Linux)&quot;&gt;LVM&lt;/a&gt; erlaubt uns, nachträglich die Volume-Größen im Dateisystem relativ einfach zu ändern oder zusätzliche Platten einzubinden. Dafür sind im Chassis die passenden Anschlüsse bereits vorhanden.&lt;/p&gt;

&lt;h3 id=&quot;manuelle-einrichtung-der-zweiten-festplatte&quot;&gt;Manuelle Einrichtung der zweiten Festplatte&lt;/h3&gt;
&lt;p&gt;Wir hätten gerne &lt;code class=&quot;highlighter-rouge&quot;&gt;/home&lt;/code&gt; auf der zweiten (noch unformatierten) Platte &lt;code class=&quot;highlighter-rouge&quot;&gt;/dev/sdb&lt;/code&gt;, da wir reichlich Platz für KI-Modelle haben wollen. Dazu legen wir eine verschlüsselte Partition an:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# parted /dev/sdb mklabel gpt
# parted -a optimal /dev/sdb mkpart primary 0% 100%
# cryptsetup luksFormat /dev/sdb1
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Um beide Platten mit demselben Passwort entsperren zu können, benutzen wir das Skript &lt;code class=&quot;highlighter-rouge&quot;&gt;decrypt_keyctl&lt;/code&gt; (in cryptsetup enthalten). Es benötigt &lt;code class=&quot;highlighter-rouge&quot;&gt;keyctl&lt;/code&gt; aus dem Paket &lt;code class=&quot;highlighter-rouge&quot;&gt;keyutils&lt;/code&gt;, das wir noch manuell installieren müssen. Anschließend wird es für beide Platten in &lt;code class=&quot;highlighter-rouge&quot;&gt;/etc/crypttab&lt;/code&gt; eingetragen:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# apt install keyutils
# cat /etc/crypttab
dm_crypt-0 UUID=035c6de5-99df-4e81-ba49-578d6b97c4cf none luks,keyscript=decrypt_keyctl
crypt_sdb1 UUID=97675b26-983a-42f8-8e2c-a5edb0fb051f none luks,keyscript=decrypt_keyctl
# update-initramfs -u
# reboot
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Beim nächsten Neustart der Maschine werden wunschgemäß beide Platten entschlüsselt. Den nun verfügbaren Platz belegen wir vollständig mit &lt;code class=&quot;highlighter-rouge&quot;&gt;/home&lt;/code&gt; in einem weiteren physischen LVM-Volume. Auf LVM könnte man für eine einzelne Partition im Prinzip auch verzichten, aber es erlaubt uns, gegebenenfalls später die Aufteilung der Platten zu ändern.&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# pvcreate /dev/mapper/crypt_sdb1
# vgcreate data-vg /dev/mapper/crypt_sdb1
# lvcreate -n data-home -l 100%FREE data-vg
# mkfs.ext4 /dev/data-vg/data-home
# cat /etc/fstab
...
/dev/disk/by-uuid/8209347b-0ddd-47f8-a5ba-b505cb822085 /home ext4 defaults 0 1
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Normalerweise wird beim Systemstart das Kennwort für verschlüsselte Festplatten auf der Konsole verlangt. Diese wird jedoch nicht mehr zugänglich sein, sobald die Maschine ins Rack kommt. Wir installieren daher &lt;code class=&quot;highlighter-rouge&quot;&gt;dropbear-initramfs&lt;/code&gt;, um die Platten über SSH entsperren zu können.
Abweichend von der üblichen Vorgehensweise konvertieren wir die vorhandenen OpenSSH Host Keys ins Dropbear-Format und installierten sie ins &lt;code class=&quot;highlighter-rouge&quot;&gt;initramfs&lt;/code&gt;, so dass wir zur Entsperrung den normalen SSH-Port 22 ohne Schlüsselkonflikte nutzen können.&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# /usr/lib/dropbear/dropbearconvert openssh dropbear \
/etc/ssh/ssh_host_ecdsa_key \
/etc/dropbear/initramfs/dropbear_ecdsa_host_key
# /usr/lib/dropbear/dropbearconvert openssh dropbear \
/etc/ssh/ssh_host_ed25519_key \
/etc/dropbear/initramfs/dropbear_ed25519_host_key
# /usr/lib/dropbear/dropbearconvert openssh dropbear \
/etc/ssh/ssh_host_rsa_key \
/etc/dropbear/initramfs/dropbear_rsa_host_key
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Zuletzt werden öffentliche Schlüssel der Administratoren in &lt;code class=&quot;highlighter-rouge&quot;&gt;/etc/dropbear/initramfs/authorized_keys&lt;/code&gt; eingetragen und die Ramdisk aktualisiert:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# update-initramfs -u
# reboot
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Voilà, nach einem Neustart lassen sich die Platten auch über SSH entsperren.&lt;/p&gt;

&lt;h3 id=&quot;unfreiwillige-nacharbeiten&quot;&gt;Unfreiwillige Nacharbeiten&lt;/h3&gt;
&lt;p&gt;Bei der abschließenden Bereinigung des Systems übersehen wir dummerweise, dass &lt;code class=&quot;highlighter-rouge&quot;&gt;cryptsetup-initramfs&lt;/code&gt; kein manuell gewähltes Paket ist und automatisch deinstalliert wird. Daraufhin startet das System nicht mehr, weil die Root-Partition nicht entschlüsselt werden kann.&lt;/p&gt;

&lt;p&gt;Ein vollständiges Rettungssystem ist im Hilfemenü des Ubuntu-Installers versteckt. Von dort hängen wir das installierte Dateisystem manuell ein und installieren &lt;code class=&quot;highlighter-rouge&quot;&gt;cryptsetup-initramfs&lt;/code&gt; im &lt;code class=&quot;highlighter-rouge&quot;&gt;chroot&lt;/code&gt; noch einmal. Nun startet die Maschine wieder.&lt;/p&gt;

&lt;p&gt;Für den nächsten Schritt montieren wir die P40-GPUs in die Einschübe 1+2. Deren Einrichtung und die Messung der Rechenleistung werden im zweiten Teil beschrieben.&lt;/p&gt;</content><author><name>danielKnauth</name></author><summary type="html">Wir zeigen, wie ihr mit kleinem Budget einen High-Performance-Server für Machine Learning einrichtet.</summary></entry><entry xml:lang="en"><title type="html">Automated Security Testing: Playwright for Robust Web Security</title><link href="https://blog.cronn.de/en/testing/2025/11/13/securitye2e-tests.html" rel="alternate" type="text/html" title="Automated Security Testing: Playwright for Robust Web Security" /><published>2025-11-13T00:00:00-06:00</published><updated>2025-11-13T00:00:00-06:00</updated><id>https://blog.cronn.de/en/testing/2025/11/13/securitye2e-tests</id><content type="html" xml:base="https://blog.cronn.de/en/testing/2025/11/13/securitye2e-tests.html">&lt;h2 id=&quot;introduction&quot;&gt;Introduction&lt;/h2&gt;
&lt;p&gt;With automated end-to-end tests, you can not only find bugs, but also regularly check if your software is compliant with security standards. Automation brings several advantages:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Automated security tests provide reliable verification that security features are working as intended.&lt;/li&gt;
  &lt;li&gt;They help keep security mechanisms stable during further development and detect unwanted regressions at an early stage.&lt;/li&gt;
  &lt;li&gt;Writing automated tests allows you to look at your software from the perspective of potential attackers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In this article, we use concrete examples to show how Playwright can be used to reliably test security-relevant aspects such as Content Security Policy (CSP), clickjacking or Cross-Site Request Forgery (CSRF).&lt;/p&gt;

&lt;h2 id=&quot;approach-playwright-end-to-end-security-testing&quot;&gt;Approach: Playwright end-to-end security testing&lt;/h2&gt;
&lt;p&gt;We will now focus on reviewing selected security aspects using automated end-to-end testing. These tests can be implemented alongside end-to-end feature tests as they can run in the same pipeline as these “normal” tests. Therefore, their development feels like the development of application feature tests. To show you how to check some aspects with the help of Playwright, we shall use an example which is relevant to CSP (Content Security Policy). The CSP is sent in the header of an HTML response, and it is configured during development of the frontend. If you are therefore intending to check the CSP, it is a good idea to call up the page as part of a test and perform the checks there. Playwright is currently the most common tool for end-to-end testing of a web application. By and large, the same approaches and methods can be used for security testing as are used for end-to-end testing for new features.
In our tests for the CSP, we want to check various aspects.&lt;/p&gt;

&lt;h2 id=&quot;content-security-policy-review&quot;&gt;Content Security Policy Review&lt;/h2&gt;
&lt;p&gt;The first aspect concerns simple access to the page being checked. The first thing we want to do is make sure that no CSP is being violated by the existing implementation. Therefore, we enter the page and check that no warning appears in the browser’s console. With a small helper function, we can capture browser console error messages produced during our Playwright test and store them in an array. We simply pass the page and the target array to the function, and its implementation appends any console errors to that array as they occur.&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;logBrowserErrors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;errors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kr&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[])&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;on&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;console&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;messsage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;messsage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;error&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;errors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;push&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;messsage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;());&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Therefore, after calling up our page to be checked, we can validate that no CSP warnings or other error messages were triggered on the page. The check can be done using Playwright’s expect function.&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nx&quot;&gt;expect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;errors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;toHaveLength&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;When Playwright calls up the page, we also get a response to this call. This contains the CSP attributes in the header. We write these values to a so-called validation file, which is filled with the current CSP attributes when the test is run for the first time. These values must initially be critically checked for the expected values. If there are deviations from the expected values, the CSP must be adjusted so that the values in the validation file match the expected values.&lt;/p&gt;

&lt;p&gt;Once the validation file has been released each subsequent run of the test, be it run locally or in a pipeline, compares the contents of the file to the obtained attributes. If a deviation is detected, the test fails. In this way, all changes to the CSP are reliably detected. If you plan to make changes to the CSP, the file can be adapted. In all remaining cases it is checked why the CSP has been changed, and it can be decided whether the change needs to be reversed or whether it can be kept.&lt;/p&gt;

&lt;p&gt;Here’s an example of what the contents of such a validation file look like:&lt;/p&gt;

&lt;div class=&quot;language-json highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;cspHeaderValues&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;default-src 'self'&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;connect-src 'self'&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;script-src 'nonce-[NONCE]' 'strict-dynamic' 'wasm-unsafe-eval'&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;style-src-elem 'self' 'nonce-[NONCE]'&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;style-src-attr 'unsafe-inline'&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;img-src 'self' blob: data:&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;font-src 'self' data:&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;object-src 'none'&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;base-uri 'self'&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;form-action 'self'&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;frame-ancestors 'none'&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;We have masked the nonce values in this file because they are regenerated in each run and therefore the test cannot test for a concrete nonce value.&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;validateCSPData&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;cspHeaderValues&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;allHeaders&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())[&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;content-security-policy&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;??&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;cspHeaderValues&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;throw&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Error&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;CSP must not be empty.&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;hasMetaCSP&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;checkMetaCSP&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;expect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;hasMetaCSP&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;toBeFalsy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;snapshot&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Record&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kr&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kr&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[]&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{};&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;snapshot&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;cspHeaderValues&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;cspHeaderValues&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;split&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sr&quot;&gt;/;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\s&lt;/span&gt;&lt;span class=&quot;sr&quot;&gt;*/&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;str&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!==&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;compareActualWithValidationFile&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;snapshot&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;In the &lt;code class=&quot;highlighter-rouge&quot;&gt;validateCSPData&lt;/code&gt; method shown, you can see our implementation for validating the CSP attributes. All we have to do is pass the page and the response of the page’s call to the method. The method extracts the proportion which affects the CSP from the response. In an initial validation, we make sure the CSP is not empty. We then run another check and validate that there are no meta CSP attributes in the HTML part of the response, as we have decided not to allow meta CSP attributes and we must check to avoid conflicts between the CSP in the header and in the meta-attributes. At the end of the method, we format the CSP attributes and pass them to our method, which compares the values with those in the file mentioned above.&lt;/p&gt;

&lt;h2 id=&quot;check-csp-warning&quot;&gt;Check CSP Warning&lt;/h2&gt;
&lt;p&gt;In a further step, we manipulate the HTML part of our page to be checked in order to verify that the expected CSP warnings appear in the browser’s console.
In our example we add the following line to the HTML body of the page:&lt;/p&gt;

&lt;div class=&quot;language-html highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;script &lt;/span&gt;&lt;span class=&quot;na&quot;&gt;src=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;https://bad.test/evil.js&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;async=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This manipulation simulates an attack via XSS (Cross-Site-Scripting). In such an attack, “malicious code”, usually in the form of JavaScript, is injected into a website. If the code were to be executed, sensitive data could be tapped. Therefore, it is important to check that if code were to be injected into the page, it would not, under any circumstances, be executed.
We manipulate the HTML body using the route method, which we apply to Playwright’s &lt;code class=&quot;highlighter-rouge&quot;&gt;page&lt;/code&gt; object:&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;setupRouteWithModifiedBody&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Page&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;fetch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
      &lt;span class=&quot;kd&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;bodyForModification&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;bodyForModification&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;bodyForModification&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;replace&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s2&quot;&gt;`&amp;lt;script src=&quot;https://bad.test/evil.js&quot; async=&quot;&quot;&amp;gt;&amp;lt;/script&amp;gt;&amp;lt;/body&amp;gt;`&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
      &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;fulfill&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;bodyForModification&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;In this method, we manipulate the call to the page to be checked. We’ll apply the &lt;code class=&quot;highlighter-rouge&quot;&gt;route&lt;/code&gt; method to the URL of the page, manipulating the HTML body in the process. In the &lt;code class=&quot;highlighter-rouge&quot;&gt;route&lt;/code&gt; method, we specify the URL we want to manipulate as the first parameter. As a second parameter, we define the instructions that cause the body to be manipulated. To do this, we first use &lt;code class=&quot;highlighter-rouge&quot;&gt;route.fetch&lt;/code&gt; to store the actual response to queries about the page in a variable. We then change this answer by adding a “bad” script at the end. Using &lt;code class=&quot;highlighter-rouge&quot;&gt;route.fulfill&lt;/code&gt;, we instruct Playwright to return the manipulated body when the page is accessed.
After the method has been called in the test, every call to the page is intercepted by Playwright and the HTML body of the response is replaced by the manipulated body.
If the script should be called due to an insufficient CSP, we also use Playwright’s &lt;code class=&quot;highlighter-rouge&quot;&gt;route&lt;/code&gt; method. This redirects the call for the script to a script that we have defined:&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;setupRouteForEvilScript&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;https://bad.test/evil.js&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;jsContent&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;`console.log(&quot;Hello world!&quot;);`&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;fulfill&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;200&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;contentType&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;application/javascript&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;jsContent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;If the page with the manipulated body is called up during the test execution, a warning is issued in the console of the browser and the “evil” script is not loaded.&lt;/p&gt;

&lt;figure&gt;
    &lt;img data-src=&quot;/img/posts/security-e2e-tests-img1.png&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;Screenshot of the browser with the URL to be tested. The dev tool is open and shows the error message: Refused to load the script https://evildomain.test/evil.js because it violates the following Content Security Policy directive: script-src nonce-N2Q4MWMmMTIyTUzMy0NmQ5LTg5MWYtZmIxZDUSMWUxZjVi strict-dynamic wasm-unsafe-eval. Note that script-src-elem was not explicitly set, so script-src is used as a fallback. (Backticks and code quotation marks have been erased in this alternative text for technical reasons in order not to disrupt the HTML syntax in the CMS.)&quot; /&gt;
    &lt;figcaption class=&quot;long-fig-caption&quot;&gt; 
&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;The screenshot was taken during the test execution and in it you can see several violated CSP rules. These error messages are written to the array mentioned at the beginning. They are validated in a separate file, just as the CSP in the header of the HTML response. If the error message changes or does not appear at all during a test execution, the test will fail, and a cause and solution must be sought.&lt;/p&gt;

&lt;h2 id=&quot;prevent-clickjacking-with-csp&quot;&gt;Prevent clickjacking with CSP&lt;/h2&gt;
&lt;p&gt;CSP can also be used to prevent “malicious” websites from embedding our page into their website using an iframe element, a so-called clickjacking attack. By embedding the website, our site is overlaid by the malicious website and neither the users nor we as the operator recognize that functions are unintentionally executed on the site. To prevent this, “frame-ancestors `none`” is added to the CSP. This will cause any embedding attempts to fail. For our test, we created a minimal website that includes an iframe element on our page. We used the &lt;code class=&quot;highlighter-rouge&quot;&gt;route&lt;/code&gt; method again.&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;setupRouteForIframeSite&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;body&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;`&amp;lt;!DOCTYPE html&amp;gt;
    &amp;lt;head&amp;gt;
      &amp;lt;meta charset=&quot;utf-8&quot;&amp;gt;
      &amp;lt;title&amp;gt;ClickJacking Test&amp;lt;/title&amp;gt;
    &amp;lt;/head&amp;gt;
    &amp;lt;body&amp;gt;&amp;lt;iframe src=&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;`&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;https://bad.test/clickjacking&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;fulfill&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;contentType&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;text/html;charset=utf-8&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}),&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The method &lt;code class=&quot;highlighter-rouge&quot;&gt;setupRouteForIframeSite&lt;/code&gt; works so that when the URL “https://bad.test/clickjacking” is called in the test, the page defined in the method is called. If the CSP is configured correctly, then the iframe element will not work. In addition, an error message is displayed on the page in the console.&lt;/p&gt;

&lt;figure&gt;
    &lt;img data-src=&quot;/img/posts/security-e2e-tests-img2.png&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;Screenshot of the browser in which the malicious clickjacking domain is opened. The dev tool is open and shows the following error message: Refused to frame http://localhost:4002/ because an ancestor violates the following Content Security Policy directive: frame-ancestors none. (Backticks and code quotation marks have been erased in this alternative text for technical reasons in order not to disrupt the HTML syntax in the CMS.)&quot; /&gt;
    &lt;figcaption class=&quot;long-fig-caption&quot;&gt; 
&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;This can be seen in the screenshot above. The error message also specifies the breached CSP “frame-ancestors `none`”. This error message is written to a validation file as described above and checked each time the test runs.&lt;/p&gt;

&lt;h2 id=&quot;test-csrf-attack&quot;&gt;Test CSRF attack&lt;/h2&gt;
&lt;p&gt;Finally, we present a CSRF scenario that can be checked by means of end-to-end tests in Playwright. The first step is Playwright logging in to the software to be tested. For this test, we have created two minimal websites that send a query to our software to be tested when you click on a link. However, this is not obvious to a user at first glance. For demonstration purposes or test purposes, we used a state-changing GET request.&lt;/p&gt;

&lt;p&gt;We test both a cross-origin and a same-site case.&lt;/p&gt;

&lt;figure&gt;
    &lt;img data-src=&quot;/img/posts/security-e2e-tests-img3.png&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;&quot; /&gt;
    &lt;figcaption class=&quot;long-fig-caption&quot;&gt; 
&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;The first website has a different domain from the page being tested. The second website has a subdomain of our page as a URL (as pictured above). As you can see, for the purposes of this test it has been kept very minimal and essentially only contains the malicious link. When Playwright clicks on the link in the test, we always check that an error message appears when calling up the link. In addition, we use Playwright’s &lt;code class=&quot;highlighter-rouge&quot;&gt;route&lt;/code&gt; method to monitor the endpoint that is attacked by the malicious calls, in this case clicking on the link.&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;monitorAttackedEndpoint&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;attackedEndpoint&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;fetch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;expect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()).&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;toBe&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;403&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;fulfill&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;One of the methods of preventing such an attack is the use of CSRF cookies. This prevents the endpoint from responding to the malicious request, as the malicious site does not have access to the CSRF cookies that must be sent along for a successful request. An http-403 error code is returned in our software when an attempted CSRF attack occurs. We check this using the method presented above.&lt;/p&gt;

&lt;h2 id=&quot;final-reflections&quot;&gt;Final Reflections&lt;/h2&gt;
&lt;p&gt;In this article we used some examples to show how security aspects for web applications, including CSP or CSRF, can be tested automatically in conjunction with Playwright through end-to-end tests. It was shown how some different aspects can be tested, such as the presence of the expected CSP in the http response. The tests can be adapted to the needs of different web applications and thus can be used across projects. The tests presented are only a small excerpt of possible security tests that can be automated. Other aspects of security, such as access authorizations or brute force attacks, can also be tested automatically with the help of end-to-end tests by Playwright.&lt;/p&gt;</content><author><name>adrianWeber</name></author><summary type="html">Testing the security of web applications with Playwright – we show you how to do it, with examples.</summary></entry><entry xml:lang="de"><title type="html">Sicherheit automatisiert testen: Mit Playwright zu robuster Web Security</title><link href="https://blog.cronn.de/de/testing/2025/11/13/security-e2e-tests.html" rel="alternate" type="text/html" title="Sicherheit automatisiert testen: Mit Playwright zu robuster Web Security" /><published>2025-11-13T00:00:00-06:00</published><updated>2025-11-13T00:00:00-06:00</updated><id>https://blog.cronn.de/de/testing/2025/11/13/security-e2e-tests</id><content type="html" xml:base="https://blog.cronn.de/de/testing/2025/11/13/security-e2e-tests.html">&lt;h2 id=&quot;einleitung&quot;&gt;Einleitung&lt;/h2&gt;
&lt;p&gt;Mit automatisierten Ende-zu-Ende-Tests lassen sich nicht nur Bugs finden, sondern auch regelmäßig die Einhaltung von Sicherheitsmaßnahmen überprüfen. Das hat eine Reihe von Vorteilen:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Automatisierte Security-Tests überprüfen zuverlässig, ob Sicherheitsfunktionen wie vorgesehen funktionieren.&lt;/li&gt;
  &lt;li&gt;Sie helfen dabei, Sicherheitsmechanismen während der Weiterentwicklung stabil zu halten und ungewollte Regressionen frühzeitig zu erkennen.&lt;/li&gt;
  &lt;li&gt;Beim Schreiben automatisierter Tests wird die Perspektive potenzieller Angreifer eingenommen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In diesem Artikel zeigen wir anhand konkreter Beispiele, wie sich mit Playwright sicherheitsrelevante Aspekte wie Content Security Policy (CSP), Clickjacking oder Cross-Site Request Forgery (CSRF) zuverlässig testen lassen.&lt;/p&gt;

&lt;h2 id=&quot;ansatz-playwright-ende-zu-ende-security-testing&quot;&gt;Ansatz: Playwright-Ende-zu-Ende-Security-Testing&lt;/h2&gt;
&lt;p&gt;In diesem Artikel werden wir uns auf die Überprüfung ausgewählter Sicherheitsaspekte mithilfe von automatisierten Ende-zu-Ende-Tests konzentrieren. Diese Tests können neben den Ende-zu-Ende-Tests für die Features der Anwendung implementiert werden. Sie können in der gleichen Pipeline laufen wie diese „normalen“ Tests. Daher fühlt sich ihre Entwicklung wie die Entwicklung der Tests für Anwendungsfeatures an. Wir zeigen in diesem Beispiel exemplarisch für Content Security Policy (CSP) wie man einige Aspekte mithilfe von Playwright überprüfen kann. Die CSP wird im Header einer HTML-Antwort verschickt. Sie wird während der Entwicklungsarbeiten des Frontends konfiguriert. Um die CSP zu überprüfen, bietet es sich daher an, im Rahmen eines Tests, die Seite aufzurufen und dort die Checks durchzuführen. Playwright ist für Ende-zu-Ende Tests einer Webapplikation derzeit das gängige Werkzeug. Hier werden wir speziell auf die Besonderheiten beim Testen der CSP mit Playwright eingehen. Im Großen und Ganzen können für die Sicherheitstests die gleichen Ansätze und Methoden verwendet werden wie für Ende-zu-Ende Tests für neue Features.
In unseren Tests für die CSP wollen wir verschiedene Aspekte überprüfen.&lt;/p&gt;

&lt;h2 id=&quot;content-security-policy-überprüfung&quot;&gt;Content-Security-Policy-Überprüfung&lt;/h2&gt;
&lt;p&gt;Der erste Aspekt betrifft das einfache Aufrufen der zu überprüfenden Seite. Hier wollen wir als Erstes sicherstellen, dass keine CSP durch die vorhandene Implementierung verletzt wird. Daher rufen wir die Seite auf und überprüfen, dass keine Warnung in der Konsole des Browsers erscheint. Mit einer kleinen Funktion können wir Playwright anweisen, die Fehlermeldungen der Browserkonsole, die während des Tests erzeugt werden, in ein Array zu schreiben. Dazu übergeben wir die Seite und das Array an die Funktion und deren Implementierung sorgt dafür, dass die Fehlermeldungen in unser Array geschrieben werden.&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;logBrowserErrors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;errors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kr&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[])&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;on&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;console&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;messsage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;messsage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;error&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;errors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;push&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;messsage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;());&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Wir können daher nach dem Aufruf unserer zu überprüfenden Seite validieren, dass keine CSP-Warnungen oder andere Fehlermeldungen auf der Seite ausgelöst wurden. Die Überprüfung kann mit der expect-Funktion von Playwright vorgenommen werden.&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nx&quot;&gt;expect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;errors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;toHaveLength&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Beim Aufrufen der Seite durch Playwright erhalten wir auch die Antwort auf diesen Aufruf. Diese enthält im Header die CSP-Attribute. Wir schreiben diese Werte in eine sogenannte Validierungsdatei. Diese wird beim ersten Durchlaufen des Tests mit den aktuellen CSP-Attributen gefüllt. Diese Werte müssen initial auf die erwarteten Werte kritisch überprüft werden. Sollte es Abweichungen zu den erwarteten Werten geben, so muss die CSP angepasst werden, damit die Werte in der Validierungsdatei mit den erwarteten Werten übereinstimmen.&lt;/p&gt;

&lt;p&gt;Sobald die Validierungsdatei freigegeben worden ist, wird in jedem weiteren Durchlauf des Tests, ob lokal oder in einer Pipeline, der Inhalt der Datei mit den aktuell erhaltenen Attributen verglichen. Sollte eine Abweichung erkannt werden, schlägt der Test fehl. Auf diese Weise werden zuverlässig alle Änderungen an der CSP erkannt. Bei geplanten Änderungen der CSP kann die Datei angepasst werden. In den restlichen Fällen wird überprüft, warum sich die CSP geändert hat und es kann entschieden werden, ob die Änderung rückgängig gemacht werden muss oder ob sie beibehalten werden kann.&lt;/p&gt;

&lt;p&gt;Hier ist ein Beispiel, wie der Inhalt einer solchen Validierungsdatei aussieht:&lt;/p&gt;

&lt;div class=&quot;language-json highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;cspHeaderValues&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;default-src 'self'&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;connect-src 'self'&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;script-src 'nonce-[NONCE]' 'strict-dynamic' 'wasm-unsafe-eval'&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;style-src-elem 'self' 'nonce-[NONCE]'&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;style-src-attr 'unsafe-inline'&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;img-src 'self' blob: data:&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;font-src 'self' data:&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;object-src 'none'&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;base-uri 'self'&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;form-action 'self'&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;frame-ancestors 'none'&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;Die Nonce-Werte haben wir in dieser Datei maskiert, da sie in jedem Durchlauf neu erzeugt werden und der Test daher nicht auf einen konkreten Nonce-Wert testen kann.&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;validateCSPData&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;cspHeaderValues&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;allHeaders&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())[&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;content-security-policy&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;??&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;cspHeaderValues&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;throw&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Error&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;CSP must not be empty.&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;hasMetaCSP&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;checkMetaCSP&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;expect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;hasMetaCSP&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;toBeFalsy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;snapshot&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Record&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kr&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kr&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[]&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{};&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;snapshot&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;cspHeaderValues&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;cspHeaderValues&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;split&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sr&quot;&gt;/;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\s&lt;/span&gt;&lt;span class=&quot;sr&quot;&gt;*/&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;str&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!==&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;compareActualWithValidationFile&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;snapshot&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;In der gezeigten Methode &lt;code class=&quot;highlighter-rouge&quot;&gt;validateCSPData&lt;/code&gt; ist unsere Implementierung für die Validierung der CSP-Attribute zu sehen. Wir müssen der Methode lediglich die Seite (&lt;code class=&quot;highlighter-rouge&quot;&gt;page&lt;/code&gt;) und die Antwort des Aufrufs der Seite (&lt;code class=&quot;highlighter-rouge&quot;&gt;response&lt;/code&gt;) übergeben. Die Methode extrahiert aus der Antwort den Anteil, der die CSP betrifft. In einer ersten Validierung überprüfen wir, dass die CSP nicht leer ist. Wir führen dann eine weitere Überprüfung aus und validieren, dass keine Meta-CSP-Attribute im HTML-Teil der Antwort befindlich sind. Wir haben uns dazu entschieden als eigenen Standard keine Meta-CSP-Attribute zuzulassen und überprüfen das an dieser Stelle, um Konflikte zwischen der CSP im Header und in den Meta-Attributen zu vermeiden. Am Ende der Methode formatieren wir die CSP-Attribute und übergeben sie unserer Methode, die die Werte mit der oben erwähnten Datei vergleicht.&lt;/p&gt;

&lt;h2 id=&quot;csp-warnung-überprüfen&quot;&gt;CSP-Warnung überprüfen&lt;/h2&gt;
&lt;p&gt;In einem weiteren Schritt manipulieren wird den HTML-Teil unserer zu überprüfenden Seite und verifizieren, dass die erwarteten CSP-Warnungen in der Konsole des Browsers erscheinen.
Eine Manipulation enthält zum Beispiel folgende Zeile, die wir dem HTML-Body der Seite hinzufügen:&lt;/p&gt;

&lt;div class=&quot;language-html highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;script &lt;/span&gt;&lt;span class=&quot;na&quot;&gt;src=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;https://bad.test/evil.js&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;async=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Diese Manipulation simuliert einen Angriff per XSS (Cross-Site-Scripting). Bei einem solchen Angriff wird auf eine Website „bösartiger Code“, meist in Form von JavaScript, eingeschleust. Falls der Code zur Ausführung käme, könnten zum Beispiel sensible Daten abgegriffen werden. Daher ist es wichtig zu überprüfen, dass falls Code in die Seite eingeschleust werden sollte, dieser auf keinen Fall ausgeführt wird.&lt;/p&gt;

&lt;p&gt;Die Manipulation des HTML-Bodys erreichen wir mithilfe der Methode route, die wir auf das &lt;code class=&quot;highlighter-rouge&quot;&gt;page&lt;/code&gt;-Objekt von Playwright anwenden:&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;setupRouteWithModifiedBody&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Page&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;fetch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
      &lt;span class=&quot;kd&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;bodyForModification&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;bodyForModification&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;bodyForModification&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;replace&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s2&quot;&gt;`&amp;lt;script src=&quot;https://bad.test/evil.js&quot; async=&quot;&quot;&amp;gt;&amp;lt;/script&amp;gt;&amp;lt;/body&amp;gt;`&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
      &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;fulfill&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;bodyForModification&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;In dieser Methode manipulieren wir den Aufruf der zu überprüfenden Seite. Wir wenden die &lt;code class=&quot;highlighter-rouge&quot;&gt;route&lt;/code&gt;-Methode auf die URL der Seite an und manipulieren dabei den HTML-Body. In der &lt;code class=&quot;highlighter-rouge&quot;&gt;route&lt;/code&gt;-Methode geben wir als ersten Parameter die URL an, die wir manipulieren möchten. Als zweiten Parameter definieren wir die Anweisungen, die dazu führen, dass der Body manipuliert wird. Dazu lassen wir zuerst mittels &lt;code class=&quot;highlighter-rouge&quot;&gt;route.fetch&lt;/code&gt; die eigentliche Antwort auf Anfragen zu der zu testenden Seite in eine Variable speichern. Diese Antwort verändern wird dann, indem wir am Ende ein „böses“ Skript hinzufügen. Mittels &lt;code class=&quot;highlighter-rouge&quot;&gt;route.fulfill&lt;/code&gt; weisen wir Playwright an, beim Aufruf der Seite den manipulierten Body zurückzugeben.&lt;/p&gt;

&lt;p&gt;Nachdem die Methode im Test aufgerufen worden ist, wird jeder Aufruf der Seite von Playwright abgefangen und der HTML-Body der Antwort wird durch den manipulierten Body ersetzt.&lt;/p&gt;

&lt;p&gt;Für den Fall, dass durch eine unzureichende CSP das Skript aufgerufen werden sollte, verwenden wir auch die &lt;code class=&quot;highlighter-rouge&quot;&gt;route&lt;/code&gt;-Methode von Playwright. Diese leitet den Aufruf für das Skript auf ein von uns definiertes Skript um:&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;setupRouteForEvilScript&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;https://bad.test/evil.js&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;jsContent&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;`console.log(&quot;Hello world!&quot;);`&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;fulfill&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;200&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;contentType&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;application/javascript&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;jsContent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Wenn während der Testausführung die Seite mit dem manipulierten Body aufgerufen wird, wird eine Warnung in der Konsole des Browsers ausgegeben und das „böse“ Skript wird nicht geladen.&lt;/p&gt;

&lt;figure&gt;
    &lt;img data-src=&quot;/img/posts/security-e2e-tests-img1.png&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;Screenshot des Browsers mit der zu testenden URL. Das Dev-Tool ist geöffnet und zeigt die Fehlermeldung: Refused to load the script https://evildomain.test/evil.js because it violates the following Content Security Policy directive: script-src nonce-N2Q4MWMmMTIyTUzMy0NmQ5LTg5MWYtZmIxZDUSMWUxZjVi strict-dynamic wasm-unsafe-eval. Note that script-src-elem was not explicitly set, so script-src is used as a fallback. (Backticks und Code-Anführungszeichen wurden in diesem Alt-Text aus technischen Gründen entfernt, um der HTML-Syntax des CMS Genüge zu tun .)&quot; /&gt;
    &lt;figcaption class=&quot;long-fig-caption&quot;&gt; 
&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Man kann in dem Screenshot, der während der Testausführung erstellt wurde, mehrere verletzte CSP-Regeln sehen. Diese Fehlermeldungen werden in das anfangs erwähnte Array geschrieben. Sie werden wie die CSP im Header der HTML-Antwort in einer separaten Datei validiert. Sollte sich während einer Testausführung die Fehlermeldung ändern oder ganz ausbleiben, schlägt der Test fehl und es muss nach einer Ursache sowie einer Lösung dafür gesucht werden.&lt;/p&gt;

&lt;h2 id=&quot;clickjacking-mittels-csp-verhindern&quot;&gt;Clickjacking mittels CSP verhindern&lt;/h2&gt;
&lt;p&gt;Mithilfe der CSP kann auch verhindert werden, dass „bösartige“ Websites unsere Seite mittels eines iframe Elements in ihre Website einbetten, ein sogenannter Clickjacking-Angriff. Durch die Einbettung der Website wird unsere Seite durch die bösartige Website überlagert und weder die User noch wir als Betreiber erkennen, dass ungewollt Funktionen auf der Seite ausgeführt werden. Um dies zu verhindern, wird der CSP „frame-ancestors `none`“ hinzugefügt. Dies sorgt dafür, dass die Einbettung auf anderen Websites fehlschlägt. Für unseren Test haben wir eine minimale Website erstellt, die ein iframe-Element auf unsere Seite enthält. Wir haben dazu wieder die &lt;code class=&quot;highlighter-rouge&quot;&gt;route&lt;/code&gt;-Methode verwendet.&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;setupRouteForIframeSite&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;body&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;`&amp;lt;!DOCTYPE html&amp;gt;
    &amp;lt;head&amp;gt;
      &amp;lt;meta charset=&quot;utf-8&quot;&amp;gt;
      &amp;lt;title&amp;gt;ClickJacking Test&amp;lt;/title&amp;gt;
    &amp;lt;/head&amp;gt;
    &amp;lt;body&amp;gt;&amp;lt;iframe src=&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;`&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;https://bad.test/clickjacking&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;fulfill&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;contentType&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;text/html;charset=utf-8&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}),&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Die Methode &lt;code class=&quot;highlighter-rouge&quot;&gt;setupRouteForIframeSite&lt;/code&gt; führt dazu, dass wenn im Test die URL „https://bad.test/clickjacking“ aufgerufen wird, die in der Methode definierte Seite aufgerufen wird. Wenn die CSP korrekt konfiguriert ist, dann funktioniert das iframe-Element nicht. Zudem wird auf der Seite eine Fehlermeldung in der Konsole ausgegeben.&lt;/p&gt;

&lt;figure&gt;
    &lt;img data-src=&quot;/img/posts/security-e2e-tests-img2.png&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;Screenshot des Browsers in dem die bösartige Clickjacking-Domain geöffnet ist. Das Dev-Tool ist geöffnet und zeigt folgende Fehlermeldung: Refused to frame „http://localhost:4002/ because an ancestor violates the following Content Security Policy directive: frame-ancestors none. (Backticks und Code-Anführungszeichen wurden in diesem Alternativtext aus technischen Gründen entfernt, um der HTML-Syntax im CMS Genüge zu tun .)&quot; /&gt;
    &lt;figcaption class=&quot;long-fig-caption&quot;&gt; 
&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Das ist in dem obigen Screenshot zu sehen. In der Fehlermeldung wird auch die verletzte CSP „frame-ancestors 'none’“ angegeben. Auch diese
Fehlermeldung wird wie oben beschrieben in eine Validierungsdatei geschrieben und bei jeder Ausführung des Tests überprüft.&lt;/p&gt;

&lt;h2 id=&quot;csrf-angriff-testen&quot;&gt;CSRF-Angriff testen&lt;/h2&gt;
&lt;p&gt;Zum Abschluss stellen wir noch ein CSRF-Szenario vor, welches man mittels Ende-zu-Ende-Tests in Playwright überprüfen kann. In einem ersten Schritt loggt sich der Playwright Test bei der zu testenden Software ein. Wir haben für diesen Test zwei minimale Websites erstellt, die bei dem Klick auf einen Link eine Abfrage an unsere zu testende Software abschicken. Dies ist jedoch auf den ersten Blick für einen Nutzer nicht ersichtlich. Zu Demonstrationszwecken beziehungsweise Testzwecken haben wir dazu einen zustandsändernden GET-Request verwendet.&lt;/p&gt;

&lt;p&gt;Wir testen sowohl einen Cross-Origin- als auch einen Same-Site-Fall.&lt;/p&gt;

&lt;figure&gt;
    &lt;img data-src=&quot;/img/posts/security-e2e-tests-img3.png&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;&quot; /&gt;
    &lt;figcaption class=&quot;long-fig-caption&quot;&gt; 
&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Die erste Website hat eine von der zu testenden Seite unterschiedliche Domain. Die zweite Website hat eine Subdomain unserer zu testenden Seite als URL. Diese Seite ist oben abgebildet. Sie ist, wie man sieht, für den Test sehr minimal gehalten und enthält im Wesentlichen nur den bösartigen Link. Wenn Playwright im Test auf den Link klickt, überprüfen wir jeweils, dass eine Fehlermeldung beim Aufruf des Links auf unsere zu testende Software erscheint. Zusätzlich überwachen wir mittels der &lt;code class=&quot;highlighter-rouge&quot;&gt;route&lt;/code&gt;-Methode von Playwright den Endpunkt, der durch die bösartigen Aufrufe, also hier das Klicken auf den Link, angegriffen wird.&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;monitorAttackedEndpoint&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;attackedEndpoint&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;fetch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;expect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()).&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;toBe&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;403&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;fulfill&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Um einen solchen Angriff zu verhindern, werden zum Beispiel CSRF-Cookies verwendet. Auf diese Weise wird verhindert, dass der Endpunkt den bösartigen Request beantwortet, da die bösartige Seite keinen Zugriff auf die CSRF-Cookies hat, die für einen erfolgreichen Request mitgeschickt werden müssen. Es wird in unserer Software bei einem versuchten CSRF-Angriff ein http-403-Fehlercode zurückgegeben. Dies überprüfen wir mit der oben dargestellten Methode.&lt;/p&gt;

&lt;h2 id=&quot;schlussbetrachtung&quot;&gt;Schlussbetrachtung&lt;/h2&gt;
&lt;p&gt;Wir haben hier an einigen Beispielen dargelegt, wie sich Sicherheitsaspekte für Webanwendungen, unter anderem CSP oder CSRF, im Zusammenspiel mit Playwright durch Ende-zu-Ende-Tests automatisiert testen lassen. Es wurde prinzipiell gezeigt, wie sich einige unterschiedliche Aspekte, zum Beispiel das Vorhandensein der erwarteten CSP in der http-Antwort, testen lassen. Die Tests lassen sich an unterschiedliche Webanwendungen anpassen und können auf diese Weise projektübergreifend eingesetzt werden. Die dargestellten Tests sind nur ein kleiner Ausschnitt von möglichen automatisierbaren Sicherheitstests. Weitere Sicherheitsaspekte, wie beispielsweise Zugriffsberechtigungen oder Brute-Force-Angriffe, können auch mithilfe von Ende-zu-Ende-Tests durch Playwright automatisiert getestet werden.&lt;/p&gt;</content><author><name>adrianWeber</name></author><summary type="html">Die Sicherheit von Webanwendungen mit Playwright testen – wir zeigen mit Beispielen wie das geht.</summary></entry><entry xml:lang="en"><title type="html">Using OpenRewrite for large-scale refactoring</title><link href="https://blog.cronn.de/en/java/2025/10/23/openrewrite-for-refactoring.html" rel="alternate" type="text/html" title="Using OpenRewrite for large-scale refactoring" /><published>2025-10-23T00:00:00-05:00</published><updated>2025-10-23T00:00:00-05:00</updated><id>https://blog.cronn.de/en/java/2025/10/23/openrewrite-for-refactoring</id><content type="html" xml:base="https://blog.cronn.de/en/java/2025/10/23/openrewrite-for-refactoring.html">&lt;h2 id=&quot;our-starting-position&quot;&gt;Our Starting Position&lt;/h2&gt;
&lt;p&gt;What makes OpenRewrite so compelling is its automated nature. Migrating your code base between Java versions or upgrading a framework becomes a more relaxed task: You add the corresponding so-called “recipe”, execute &lt;code class=&quot;highlighter-rouge&quot;&gt;rewriteRun&lt;/code&gt;, verify the code with your automated tests and then you’re done. Instead of replacing imports by hand or fighting with Gradle because of a rogue transitive dependency, you can take a coffee break while OpenRewrite works in the background.&lt;/p&gt;

&lt;p&gt;An OpenRewrite recipe contains the logic to do a specific task, like changing &lt;code class=&quot;highlighter-rouge&quot;&gt;org.junit&lt;/code&gt; imports with &lt;code class=&quot;highlighter-rouge&quot;&gt;org.assertj&lt;/code&gt; equivalents. Due to the large user base and the open-source nature of most recipes, you can find recipes for everything from Spring Boot upgrades to switching from &lt;code class=&quot;highlighter-rouge&quot;&gt;JUnit&lt;/code&gt; to &lt;code class=&quot;highlighter-rouge&quot;&gt;AssertJ&lt;/code&gt; in minutes. In some cases, it might also be useful for enforcing code standards – much like an auto-formatter – where OpenRewrite can be integrated into the normal development pipeline, for example as a pre-commit hook.&lt;/p&gt;

&lt;h2 id=&quot;how-does-it-work&quot;&gt;How Does It Work?&lt;/h2&gt;
&lt;p&gt;There are “declarative” and “imperative” recipes which have different purposes. You can imagine declarative recipes like Lego. They are defined in a simple YAML file and typically consist of a list of existing recipes that should be executed together. Many of these recipes are available in OpenRewrite’s public repositories&lt;sup id=&quot;fnref:3&quot;&gt;&lt;a href=&quot;#fn:3&quot; class=&quot;footnote&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; and are designed for common tasks, such as dependency upgrades or framework migrations. For example, the AssertJ&lt;sup id=&quot;fnref:2&quot;&gt;&lt;a href=&quot;#fn:2&quot; class=&quot;footnote&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; recipe I mentioned earlier shows how an entire framework change can be automated with just a single declarative recipe.&lt;/p&gt;

&lt;p&gt;Imperative recipes, on the other hand, are implemented in code. They define the actual logic that transforms your source code; in many cases by replacing old methods with new ones or changing an import. While there are many of these already available, OpenRewrite also provides a comprehensive Java API for writing your own recipes which we’ll explore in more detail next.&lt;/p&gt;

&lt;h2 id=&quot;lossless-semantic-tree-and-visitor-pattern&quot;&gt;Lossless Semantic Tree and Visitor Pattern&lt;/h2&gt;
&lt;p&gt;OpenRewrite builds a Lossless Semantic Tree or LST&lt;sup id=&quot;fnref:4&quot;&gt;&lt;a href=&quot;#fn:4&quot; class=&quot;footnote&quot;&gt;3&lt;/a&gt;&lt;/sup&gt; when it is invoked. An LST, as its name suggests, is a much more detailed version of an AST (Abstract Syntax Tree). While the AST only contains the information necessary for evaluating the logical structure of the program, the LST includes whitespace information as well as a complete representation of the type relations. This means that once OpenRewrite has parsed a source file into an LST it can generate an exact replica from that LST alone. Because of this, local design abnormalities like an unusual indentation will be preserved as OpenRewrite doesn’t assume anything about your code styles. Additionally, because of the extensive type information, it can correctly identify the type of any given field. This is incredibly helpful if a recipe only wants to act on a very specific set of statements, for example for fixing a known vulnerability in a specific method from a package. OpenRewrite also uses this to verify that the new code uses existing types and doesn’t reference unavailable classes.&lt;/p&gt;

&lt;p&gt;Once that LST is built, we get a chance to modify it. OpenRewrite is designed around the visitor pattern&lt;sup id=&quot;fnref:5&quot;&gt;&lt;a href=&quot;#fn:5&quot; class=&quot;footnote&quot;&gt;4&lt;/a&gt;&lt;/sup&gt; which allows us to define the behavior of a “visitor” which is moving along the LST. Different visitor types exist to balance how much you’re able to change vs. what can be validated by OpenRewrite. For example, a &lt;code class=&quot;highlighter-rouge&quot;&gt;JavaIsoVisitor&lt;/code&gt; isn’t allowed to replace a method declaration with a field, however this is possible when using a &lt;code class=&quot;highlighter-rouge&quot;&gt;JavaVisitor&lt;/code&gt;. We would do this by overriding &lt;code class=&quot;highlighter-rouge&quot;&gt;visitX&lt;/code&gt; methods for all kinds of elements of a source file, such as class declarations, method declarations/invocations or conditionals. In each of these methods, we get some representation of that LST node in our code. These are immutable objects which contain the information present in the source file. We can use these when we want to change something for the current element, such as only renaming methods that start with “test”:&lt;/p&gt;

&lt;div class=&quot;language-java highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nd&quot;&gt;@Override&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;J&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;MethodDeclaration&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;visitMethodDeclaration&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;J&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;MethodDeclaration&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;ExecutionContext&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;executionContext&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;{&lt;/span&gt;
   &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;getSimpleName&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;().&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;startsWith&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;test&quot;&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;{&lt;/span&gt;
       &lt;span class=&quot;c1&quot;&gt;// TODO: Rename this method&lt;/span&gt;
   &lt;span class=&quot;o&quot;&gt;}&lt;/span&gt;
   &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;super&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;visitMethodDeclaration&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;executionContext&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;To allow for more control about how the LST is traversed  , OpenRewrite leaves it up to us to decide if and where we call &lt;code class=&quot;highlighter-rouge&quot;&gt;super.visitX&lt;/code&gt;. OpenRewrite generally recommends starting any &lt;code class=&quot;highlighter-rouge&quot;&gt;visitX&lt;/code&gt; method with the call to &lt;code class=&quot;highlighter-rouge&quot;&gt;super&lt;/code&gt;. Omitting this call entirely will mean that the sub-tree is not traversed  at all. This can be beneficial for improving performance; however, it isn’t needed in most cases.
To further expand upon our example from above, let’s now change the method name. In OpenRewrite, the LST itself should not be mutated. Instead, we build a new “method object” that we then return from our method.&lt;/p&gt;

&lt;div class=&quot;language-java highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nd&quot;&gt;@Override&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;J&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;MethodDeclaration&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;visitMethodDeclaration&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;J&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;MethodDeclaration&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;ExecutionContext&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;executionContext&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;{&lt;/span&gt;
   &lt;span class=&quot;nc&quot;&gt;String&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;methodName&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;getSimpleName&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;();&lt;/span&gt;

   &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;methodName&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;startsWith&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;test&quot;&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;{&lt;/span&gt;
       &lt;span class=&quot;nc&quot;&gt;String&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;newName&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;methodName&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;replaceFirst&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;test&quot;&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;check&quot;&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;);&lt;/span&gt;
       &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;withName&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;getName&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;().&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;withSimpleName&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;newName&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;));&lt;/span&gt;
   &lt;span class=&quot;o&quot;&gt;}&lt;/span&gt;
   &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;super&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;visitMethodDeclaration&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;executionContext&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;OpenRewrite detects that we returned an object different to what was passed into   the method. It concludes that we must have changed something about the code and will store this new object in place of the old node in the LST. If you want to instead completely remove a statement, simply return null. In cases where you don’t want to do anything you should return &lt;code class=&quot;highlighter-rouge&quot;&gt;super.visitX&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;After the first visitor has traversed  the whole LST, OpenRewrite will run another visitor through our recipe. If it detects any further changes, it will repeat this step, until no changes are made anymore. To make sure that changes from our recipe did not cause a “regression” in another active recipe, it will then re-run all other recipes in a similar pattern. Once that finishes it can confidently assert that all recipes have applied their logic to every single piece of code in the code base and every possible change has been made.&lt;/p&gt;

&lt;h2 id=&quot;lessons-learned&quot;&gt;Lessons learned&lt;/h2&gt;
&lt;p&gt;Because of the inherent complexity in this type of meta programming, a test-driven development approach is highly favorable. It allows you to effectively cover the many possible edge cases.&lt;/p&gt;

&lt;p&gt;Something that OpenRewrite already warns about in their documentation is recipe state. Recipe state      increases the risk of artifacts from previous data unexpectedly changing the behaviour of your recipe. This not only introduces bugs that are difficult to find and fix, it also massively increases the complexity of your recipe. In our above example this can’t be avoided entirely, since we not only need to rename method declarations but also adjust any calls to those methods. This means we need to pass the information about our new names to &lt;code class=&quot;highlighter-rouge&quot;&gt;visitMethodInvocation&lt;/code&gt; so that we can adjust the method calls accordingly.&lt;/p&gt;

&lt;p&gt;The first option we have is the cursor. While the Java API of OpenRewrite itself doesn’t expose explicit methods like &lt;code class=&quot;highlighter-rouge&quot;&gt;enterClass&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;exitClass&lt;/code&gt;, the cursor keeps track of where exactly we currently are in a stack-like structure, hence the name. It is cleared between every single cycle of a recipe and is best suited for communicating between two methods inside a visitor that come after each other. This wouldn’t be suitable for our scenario since a method call may come from a completely different place in the code base. Another possible solution is to put our information into the execution context. It is only ever cleared after all recipes have run so it is a much more persistent storage location. There are some limitations that you need to keep track of, however. The execution context does not allow mutating stored data to avoid hard to debug problems that occur due to state conflicts. You also need make sure that you don’t overwrite data from other recipes. The optimal way would be a ScanningRecipe&lt;sup id=&quot;fnref:6&quot;&gt;&lt;a href=&quot;#fn:6&quot; class=&quot;footnote&quot;&gt;5&lt;/a&gt;&lt;/sup&gt; visitor, where we first get the opportunity to scan the whole code base and collect information, after which a second visitor can apply changes.&lt;/p&gt;

&lt;h2 id=&quot;final-thoughts&quot;&gt;Final Thoughts&lt;/h2&gt;
&lt;p&gt;With an extensive collection of open-source recipes and a fleshed-out Java API, OpenRewrite is a great way to approach code refactoring at a large scale. While the in-memory nature of the LST naturally will become a bottleneck for bigger projects, this problem is solved by Moderne’s custom solution with which it is possible to split the tree generation and store it more permanently.
While OpenRewrite is primarily focused on Java and the surrounding ecosystem, it also offers recipes for YAML, XML, JSON and even a few other languages like C# or Scala (although in a much more limited capacity).
Further code examples can be found in the cronn github&lt;sup id=&quot;fnref:1&quot;&gt;&lt;a href=&quot;#fn:1&quot; class=&quot;footnote&quot;&gt;6&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;

&lt;div class=&quot;footnotes&quot;&gt;
  &lt;ol&gt;
    &lt;li id=&quot;fn:3&quot;&gt;
      &lt;p&gt;&lt;a href=&quot;https://docs.openrewrite.org/recipes&quot; target=&quot;_blank&quot;&gt;OpenRewrite Recipe catalog&lt;/a&gt; &lt;a href=&quot;#fnref:3&quot; class=&quot;reversefootnote&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
    &lt;li id=&quot;fn:2&quot;&gt;
      &lt;p&gt;&lt;a href=&quot;https://docs.openrewrite.org/recipes/java/testing/assertj/junittoassertj&quot; target=&quot;_blank&quot;&gt;Migrate JUnit asserts to AssertJ&lt;/a&gt; &lt;a href=&quot;#fnref:2&quot; class=&quot;reversefootnote&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
    &lt;li id=&quot;fn:4&quot;&gt;
      &lt;p&gt;&lt;a href=&quot;https://docs.openrewrite.org/concepts-and-explanations/lossless-semantic-trees&quot; target=&quot;_blank&quot;&gt;Lossless Semantic Trees (LST)&lt;/a&gt; &lt;a href=&quot;#fnref:4&quot; class=&quot;reversefootnote&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
    &lt;li id=&quot;fn:5&quot;&gt;
      &lt;p&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Visitor_pattern&quot; target=&quot;_blank&quot;&gt;Wikipedia: Visitor pattern&lt;/a&gt; &lt;a href=&quot;#fnref:5&quot; class=&quot;reversefootnote&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
    &lt;li id=&quot;fn:6&quot;&gt;
      &lt;p&gt;&lt;a href=&quot;https://docs.openrewrite.org/concepts-and-explanations/recipes#scanning-recipes&quot; target=&quot;_blank&quot;&gt;Scanning Recipes&lt;/a&gt; &lt;a href=&quot;#fnref:6&quot; class=&quot;reversefootnote&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
    &lt;li id=&quot;fn:1&quot;&gt;
      &lt;p&gt;&lt;a href=&quot;https://github.com/cronn/open-rewrite-blog-post-example&quot; target=&quot;_blank&quot;&gt;Demo project&lt;/a&gt; &lt;a href=&quot;#fnref:1&quot; class=&quot;reversefootnote&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
  &lt;/ol&gt;
&lt;/div&gt;</content><author><name>jakobRoth</name></author><summary type="html">Automated refactoring with OpenRewrite – efficient and time-saving.</summary></entry><entry xml:lang="en"><title type="html">Performance Testing with k6: A Field Report</title><link href="https://blog.cronn.de/en/testing/2025/07/18/performance-testing-with-k6.html" rel="alternate" type="text/html" title="Performance Testing with k6: A Field Report" /><published>2025-07-18T00:00:00-05:00</published><updated>2025-07-18T00:00:00-05:00</updated><id>https://blog.cronn.de/en/testing/2025/07/18/performance-testing-with-k6</id><content type="html" xml:base="https://blog.cronn.de/en/testing/2025/07/18/performance-testing-with-k6.html">&lt;h3 id=&quot;project-context&quot;&gt;Project context&lt;/h3&gt;

&lt;p&gt;&lt;a href=&quot;https://ga-lotse.de/&quot;&gt;GA-Lotse&lt;/a&gt; is a modular web application for health authorities which is intended to simplify internal documentation and external communication with citizens. Different departments are mapped in modules, which then can be configured by the health authorities. To ensure that the application meets highest security standards, the data is stored separately for each module. This and other security features – such as the Zero Trust principle – lead to intrinsic performance losses, which is why performance testing was an important part of the &lt;a href=&quot;https://www.cronn.de/referenzen/digitalisierung-gesundheitsamt-en&quot;&gt;project&lt;/a&gt;.&lt;/p&gt;

&lt;h3 id=&quot;selecting-the-load-testing-tool&quot;&gt;Selecting the load testing tool&lt;/h3&gt;

&lt;p&gt;It is often the case that you don’t have to implement everything yourself, so we looked for a tool which supports performance testing. Since we want to test a web application, the tool must allow browser testing. Our additional requirements were as follows:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
    &lt;p&gt;The ability to write the test code in TypeScript, as we also use TypeScript for the frontend of the application and the end-to-end tests&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;Open-source availability of the tool&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;Executability on a self-hosted server (not a pure cloud solution)&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;Good reporting to visualize the results of the tests for us and the developers.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After evaluating several tools, we decided on &lt;a href=&quot;https://grafana.com/docs/k6/latest/&quot;&gt;k6&lt;/a&gt;. k6 supports browser tests, enables development in TypeScript and, in combination with Grafana and through individually definable metrics, offers comprehensive reporting.&lt;/p&gt;

&lt;h3 id=&quot;our-setup&quot;&gt;Our setup&lt;/h3&gt;

&lt;p&gt;k6 runs the performance tests and generates some metrics, such as &lt;a href=&quot;https://web.dev/articles/ttfb&quot;&gt;TTFB&lt;/a&gt; or the duration of the individual requests. However, in order to visualize these and other test results, we needed even more tools. We chose &lt;a href=&quot;https://www.influxdata.com/&quot;&gt;InfluxDB&lt;/a&gt; as the database, as it is optimized for storing data in a time-resolved manner. To visualize the results, we used &lt;a href=&quot;https://grafana.com/oss/grafana&quot;&gt;Grafana-Dashboards&lt;/a&gt; because k6 belongs to Grafana and it provides an interface to InfluxDB. To query the data from the InfluxDB, we used the proprietary database query language &lt;a href=&quot;https://docs.influxdata.com/flux/v0/&quot;&gt;Flux&lt;/a&gt;. However, this is not a long-term solution as Flux will probably no longer be supported – or only supported to a limited extent – in the next major version. We decided to use the tools locally and package them in Docker containers in order to be able to run the tests hardware-independently and not be dependent on cloud providers. Alternatively, there is the option of using &lt;a href=&quot;https://grafana.com/products/cloud/k6/&quot;&gt;Grafana Cloud k6&lt;/a&gt;
to avoid installing the tools locally.&lt;/p&gt;

&lt;h3 id=&quot;performance-testing-with-k6&quot;&gt;Performance testing with k6&lt;/h3&gt;

&lt;p&gt;A test with k6 can be executed with a Javascript or TypeScript file (see example script).&lt;/p&gt;
&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Scenario&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;k6/options&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;schoolEntryBrowserTest&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@/modules/browser/schoolEntryBrowserTest&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;schoolEntryApiTest&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@/modules/api/schoolEntryApiTest&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;scenarios&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Record&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kr&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Scenario&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;schoolEntryBrowser&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;exec&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;schoolEntryBrowserTestFunction&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;executor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;constant-vus&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;vus&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;15m&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;browser&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;chromium&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;schoolEntryApi&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;exec&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;schoolEntryApiTestFunction&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;executor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;ramping-vus&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;startVUs&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;stages&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;5m&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;5m&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;5m&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Options&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;discardResponseBodies&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;scenarios&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;scenarios&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;systemTags&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;check&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;scenario&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;setupTimeout&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;5m&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;schoolEntryBrowserTestFunction&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;schoolEntryBrowserTest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;schoolEntryApiTestFunction&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;schoolEntryApiTest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This script defines options for the test and the test functions to be executed. The options are defined as JSON. An important option which determines the course of the test is &lt;code class=&quot;highlighter-rouge&quot;&gt;scenarios&lt;/code&gt;. This is where executable scenarios can be defined, thus mapping the actual test.&lt;/p&gt;

&lt;p&gt;To define a scenario one must define a function to be executed, as well as the number of executing parallel users, which in k6 are called Virtual Users (VU). The total duration of the scenario can be determined by specifying time periods. In addition, ramps can be defined to increase or decrease the number of parallel users during the test. Another way to influence the course of the test is to set a time interval in which a specific number of VUs should go through the scenario.&lt;/p&gt;

&lt;p&gt;Several such scenarios can be defined for a test, which are then run using different configurations. To make this definition of the scenarios easier and faster than editing a long JSON file, we have developed a builder that dynamically creates the scenario configuration and makes it available on GitHub: &lt;a href=&quot;https://github.com/cronn/k6-scenario-builder&quot;&gt;https://github.com/cronn/k6-scenario-builder&lt;/a&gt;.&lt;/p&gt;

&lt;h3 id=&quot;our-findings&quot;&gt;Our findings&lt;/h3&gt;

&lt;p&gt;During testing, we noticed a few things which need to be taken into account. First of all, it makes sense to have a dedicated machine available to run the tests. Since performance is not only affected by the load of many simultaneous users, but also by the amount of data in the database, we created both short spike tests as well as test scenarios that have a runtime of several hours in order to constantly increase the amount of data and simulate a kind of time-lapse of the actual use of the application. These tests can be carried out much more comfortably by an external machine than on your own laptop.&lt;/p&gt;

&lt;p&gt;In addition, the execution of a test requires sufficient resources on the executing machine. Therefore, care should be taken to ensure that there are always free resources available during the execution of a test so as not to unintentionally influence the results. We noticed this when running browser tests with some VUs. Too many browsers open at the same time turned the machine into a bottleneck. Our solution to this is to define both scenarios and browser tests which depict the same user journey, but send the necessary requests directly to the backend in order to increase the load on the backend without accessing the browser. Such API scenarios are also well suited to quickly assemble a scenario and thus get an overview of the backend’s performance.&lt;/p&gt;

&lt;p&gt;Another insight we gained was to test in an environment which was as close to production as possible. After all, the configuration of an environment, especially a complex microservice cluster, can have significant impact on performance. In addition to running the tests from another machine and testing on a production-like environment, it was still important for us to enable testing entirely on our own laptop. This allows developers to independently develop new scenarios and provide easy access to databases and logs.&lt;/p&gt;

&lt;p&gt;It also occurred that we had exceeded professional limits by configuring our scenarios, especially during long tests. For example, we created an unrealistic number of appointments for one day or user, or even had too many users with the same permissions. Many different parameters can influence performance and should therefore be defined as early as possible, allowing us to avoid unnecessary test runs. Nevertheless, it was also important for us to deliberately exceed the known limits to test the limits of the application and then improve it where necessary. After all, the customer may not know their professional limits, or their limits might be reached through technical errors. The application should not become unusable because the user booked one appointment too many. One lesson learned was therefore to clarify professional limits at an early stage and to observe them in the tests.&lt;/p&gt;

&lt;h3 id=&quot;pros-and-cons-of-k6&quot;&gt;Pros and Cons of k6&lt;/h3&gt;

&lt;p&gt;We ran into problems from time to time during testing with k6. A significant limitation of developing performance tests with k6 is a lack of a debugger. k6 uses its own &lt;a href=&quot;https://github.com/grafana/sobek&quot;&gt;JavaScript engine&lt;/a&gt;
to execute the test code, and there is no built-in debugger. The Javascript engine also has other weaknesses which you should be aware of, such as that it does not support the popular fetch API. In the context of browser tests, methods such as &lt;em&gt;goto()&lt;/em&gt; are a weakness, as they do not always work reliably in combination with Chromium, which occasionally leads to timing problems. In addition, locators must be identified via XPaths, which is very susceptible to regression, as well as often unsightly and long. Finally, the documentation of k6 is often relatively short.&lt;/p&gt;

&lt;p&gt;However, k6 also has many advantages. The reporting in combination with InfluxDB and Grafana works very well. Meaningful plots can be quickly created in such a setup without much prior knowledge and then be displayed in a dashboard so that the test results can be analyzed and communicated. In addition, the parallel execution of different scenarios, each of which is also executed with parallel virtual users, works very well. It allows you to create complex scenarios which map different types of performance tests, such as load tests, spike tests, and soak tests. The fact that the test options (and especially the scenarios) are described in JSON is an advantage as it provides a smooth transition to the Typescript code. You also have the option of running the browser tests in headful mode, so that problems can be detected and fixed during execution.&lt;/p&gt;

&lt;h3 id=&quot;summary&quot;&gt;Summary&lt;/h3&gt;

&lt;p&gt;Since we had constantly developed both our tests and setup during the test phase, an iterative approach paid off for us. We started with two simple scenarios for application-critical modules. In these initial scenarios, we realized that we needed more metrics and plots in our reports to analyze the results. Iteratively, we then added metrics to our tests and visualized them in the Grafana board. These metrics included information such as the duration of requests, the loading times of certain pages, or even the CPU and RAM usage of the executing machine. The duration of individual requests was particularly important for us, but which information is relevant depends on the application. Metric types built into k6 allow the collection of information to be flexibly designed. Working with k6 has shown us both strengths and weaknesses of the tool. Whether k6 is the best choice certainly depends on the use case, but for us it was a suitable tool despite some significant weaknesses.&lt;/p&gt;</content><author><name>simonBiwer</name></author><summary type="html">We're sharing our experience with performance testing in the GA-Lotse project – using a setup with k6, Grafana, InfluxDB, and TypeScript.</summary></entry><entry xml:lang="de"><title type="html">Performance-Testing mit k6: Ein Erfahrungsbericht</title><link href="https://blog.cronn.de/de/testing/2025/07/18/performance-testing-mit-k6.html" rel="alternate" type="text/html" title="Performance-Testing mit k6: Ein Erfahrungsbericht" /><published>2025-07-18T00:00:00-05:00</published><updated>2025-07-18T00:00:00-05:00</updated><id>https://blog.cronn.de/de/testing/2025/07/18/performance-testing-mit-k6</id><content type="html" xml:base="https://blog.cronn.de/de/testing/2025/07/18/performance-testing-mit-k6.html">&lt;h3 id=&quot;projektkontext&quot;&gt;Projektkontext&lt;/h3&gt;

&lt;p&gt;&lt;a href=&quot;https://ga-lotse.de/&quot;&gt;GA-Lotse&lt;/a&gt; (Gesundheitsamt-Lotse) ist eine modular aufgebaute Webanwendung für Gesundheitsämter, die die interne Dokumentation und externe Kommunikation mit Bürgerinnen und Bürgern vereinfachen soll. Verschiedene Abteilungen eines Gesundheitsamtes sind in Modulen abgebildet, die für Gesundheitsämter konfiguriert werden können. Damit die Anwendung höchsten Sicherheitsstandards genügt, werden die Daten für jedes Modul separat gespeichert. Dies und weitere Sicherheitsfeatures wie das Zero-Trust-Prinzip führen zu intrinsischen Einbußen der Performance, weshalb das Testen der Performance ein wichtiger Teil des &lt;a href=&quot;https://www.cronn.de/referenzen/digitalisierung-gesundheitsamt&quot;&gt;Projektes&lt;/a&gt; war.&lt;/p&gt;

&lt;h3 id=&quot;auswahl-des-lasttesttools&quot;&gt;Auswahl des Lasttesttools&lt;/h3&gt;

&lt;p&gt;Wie so häufig muss man nicht alles selbst implementieren, daher haben wir uns nach einem Tool umgesehen, das Performance-Testing unterstützt. Da wir eine Webanwendung testen wollen, sollte es Browsertests ermöglichen. Zudem waren unsere Hauptanforderungen folgende:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;
    &lt;p&gt;Die Möglichkeit den Testcode in TypeScript zu schreiben, da wir TypeScript auch für das Frontend der Anwendung und die Ende-zu-Ende-Tests verwenden&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;Open-Source-Verfügbarkeit des Tools&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;Ausführbarkeit auf einem selbstgehosteten Server (keine reine Cloud-Lösung)&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;Ein gutes Reporting, um die Ergebnisse der Tests für uns und die Entwickler zu visualisieren.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nach der Evaluation mehrerer Tools haben wir uns für &lt;a href=&quot;https://grafana.com/docs/k6/latest/&quot;&gt;k6&lt;/a&gt; entschieden. k6 unterstützt Browsertests, ermöglicht die Entwicklung in TypeScript und bietet in Kombination mit Grafana sowie durch individuell definierbare Metriken ein umfassendes Reporting.&lt;/p&gt;

&lt;h3 id=&quot;unser-setup&quot;&gt;Unser Setup&lt;/h3&gt;

&lt;p&gt;k6 führt die Performance-Tests aus und erzeugt dabei bereits einige Metriken, wie z.B. &lt;a href=&quot;https://web.dev/articles/ttfb?hl=de#:~:text=Hinweis%3A%20%E2%80%9ETime%20to%20First%20Byte,um%20auf%20Anfragen%20zu%20reagieren.&quot;&gt;TTFB&lt;/a&gt; oder die Dauer der einzelnen Requests. Um diese und weitere Testergebnisse persistieren und visualisieren zu können, benötigten wir noch weitere Tools.&lt;/p&gt;

&lt;p&gt;Als Datenbank haben wir uns für &lt;a href=&quot;https://www.influxdata.com/&quot;&gt;InfluxDB&lt;/a&gt; entschieden, da diese dafür optimiert ist, Daten zeitaufgelöst zu speichern. Zur Visualisierung der Ergebnisse haben wir &lt;a href=&quot;https://grafana.com/oss/grafana&quot;&gt;Grafana-Dashboards&lt;/a&gt; genutzt, unter anderem da k6 zu Grafana gehört und es eine Schnittstelle zur InfluxDB bietet. Zur Abfrage der Daten aus der InfluxDB haben wir die proprietäre Datenbankabfragesprache &lt;a href=&quot;https://docs.influxdata.com/flux/v0/&quot;&gt;Flux&lt;/a&gt; genutzt. Diese wird jedoch vermutlich in der nächsten Major-Version v3 nicht mehr oder nur noch eingeschränkt unterstützt.&lt;/p&gt;

&lt;p&gt;Wir haben uns entschieden, die Tools lokal zu nutzen und sie in Docker-Container zu verpacken, um die Tests hardwareunabhängig ausführen zu können und nicht von Cloud-Anbietern abhängig zu sein. Alternativ besteht die Möglichkeit, &lt;a href=&quot;https://grafana.com/products/cloud/k6/&quot;&gt;Grafana Cloud k6&lt;/a&gt; zu verwenden, um die lokale Installation der Tools zu vermeiden.&lt;/p&gt;

&lt;h3 id=&quot;performance-tests-mit-k6&quot;&gt;Performance-Tests mit k6&lt;/h3&gt;

&lt;p&gt;Ein Test mit k6 lässt sich mit einem Javascript oder TypeScript-File ausführen (s. Beispielskript).&lt;/p&gt;
&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Scenario&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;k6/options&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;schoolEntryBrowserTest&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@/modules/browser/schoolEntryBrowserTest&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;schoolEntryApiTest&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@/modules/api/schoolEntryApiTest&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;scenarios&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Record&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kr&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Scenario&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;schoolEntryBrowser&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;exec&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;schoolEntryBrowserTestFunction&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;executor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;constant-vus&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;vus&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;15m&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;browser&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;chromium&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;schoolEntryApi&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;exec&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;schoolEntryApiTestFunction&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;executor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;ramping-vus&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;startVUs&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;stages&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;5m&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;5m&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;5m&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Options&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;discardResponseBodies&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;scenarios&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;scenarios&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;systemTags&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;check&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;scenario&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;setupTimeout&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;5m&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;schoolEntryBrowserTestFunction&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;schoolEntryBrowserTest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;schoolEntryApiTestFunction&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;schoolEntryApiTest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;In diesem Skript werden Optionen für den Test sowie die auszuführenden Testfunktionen definiert. Die Optionen werden als JSON definiert. Eine wichtige Option, die den Testverlauf bestimmt, ist &lt;code class=&quot;highlighter-rouge&quot;&gt;scenarios&lt;/code&gt;. Dort können Szenarien definiert werden, die ausgeführt werden und somit den eigentlichen Test abbilden.&lt;/p&gt;

&lt;p&gt;Für ein solches Szenario wird eine auszuführende Funktion, sowie die Anzahl an ausführenden parallelen Nutzern, die in k6 Virtual User (VU) genannt werden, definiert. Mit der Angabe von Zeiträumen kann die Gesamtdauer des Szenarios bestimmt werden. Außerdem können Rampen definiert werden, um die Anzahl der parallelen User während des Tests zu erhöhen oder zu verringern. Eine andere Möglichkeit den Testverlauf zu beeinflussen, ist, ein Zeitintervall festzulegen, in dem eine konkrete Anzahl an VUs das Szenario durchlaufen sollen.&lt;/p&gt;

&lt;p&gt;Für einen Test können mehrere solcher Szenarien definiert werden, die mit unterschiedlichen Konfigurationen durchlaufen werden. Um diese Definition der Szenarien einfacher und schneller zu gestalten als ein langes JSON-File zu editieren, haben wir einen Builder entwickelt, der die Szenario-Konfiguration dynamisch erstellt und diesen auf GitHub zur Verfügung gestellt: &lt;a href=&quot;https://github.com/cronn/k6-scenario-builder&quot;&gt;https://github.com/cronn/k6-scenario-builder&lt;/a&gt;.&lt;/p&gt;

&lt;h3 id=&quot;unsere-erkenntnisse&quot;&gt;Unsere Erkenntnisse&lt;/h3&gt;

&lt;p&gt;Während des Testens sind uns einige Dinge aufgefallen, die es aus unserer Sicht zu berücksichtigen gilt. Zunächst ist es sinnvoll, eine dedizierte Maschine zur Verfügung zu haben, die die Tests ausführt. Da die Performance nicht nur durch Last vieler gleichzeitiger User beeinträchtigt wird, sondern auch von der Menge der Daten in der Datenbank, haben wir neben kurzen Spike-Tests auch Testszenarien erstellt, die eine Laufzeit über mehrere Stunden haben, um so die Datenmenge stetig zu erhöhen und eine Art Zeitraffer der tatsächlichen Nutzung der Anwendung zu simulieren. Diese Tests sind von einer externen Maschine deutlich komfortabler auszuführen als von dem eigenen Laptop.&lt;/p&gt;

&lt;p&gt;Zudem benötigt die Ausführung eines Tests ausreichend Ressourcen auf der ausführenden Maschine. Daher sollte darauf geachtet werden, dass während der Ausführung eines Tests stets noch freie Ressourcen vorhanden sind, um nicht die Ergebnisse ungewollt zu beeinflussen. Dies haben wir bei der Ausführung von Browsertests mit einigen VUs bemerkt. Eine zu große Anzahl an gleichzeitig geöffneten Browsern hat die auszuführende Maschine zum Bottleneck gemacht. Unsere Lösung dafür ist, neben Browsertests gleichzeitig Szenarien zu definieren, die eine möglichst gleiche User-Journey abbilden, jedoch die nötigen Requests direkt ans Backend schicken, um somit die Last aufs Backend browserunabhängig zu erhöhen. Solche API-Szenarien eignen sich auch gut, um schnell ein Szenario zusammenzubauen und somit browserunabhängig einen Überblick über die Performance des Backends zu bekommen.&lt;/p&gt;

&lt;p&gt;Eine weitere Erkenntnis von uns war, auf einer möglichst produktionsnahen Umgebung zu testen. Denn auch die Konfiguration einer Umgebung, gerade ein komplexer Microservice-Cluster, kann die Performance erheblich beeinflussen. Neben dem Ausführen der Tests von einer anderen Maschine und dem Testen auf einer produktionsähnlichen Umgebung war es für uns dennoch wichtig, auch das Testen vollständig auf dem eigenen Laptop zu ermöglichen. Dies ermöglicht die unabhängige Entwicklung neuer Szenarien durch die Entwickler und einen einfachen Zugang zu Datenbanken und Logs.&lt;/p&gt;

&lt;p&gt;Es ist vorgekommen, dass wir durch die Konfiguration unserer Szenarios, vor allem bei langen Tests, fachliche Limits überschritten haben. Zum Beispiel haben wir unrealistisch viele Termine für einen Tag oder User angelegt, oder sogar zu viele User mit den gleichen Berechtigungen gehabt. Viele Größen können die Performance beeinflussen und sollten deshalb möglichst frühzeitig abgesteckt werden. Dadurch können wenig aussagekräftige Testläufe vermieden werden. Trotzdem war es uns auch wichtig, die bekannten Limits bewusst zu überschreiten, um die Reaktion der Anwendung zu testen und dort dann gegebenenfalls nachzubessern. Denn es ist ja nicht gesagt, dass der Kunde seine fachlichen Limits kennt oder diese durch technische Fehler nicht überschritten werden. Bei einem Termin zu viel sollte die Anwendung nicht unbedienbar werden. Ein Learning war für uns daher, fachliche Limits früh abzuklären und in den Tests zu beachten.&lt;/p&gt;

&lt;h3 id=&quot;vor--und-nachteile-von-k6&quot;&gt;Vor- und Nachteile von k6&lt;/h3&gt;

&lt;p&gt;Während des Testens mit k6 sind wir immer mal wieder auf Probleme gestoßen. Eine erhebliche Einschränkung beim Entwickeln von Performance-Tests mit k6 ist ein fehlender Debugger. k6 nutzt eine eigene &lt;a href=&quot;https://github.com/grafana/sobek&quot;&gt;JavaScript-Engine&lt;/a&gt;, um den Testcode auszuführen, für die es keinen Debugger gibt. Die Javascript-Engine hat auch weitere Schwächen, denen man sich bewusst sein sollte. Beispielsweise unterstützt sie die verbreitete Fetch API nicht. Im Zusammenhang mit Browsertests sind Schwächen von k6, dass Methoden wie &lt;em&gt;goto()&lt;/em&gt;, die darauf warten sollen, dass eine Seite geladen ist, im Zusammenspiel mit Chromium nicht immer zuverlässig funktionieren, was hin und wieder zu Timing-Problemen führt. Darüber hinaus müssen Locator über XPaths identifiziert werden, was sehr regressionsanfällig ist, sowie häufig unschön und lang. Zuletzt ist auch die Dokumentation von k6 häufig relativ knapp.&lt;/p&gt;

&lt;p&gt;Einige andere Dinge haben sich als Vorteile von k6 herausgestellt. Das Reporting im Zusammenspiel mit der InfluxDB und Grafana hat wie erhofft sehr gut funktioniert. Über dieses Setup lassen sich ohne große Vorkenntnisse schnell aussagekräftige Plots erstellen und in einem Dashboard anzeigen, sodass die Testergebnisse analysiert und kommuniziert werden können. Außerdem funktioniert das parallele Ausführen von verschiedenen Szenarien, die jeweils ebenfalls mit parallelen virtuellen Usern ausgeführt werden, sehr gut. Dadurch lassen sich komplexe Szenarien erstellen, die verschiedene Arten von Performance-Tests wie Load-Tests, Spike-Tests und Soak-Tests abbilden. Dass die Testoptionen und insbesondere die Szenarien als JSON beschrieben werden ist sehr angenehm, da es einen fließenden Übergang zum Typescript-Code bietet. Außerdem hat man die Möglichkeit, die Browsertests in einem Headful Mode laufen zu lassen, sodass sich Probleme während der Ausführung erkennen lassen und behoben werden können.&lt;/p&gt;

&lt;h3 id=&quot;zusammenfassung&quot;&gt;Zusammenfassung&lt;/h3&gt;

&lt;p&gt;Da wir während der Testphase unsere Tests und unser Setup stetig weiterentwickelt haben, hat sich für uns ein iterativer Ansatz ausgezahlt. Wir sind mit zwei einfachen Szenarien für Module gestartet, die zu den wichtigsten in der Anwendung gehören. Bei diesen ersten Szenarien haben wir festgestellt, dass wir weitere Metriken und Plots in unseren Reports benötigen, um die Ergebnisse analysieren zu können. Iterativ haben wir dann Metriken zu unseren Tests hinzugefügt und im Grafana-Board visualisiert. Dies waren Informationen wie die Dauer von Requests, die Ladezeiten von bestimmten Seiten oder auch die CPU- und RAM-Auslastung der ausführenden Maschine. Für uns war vor allem die Dauer einzelner Requests von Bedeutung, welche Informationen relevant sind, hängt jedoch von der Anwendung ab. Durch in k6 eingebaute Metrik-Typen lässt sich die Erhebung von Informationen flexibel gestalten.&lt;/p&gt;

&lt;p&gt;Die Arbeit mit k6 hat uns sowohl Stärken als auch Schwächen des Tools gezeigt. Ob k6 passend ist, hängt sicher vom Anwendungsfall ab, für uns war es aber trotz einiger signifikanter Schwächen ein passendes Tool.&lt;/p&gt;</content><author><name>simonBiwer</name></author><summary type="html">Wir teilen unsere Erfahrungen mit Performance-Testing im Projekt GA-Lotse – mit einem Setup aus k6, Grafana, InfluxDB und TypeScript.</summary></entry><entry xml:lang="en"><title type="html">Analyzing Business Reports with LLMs – Part 2</title><link href="https://blog.cronn.de/en/ai/largelanguagemodels/2025/06/24/analyse-von-geschaeftsberichten-mit-llms-2-en.html" rel="alternate" type="text/html" title="Analyzing Business Reports with LLMs – Part 2" /><published>2025-06-24T00:00:00-05:00</published><updated>2025-06-24T00:00:00-05:00</updated><id>https://blog.cronn.de/en/ai/largelanguagemodels/2025/06/24/analyse-von-geschaeftsberichten-mit-llms-2-en</id><content type="html" xml:base="https://blog.cronn.de/en/ai/largelanguagemodels/2025/06/24/analyse-von-geschaeftsberichten-mit-llms-2-en.html">&lt;p&gt;Welcome back to our series on analysing annual reports with AI. In &lt;a href=&quot;https://blog.cronn.de/en/ai/largelanguagemodels/2023/07/26/analyzing-business-reports-with-chatgpt-part1.html&quot;&gt;Part One&lt;/a&gt; we showed how the extraction of key figures from annual reports with LLMs (such as ChatGPT) works. Now we are going deeper and showing the final working solution, which we are using in cooperation with North Data.&lt;/p&gt;

&lt;p&gt;We have already demonstrated how relevant information can be filtered out of the dense text of annual reports in a structured way. But if you want to scale this process in practice, you quickly reach its limits – be it in terms of accuracy across many different documents, the robust processing of complex layouts and tables, or the cost-effectiveness of large-scale analysis.&lt;/p&gt;

&lt;p&gt;This is exactly where there have been many exciting developments. With &lt;strong&gt;Gemini Flash&lt;/strong&gt; from Google, a model is available which reshuffles the cards for automated document analysis in terms of speed, contextual understanding, and the delivery of structured data.&lt;sup id=&quot;fnref:1&quot;&gt;&lt;a href=&quot;#fn:1&quot; class=&quot;footnote&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; In this second part, we will ask: what makes Gemini Flash so more powerful for this specific task than previous approaches or the classic OCR pipelines? How does it make the step from feasibility study to productive tool? Let us look under the hood.&lt;/p&gt;

&lt;figure&gt;
&lt;img data-src=&quot;/img/posts/Analyse-von-Geschaeftsberichten-2-northdata-grafik.webp&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;On the left side: Unstructured sample documents; in the centre an arrow pointing to the right, labelled ‘AI’; the arrow points to JSON code.&quot; /&gt;
&lt;figcaption class=&quot;long-fig-caption&quot;&gt; Gemini extracts structured JSON code from PDFs. &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h3 id=&quot;the-classic-approach-ocr-as-the-basis-but-not-the-whole-solution&quot;&gt;The classic approach: OCR as the basis, but not the whole solution&lt;/h3&gt;
&lt;p&gt;Before we dive into Gemini’s capabilities, it is worth looking at the traditional way of extracting data from PDFs. This most commonly starts with &lt;strong&gt;Optical Character Recognition (OCR)&lt;/strong&gt;. OCR tools generate text from scanned documents or image-only PDFs by converting pixels into letters. The result is not only the raw text content, but often also its position on the page, usually in the form of coordinates or so-called bounding boxes for each recognized word or line.&lt;/p&gt;

&lt;figure&gt;
&lt;img data-src=&quot;/img/posts/Analyse-von-Geschaeftsberichten-2-table.webp&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;In an example table (‘Balance sheet’), terms and figures are marked with bounding boxes.&quot; /&gt;
&lt;figcaption class=&quot;long-fig-caption&quot;&gt; OCR Bounding Boxes from Azure Document Intelligence. &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;However, for a meaningful analysis we need &lt;em&gt;structured&lt;/em&gt; data, not continuous text. This is where the challenges begin.&lt;/p&gt;

&lt;p&gt;The first hurdle lays in the structure in the pure text output being recognized. How do you automatically identify tables, related key-value pairs (such as “revenue: €10 million”) or semantically meaningful blocks? This often requires complex, downstream steps – whether purpose-built parsers, rule-based systems that look for specific patterns, or even separate machine learning models trained on tasks such as table recognition.&lt;/p&gt;

&lt;p&gt;However, these downstream systems are often &lt;strong&gt;susceptible to layout changes&lt;/strong&gt;. Small adjustments in the design of a report from one year to the next or the format differing between companies can throw off painstakingly created rules or parsers and make them unusable.&lt;/p&gt;

&lt;p&gt;In addition, there is a lack of &lt;strong&gt;contextual understanding&lt;/strong&gt;. OCR provides the text but does not understand its meaning. Recognizing that the term “Total Assets” on page 10 refers to the same metric as a detailed breakdown in a table on page 45 is beyond the capabilities of pure text recognition.&lt;/p&gt;

&lt;p&gt;All these factors create complexity and thus lead to a &lt;strong&gt;high development and maintenance effort&lt;/strong&gt;. It can be said that OCR is a valuable tool, but for the &lt;strong&gt;extraction of &lt;em&gt;structured&lt;/em&gt; data&lt;/strong&gt; it is usually only the first step in a complex and often fragile processing chain.&lt;/p&gt;

&lt;h3 id=&quot;our-path-to-productive-use-evaluation-model-selection-and-integration&quot;&gt;Our path to productive use: evaluation, model selection and integration&lt;/h3&gt;

&lt;p&gt;The leap from successful demonstration (as shown in Part 1&lt;sup id=&quot;fnref:2&quot;&gt;&lt;a href=&quot;#fn:2&quot; class=&quot;footnote&quot;&gt;2&lt;/a&gt;&lt;/sup&gt;) to a reliable, scalable production system required a systematic approach and further developments in several areas.&lt;/p&gt;

&lt;p&gt;Firstly, a &lt;strong&gt;solid evaluation&lt;/strong&gt; was essential. To this end we manually curated a dataset of 100 representative English annual reports. For the most important key figures, the correct values (ground truth) were annotated by hand and collected in a table. Only with such a reliable basis can the quality of different models and approaches be objectively measured and tracked over time.&lt;/p&gt;

&lt;p&gt;Secondly, we significantly expanded the scope of extraction. Instead of just a few key figures, the goal was now to reliably extract a wide range of over 20 relevant values per report. This includes, among other things, the wage costs, information on profit and loss, cash flow, but also data such as the average number of employees or the name of the auditor.&lt;/p&gt;

&lt;p&gt;These more demanding goals led us to test different models. In the end, the choice fell on &lt;strong&gt;Gemini 2.0 Flash Lite&lt;/strong&gt;: This model optimally combined all the decisive factors for our application.&lt;/p&gt;

&lt;figure&gt;
&lt;img data-src=&quot;/img/posts/Analyse-von-Geschäftsberichten-2-graph.webp&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;Graph, Y-axis: Artificial Analysis Intelligence Index, 0 to 75; X-axis: Price (USD per M tokens), 0 - 8 USD; the graph is divided into four quadrants, Gemini alone is in the upper left quadrant (score 70.49, 3.44 USD); all other models are significantly more expensive or perform worse in the Intelligence Score.&quot; /&gt;
&lt;figcaption class=&quot;long-fig-caption&quot;&gt; LLM comparison based on the parameters &quot;intelligence&quot; and &quot;price&quot;, via  &lt;a href=&quot;https://artificialanalysis.ai/models?models=llama-4-maverick%2Cllama-4-scout%2Cgemini-2-0-flash-lite-001%2Cgemini-2-5-pro%2Cclaude-3-5-haiku%2Cclaude-3-7-sonnet-thinking%2Cpixtral-large-2411%2Cgrok-3%2Cgpt-4o-chatgpt-03-25%2Cgemini-1-5-pro#intelligence-vs-price&quot; target=&quot;_blank&quot;&gt;artificialanalysis.ai&lt;/a&gt;. &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;&lt;strong&gt;Quality &amp;amp; Speed:&lt;/strong&gt; In our tests, Gemini 2.0 Flash Lite showed high accuracy for most of the targeted metrics, often keeping up with that of larger, more expensive models. Google itself positions the Flash models as optimized for tasks where it is important to maintain high speed and efficiency while maintaining high quality &lt;sup id=&quot;fnref:3&quot;&gt;&lt;a href=&quot;#fn:3&quot; class=&quot;footnote&quot;&gt;3&lt;/a&gt;&lt;/sup&gt;. Our experience confirms that the model lives up to its “flash” in its name in terms of processing speed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost:&lt;/strong&gt; A decisive factor for large-scale deployment is cost. Gemini 2.0 Flash Lite is significantly cheaper than the larger Pro models. Compared to older models like gpt-3.5-turbo-16k, which still cost about $3 per million input tokens in July 2023 &lt;sup id=&quot;fnref:4&quot;&gt;&lt;a href=&quot;#fn:4&quot; class=&quot;footnote&quot;&gt;4&lt;/a&gt;&lt;/sup&gt;, the Gemini Flash variant we used is cheaper by a factor of 40 &lt;sup id=&quot;fnref:5&quot;&gt;&lt;a href=&quot;#fn:5&quot; class=&quot;footnote&quot;&gt;5&lt;/a&gt;&lt;/sup&gt;! This makes the processing of thousands of reports economically viable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multimodality &amp;amp; Context:&lt;/strong&gt; A significant advantage over plain text models or classic OCR pipelines is Gemini’s multimodality. Put simply, instead of just delivering the raw text and its coordinates (like traditional OCR), Gemini Flash can “read” the text and “see” the page layout at the same time. It “understands” how text is arranged in columns or tables, recognizes headings, and can interpret images or charts in the document. As a result, it is better at capturing context which the pure text order often does not convey. This is a great advantage, especially with the complex and varied layouts of annual reports. Coupled with the long context window, which allows the analysis of large document sections in one go, this is a decisive step forward.&lt;/p&gt;

&lt;p&gt;This combination of good quality, high speed, low cost, and the ability to understand documents holistically made Gemini 2.0 Flash Lite a viable choice for our productive deployment in collaboration with North Data.&lt;/p&gt;

&lt;h3 id=&quot;gemini-flash-in-action-the-workflow-with-structured-outputs&quot;&gt;Gemini Flash in Action: The Workflow with Structured Outputs&lt;/h3&gt;

&lt;p&gt;The core of our approach combines the strengths of Gemini with pragmatic solutions to deal with the peculiarities of large documents.&lt;/p&gt;

&lt;p&gt;A central problem with annual reports is that they often comprise hundreds of pages. While handing over the entire document to Gemini would be ideal for context, it is too expensive for mass use. To get around this problem, we have developed a multi-step approach: First, we still rely on proven &lt;strong&gt;OCR technology&lt;/strong&gt; to extract the plain text of the entire document. This raw text then serves as the basis for a quick &lt;strong&gt;preliminary analysis&lt;/strong&gt; using keywords. We look for terms and phrases that typically indicate relevant sections, such as “Consolidated Balance Sheet”, “Income Statement” or “Notes to the Financial Statements”.&lt;/p&gt;

&lt;p&gt;Based on this analysis we then select the &lt;strong&gt;up to 100 pages&lt;/strong&gt; that are most likely to contain the financial ratios we are looking for. &lt;em&gt;Only this selection&lt;/em&gt; is then passed on to Gemini Flash Lite as a PDF context. This trick not only significantly reduces processing costs but also helps to focus the model on the important parts of the document and minimize the “noise” of irrelevant pages.&lt;/p&gt;

&lt;p&gt;After isolating the relevant pages, we commission Gemini to extract them into a predefined format. Another building block for precise results is the use of so-called &lt;strong&gt;structured outputs&lt;/strong&gt;. Gemini can not only generate text but also provides directly structured JSON data which follows a predetermined scheme.&lt;/p&gt;

&lt;p&gt;To do this, we define a clear target scheme in advance, which in turn defines exactly which data fields we expect and in which format (such as “number”, “text”, “currency symbol”). In Python, we like to use Pydantic for easy definition and validation. We explicitly give this structure to the model as an instruction. This is not only practical for automated further processing, but also demonstrably improves quality: In our tests, this step alone led to an &lt;strong&gt;improvement in the evaluation result of around 4%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here is a simplified Python example to illustrate the principle with the &lt;code class=&quot;highlighter-rouge&quot;&gt;google-genai&lt;/code&gt; library and structured outputs:&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;google&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;genai&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;google.genai&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;pydantic&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;BaseModel&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Field&lt;/span&gt;


&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;genai&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;api_key&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;GEMINI_API_KEY&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;


&lt;span class=&quot;c1&quot;&gt;# Define the desired output structure using Pydantic
&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;FinancialData&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;BaseModel&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;revenue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Field&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Total revenue reported for the fiscal year.&quot;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;net_income&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Field&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Net income or profit after tax.&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;total_assets&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Field&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Total assets value.&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;fiscal_year&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Field&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;The ending year of the fiscal period.&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;currency_symbol&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Field&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Currency symbol used for major values (e.g., $, £, €).&quot;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;


&lt;span class=&quot;c1&quot;&gt;# Upload the relevant PDF pages (assuming 'selected_report_pages.pdf' was created by pre-filtering)
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pdf_file&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;files&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;upload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;file&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;'selected_report_pages.pdf&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;prompt&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;&quot;&quot;
Please analyze the provided pages from the annual report PDF.
Extract the following financial figures for the main consolidated entity reported:
- Total Revenue
- Net Income (Profit after tax)
- Total Assets
- The Fiscal Year End
- The primary Currency Symbol used for the main financial figures (£, $, € etc.)

Return the data strictly adhering to the provided 'FinancialData' schema.
If a value cannot be found or determined confidently, leave the corresponding field null.
Pay close attention to units (e.g., thousands, millions).
&quot;&quot;&quot;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;generate_content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;gemini-2.0-flash-lite-001&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;contents&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;prompt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;pdf_file&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;GenerateContentConfig&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;response_mime_type&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;application/json&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;response_schema&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;FinancialData&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;extracted_data&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;FinancialData&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;model_validate_json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;extracted_data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;except&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Exception&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;f&quot;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;An error occurred: &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;finally&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;files&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;delete&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pdf_file&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;a-look-at-the-numbers-how-well-does-it-really-work&quot;&gt;A look at the numbers: How well does it really work?&lt;/h3&gt;

&lt;p&gt;To objectively assess the actual performance of our approach with Gemini Flash, we created a dataset of 100 manually annotated business reports. This serves as ground truth against which we check the extraction results of the model.&lt;/p&gt;

&lt;p&gt;The overall accuracy across all metrics and reports for our approach was &lt;strong&gt;83.5%&lt;/strong&gt;. These were the first feasibility values for the solution we integrated at North Data. This is a solid basis which demonstrates that the approach works. However, it gets more interesting when you look at the accuracy for individual metrics:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;&lt;strong&gt;Key figure (parameters)&lt;/strong&gt;&lt;/th&gt;
      &lt;th&gt;&lt;strong&gt;Accuracy&lt;/strong&gt;&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Overall&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;83.5%&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;capital&lt;/td&gt;
      &lt;td&gt;96.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;cash&lt;/td&gt;
      &lt;td&gt;95.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;employees&lt;/td&gt;
      &lt;td&gt;95.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;revenue&lt;/td&gt;
      &lt;td&gt;95.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;equity&lt;/td&gt;
      &lt;td&gt;98.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;currencySymbol&lt;/td&gt;
      &lt;td&gt;99.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;auditorName&lt;/td&gt;
      &lt;td&gt;89.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;materials&lt;/td&gt;
      &lt;td&gt;89.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;…&lt;/td&gt;
      &lt;td&gt;…&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;liabilities (creditors)&lt;/td&gt;
      &lt;td&gt;75.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;currentAssets&lt;/td&gt;
      &lt;td&gt;64.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;realEstate&lt;/td&gt;
      &lt;td&gt;60.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;receivables&lt;/td&gt;
      &lt;td&gt;52.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;tax&lt;/td&gt;
      &lt;td&gt;41.0%&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;what-does-this-table-tell-us-and-what-are-the-current-hurdles&quot;&gt;What does this table tell us and what are the current hurdles?&lt;/h3&gt;

&lt;p&gt;The results paint a clear picture: The model achieves remarkably high accuracy values for &lt;strong&gt;clearly defined master data or values&lt;/strong&gt;, which are often prominently and relatively uniformly shown in annual reports. These include, for example, &lt;code class=&quot;highlighter-rouge&quot;&gt;capital&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;equity&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;employees&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;cash&lt;/code&gt; or the &lt;code class=&quot;highlighter-rouge&quot;&gt;currency symbol&lt;/code&gt;. Fortunately, &lt;strong&gt;hallucinations&lt;/strong&gt; – for example inventing numbers that do not exist in the document – were not a significant problem in our tests. If errors occurred, it was usually due to misinterpretations of existing figures and not to their free invention.&lt;/p&gt;

&lt;p&gt;It becomes more difficult for the model with more complex key figures. This is where the limitations of the current approach become apparent, especially when it comes to &lt;strong&gt;semantic fuzziness&lt;/strong&gt; and varying levels of detail. Many balance sheet items can be defined, named, or broken down differently in reports. Terms such as “total assets” are not always clear – does it mean the balance sheet total before or after deduction of certain items such as goodwill, for example the intangible value?&lt;/p&gt;

&lt;p&gt;The exact definition of &lt;code class=&quot;highlighter-rouge&quot;&gt;current assets&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;receivables&lt;/code&gt; or liabilities varies between companies and reporting standards. This is where the model sometimes reaches its limits in deducing the exact definition valid in the respective report from the immediate context alone.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;dependence on layouts&lt;/strong&gt; and the placement of information also plays a role. Some assets, such as &lt;code class=&quot;highlighter-rouge&quot;&gt;realEstate&lt;/code&gt; (real estate assets), are often not prominently found on the main pages of the balance sheet but are hidden in detail in the “Notes to the Financial Statements” (Appendix). The model’s ability to correctly map such information across different pages and layouts is heavily challenged and results in lower accuracy scores.&lt;/p&gt;

&lt;p&gt;Finally, some metrics require &lt;strong&gt;more complex interpretations or implicit calculations&lt;/strong&gt;. The extraction of values such as &lt;code class=&quot;highlighter-rouge&quot;&gt;tax &lt;/code&gt; is a good example of this. Different types of taxes (income taxes, sales taxes, etc.) and deferred taxes can often be spread over several sections. The correct aggregation and interpretation of this information is challenging, which explains the current accuracy of only 41% for this metric.&lt;/p&gt;

&lt;p&gt;These quantitative results confirm our qualitative observations: the model is excellent at finding clearly labelled information. However, it reaches its limits when dealing with issues such as ambiguities in wording, widely varying or complex layouts, and the need to understand implicit knowledge or contexts across multiple text passages.&lt;/p&gt;

&lt;p&gt;Another important aspect is the &lt;strong&gt;varying accuracy between different companies&lt;/strong&gt;. The standard deviation of accuracy per company is about 9.2%. It is particularly striking that the accuracy of the large, individually designed reports from listed companies (PLCs) such as AstraZeneca (50%), Barclays (65%), HSBC (50%), Shell (70%) or Unilever (55%) tends to be significantly lower than average. Tests with excerpts of different lengths showed that the length of the context to be mastered is not a major difficulty for Gemini, we therefore assume that the uniqueness of the reporting structures of these groups is particularly challenging for the model. While Gemini Flash Lite handles layouts that are often created by smaller companies using off-the-shelf software, these complex cases are a bigger hurdle. One explanation could be that the reports that deviate from the standard rarely made it into Gemini’s training data.&lt;/p&gt;

&lt;p&gt;Another recurring problem is the correct capture of &lt;strong&gt;units and scales&lt;/strong&gt;. Missing or misinterpreting information such as “in thousands of £” or “millions of USD” will result in extracted values that are wrong by factors of 1,000 or 1,000,000. Here, robust downstream validation rules and targeted prompting are necessary to sensitize the model to these details.&lt;/p&gt;

&lt;p&gt;The representation of &lt;strong&gt;negative numbers&lt;/strong&gt;, which is often done by parentheses in annual reports (e.g. “(1.234)” instead of “-1.234”), also requires an explicit note in the prompt so that the model interprets this convention correctly and extracts the numbers with the correct sign. As already mentioned, hallucinations do not pose any major problems here (as it was with older models), it is the interpretation of the numbers that does not always succeed.&lt;/p&gt;

&lt;p&gt;Finally, we are also faced with the classic trade-off between costs and performance in particularly complex cases. More sophisticated reasoning approaches such as Chain-of-Thought (CoT), in which the model makes its “thought steps” explicit, or the use of even larger and more powerful models (for example Gemini 2.5 Pro) could remedy the problems mentioned, especially when analysing the more complex reports.&lt;/p&gt;

&lt;p&gt;However, these are currently often much more expensive. For example, &lt;strong&gt;Gemini 2.5 Pro is currently 16 to 32 times more expensive than the Gemini 2.0 Flash Lite we used&lt;/strong&gt;. The common GPT-4.1, which is used in ChatGPT, also costs $2 per 1 million input tokens – about 27 times as much as Gemini 2.0 Flash Lite. Using our solution to process an average report from our 30-page test dataset costs only about $0.0007!&lt;/p&gt;

&lt;h3 id=&quot;conclusion-gemini-flash-as-a-powerful-addition-to-the-toolbox&quot;&gt;Conclusion: Gemini Flash as a powerful addition to the toolbox&lt;/h3&gt;

&lt;p&gt;Gemini Flash has proven to be a useful building block for us to take the extraction of structured data from annual reports to a new level and bring it into productive use at North Data. It does not necessarily replace the entire classic pipeline (as our OCR pre-filtering shows), but it does provide a powerful, integrated alternative to the core process of intelligent data extraction and structuring.&lt;/p&gt;

&lt;p&gt;The ability to understand layouts, work within a larger context, and deliver structured outputs significantly reduces complexity and maintenance compared to traditional, multi-tiered approaches. The challenges remain, but the progress is clear and opens new opportunities for automated financial data analysis.&lt;/p&gt;

&lt;p&gt;We are excited to see how this technology will develop further and what new solutions will emerge. Have you had similar experiences or developed different strategies? Share your thoughts with us!&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This blog post was written with the support of Gemini 2.5 Pro.&lt;/em&gt;&lt;/p&gt;

&lt;div class=&quot;footnotes&quot;&gt;
  &lt;ol&gt;
    &lt;li id=&quot;fn:1&quot;&gt;
      &lt;p&gt;&lt;a href=&quot;https://getomni.ai/ocr-benchmark&quot; target=&quot;_blank&quot;&gt;OmniAI OCR Benchmark&lt;/a&gt;, retrieved 17/06/25 &lt;a href=&quot;#fnref:1&quot; class=&quot;reversefootnote&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
    &lt;li id=&quot;fn:2&quot;&gt;
      &lt;p&gt;&lt;a href=&quot;https://blog.cronn.de/en/ai/largelanguagemodels/2023/07/26/analyzing-business-reports-with-chatgpt-part1.html&quot; target=&quot;_blank&quot;&gt;cronn Blog: Analyzing Business Reports with ChatGPT – Part I&lt;/a&gt; &lt;a href=&quot;#fnref:2&quot; class=&quot;reversefootnote&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
    &lt;li id=&quot;fn:3&quot;&gt;
      &lt;p&gt;&lt;a href=&quot;https://cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/2-0-flash-lite?hl=de&quot; target=&quot;_blank&quot;&gt;Documentation Google Gemini 2.0 Flash-Lite&lt;/a&gt;, retrieved 17/06/25 &lt;a href=&quot;#fnref:3&quot; class=&quot;reversefootnote&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
    &lt;li id=&quot;fn:4&quot;&gt;
      &lt;p&gt;&lt;a href=&quot;https://web.archive.org/web/20230614104237/https://openai.com/pricing&quot; target=&quot;_blank&quot;&gt;Web Archive: OpenAI-Preise vom 14. Juni 2023&lt;/a&gt;, retrieved 17/06/25 &lt;a href=&quot;#fnref:4&quot; class=&quot;reversefootnote&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
    &lt;li id=&quot;fn:5&quot;&gt;
      &lt;p&gt;&lt;a href=&quot;https://ai.google.dev/gemini-api/docs/pricing?hl=de&quot; target=&quot;_blank&quot;&gt;Prices for Gemini Developer API&lt;/a&gt;, retrieved 17/06/25 &lt;a href=&quot;#fnref:5&quot; class=&quot;reversefootnote&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
  &lt;/ol&gt;
&lt;/div&gt;</content><author><name>leonardThiele</name></author><summary type="html">An AI use case in action: the extraction of key figures from annual reports using LLM.</summary></entry></feed>