diff --git a/n8n/exposed/README.md b/n8n/exposed/README.md new file mode 100644 index 00000000..8ee5f9a0 --- /dev/null +++ b/n8n/exposed/README.md @@ -0,0 +1,114 @@ +Here's the corrected and tightened version: + +--- + +# n8n Exposed REST API + +n8n is a workflow automation platform for creating and executing automated workflows. This testbed demonstrates both exposed and properly secured configurations of the n8n REST API. + +## Vulnerable Setups + +```shell +docker compose -f docker-compose-vuln.yml up -d +``` +Early n8n versions (e.g. 0.54.0) shipped without user management. This setup reproduces that behavior: the REST API is fully accessible without authentication and the `/rest/login` endpoint does not exist. + +```shell +docker compose -f docker-compose-vuln-no-owner.yml up -d +``` +Later 0.x versions introduced user management but allowed instances to run without a configured owner account. In this state, the REST API is accessible without authentication. The `/rest/login` endpoint exists and returns a session cookie on anonymous requests, which can be used in subsequent calls as demonstrated in the steps below. + +```shell +docker compose -f docker-compose-vuln-auth.yml up -d +``` +Reproduces the BasicAuth split-brain misconfiguration: BasicAuth is enabled and protects the UI (returns `401`), but the `/rest/*` router has no auth middleware applied and remains fully accessible without credentials. + +## Safe Setup + +```shell +docker compose -f docker-compose-non-vuln.yml up -d +``` +A properly configured n8n instance with authentication enforced on all `/rest/*` endpoints. The detector must produce no finding against this target. + +## Steps to Reproduce + +### Step 1 — Fingerprint + +Confirm the target is n8n 0.x by checking for fields that are only present in the 0.x settings schema: + +```shell +curl -s http://:5678/rest/settings | jq . +``` + +Expected response on a vulnerable instance: + +```json +{ + "data": { + ... + "urlBaseWebhook": "http://:5678/", + "versionCli": "0.237.0", + "saveManualExecutions": false, + "saveDataErrorExecution": "all", + ... + } +} +``` + +The presence of `versionCli` and `urlBaseWebhook` confirms this is n8n 0.x. This fingerprint self-scoping — the detector cannot fire against modern instances regardless of their configuration. + +### Step 2 — Session Bootstrap (where applicable) + +On instances with user management enabled but no owner configured (`docker-compose-vuln-no-owner.yml`), an anonymous `GET /rest/login` returns a valid session cookie: + +```shell +curl -s -i http://:5678/rest/login +``` + +Expected response: + +``` +HTTP/1.1 200 OK +Set-Cookie: n8n-auth=; Path=/; HttpOnly; SameSite=Lax +``` + +On early versions without user management (`docker-compose-vuln.yml` and `docker-compose-vuln-auth.yml`), this endpoint does not exist. The cookie is not needed — `/rest/workflows` is directly accessible without one. + +### Step 3 — Workflows Access + +Without cookie (early versions / split-brain): + +```shell +curl -s http://:5678/rest/workflows | jq . +``` + +With cookie (no-owner setup): + +```shell +curl -s -H 'Cookie: n8n-auth=' http://:5678/rest/workflows | jq . +``` + +Expected response in both cases: + +```json +{ + "data": [ + { + ... + } + ] +} +``` + +Confirms unauthenticated (or pre-authentication anonymous session) access to workflow data. + +## Notes + +- The vulnerable surface spans all n8n 0.x releases. Early versions (e.g. 0.54.0) had no user management and optional auth that did not cover `/rest/*` routes. Later 0.x versions introduced user management but remained exploitable when no owner account was initialized, or when flags such as `N8N_USER_MANAGEMENT_DISABLED` produced inconsistent auth behavior. +- n8n 1.x enforces authentication at the application layer via session middleware applied directly to the `/rest/` router. Empirical testing confirms that `/rest/workflows` returns `401` on all 1.x+ configurations, including fresh instances with no owner account configured. Reverse proxy misconfigurations that forward unauthenticated requests still reach n8n's own auth check and do not bypass it. +- The `versionCli` and `urlBaseWebhook` fields in `/rest/settings` are unique to n8n 0.x. Their absence in 1.x+ means the fingerprint step will not match modern instances, preventing false positives by design. + +## References + +- https://github.com/n8n-io/n8n +- https://docs.n8n.io/hosting/securing/overview/ diff --git a/n8n/exposed/docker-compose-non-vuln.yml b/n8n/exposed/docker-compose-non-vuln.yml new file mode 100644 index 00000000..303017a0 --- /dev/null +++ b/n8n/exposed/docker-compose-non-vuln.yml @@ -0,0 +1,7 @@ +services: + n8n: + image: n8nio/n8n:2.9.1 + container_name: n8n-non-vulnerable + ports: + - "5678:5678" + restart: unless-stopped diff --git a/n8n/exposed/docker-compose-vuln-auth.yml b/n8n/exposed/docker-compose-vuln-auth.yml new file mode 100644 index 00000000..bde34c25 --- /dev/null +++ b/n8n/exposed/docker-compose-vuln-auth.yml @@ -0,0 +1,11 @@ +services: + n8n: + image: n8nio/n8n:0.54.0 + container_name: n8n-vulnerable-auth + ports: + - "5678:5678" + environment: + - N8N_BASIC_AUTH_ACTIVE=true + - N8N_BASIC_AUTH_USER=admin + - N8N_BASIC_AUTH_PASSWORD=str0ngpassword! + restart: unless-stopped diff --git a/n8n/exposed/docker-compose-vuln-no-owner.yml b/n8n/exposed/docker-compose-vuln-no-owner.yml new file mode 100644 index 00000000..1b929f4c --- /dev/null +++ b/n8n/exposed/docker-compose-vuln-no-owner.yml @@ -0,0 +1,7 @@ +services: + n8n: + image: n8nio/n8n:0.237.0 + container_name: n8n-vulnerable-no-owner + ports: + - "5678:5678" + restart: unless-stopped diff --git a/n8n/exposed/docker-compose-vuln.yml b/n8n/exposed/docker-compose-vuln.yml new file mode 100644 index 00000000..aa20ce25 --- /dev/null +++ b/n8n/exposed/docker-compose-vuln.yml @@ -0,0 +1,7 @@ +services: + n8n: + image: n8nio/n8n:0.54.0 + container_name: n8n-vulnerable + ports: + - "5678:5678" + restart: unless-stopped diff --git a/osduplicate/README.md b/osduplicate/README.md new file mode 100644 index 00000000..f5b19a2f --- /dev/null +++ b/osduplicate/README.md @@ -0,0 +1,3 @@ +# OS duplicate testbeds + +This folder contains testbeds for validating scalibr `osduplicate` Annotators diff --git a/osduplicate/apk/Dockerfile b/osduplicate/apk/Dockerfile new file mode 100644 index 00000000..ddaa52f0 --- /dev/null +++ b/osduplicate/apk/Dockerfile @@ -0,0 +1,26 @@ +FROM alpine:3.21 + +# Install a pkg from main repos +RUN apk add fzf + +# Verify that the extra pkg is not available +RUN ! apk add --simulate orb + +# Add external repository +RUN wget -O /etc/apk/keys/packages@orb.net.rsa.pub https://pkgs.orb.net/stable/alpine/orb.pub +RUN echo "https://pkgs.orb.net/stable/alpine" >> /etc/apk/repositories +RUN apk update + +# Install extra pkg +RUN apk add orb + +COPY verify.sh /usr/bin/verify.sh +RUN chmod +x /usr/bin/verify.sh + +ENV DEFAULT_PKG="fzf" +ENV DEFAULT_PKG_FILE="usr/bin/fzf" +ENV EXTRA_PKG="orb" +ENV EXTRA_PKG_FILE="usr/bin/orb" +ENV LANGUAGE_EXTRACTORS="go/binary" +ENV OS_EXTRACTOR="os/apk" +ENV DUP_ANNOTATOR="vex/os-duplicate/apk" diff --git a/osduplicate/apk/README.md b/osduplicate/apk/README.md new file mode 100644 index 00000000..e7768e70 --- /dev/null +++ b/osduplicate/apk/README.md @@ -0,0 +1,26 @@ +# APK OS Testbed + +This testbed is developed to verify that scalibr adds the exploitability signals (related to `osduplicate`) only to packages which were added through default repositories. + +Inventories returned from files which are part of `apk` packages installed via default repository receive an OS-level advisory which is more precise and less prone to false positives, while packages installed by other means do not receive any OS-level advisory and should be covered using language-level extractors. + +In this example the `Dockerfile` installs: + +- `fzf` from a default repository +- `orb` from a custom repository + +## Verification Steps + +Build the image and run the testbed: + +```sh +docker build --platform linux/amd64 -t osduplicate-apk -f Dockerfile .. +docker run --rm --platform linux/amd64 -v /tmp/scalibr:/usr/bin/scalibr osduplicate-apk /usr/bin/verify.sh +``` + +For manual verification launch: + +```sh +docker build --platform linux/amd64 -t osduplicate-apk . -f Dockerfile .. +docker run --rm -it --platform linux/amd64 -v /tmp/scalibr:/usr/bin/scalibr osduplicate-apk +``` diff --git a/osduplicate/dpkg/Dockerfile b/osduplicate/dpkg/Dockerfile new file mode 100644 index 00000000..b0f4ce68 --- /dev/null +++ b/osduplicate/dpkg/Dockerfile @@ -0,0 +1,29 @@ +FROM debian:bookworm + +RUN apt update && apt-get install -y software-properties-common curl + +# Verify that spark-core is not available in default repos +RUN ! apt install spark-core + +# Install fzf from the default repos +RUN apt install fzf + +# Add extra repository +RUN sh -c "echo 'deb [signed-by=/etc/apt/keyrings/dataproc.gpg] https://storage.googleapis.com/dataproc-bigtop-repo/2_3_deb12_20251203_064720-RC01 dataproc contrib' > /etc/apt/sources.list.d/dataproc.list" +RUN sh -c "curl -s https://storage.googleapis.com/dataproc-bigtop-repo/2_3_deb12_20251203_064720-RC01/archive.key | gpg --dearmor -o /etc/apt/keyrings/dataproc.gpg" +RUN apt-get update + +# Add extra pkg +RUN apt-get install -y spark-core + + +COPY verify.sh /usr/bin/verify.sh +RUN chmod +x /usr/bin/verify.sh + +ENV DEFAULT_PKG="fzf" +ENV DEFAULT_PKG_FILE="usr/bin/fzf" +ENV EXTRA_PKG="spark-core" +ENV EXTRA_PKG_FILE="usr/lib/spark/jars/spark-core_2.12-3.5.3.jar" +ENV LANGUAGE_EXTRACTORS="go/binary,java/archive" +ENV OS_EXTRACTOR="os/dpkg" +ENV DUP_ANNOTATOR="vex/os-duplicate/dpkg" diff --git a/osduplicate/dpkg/README.md b/osduplicate/dpkg/README.md new file mode 100644 index 00000000..ef6ec498 --- /dev/null +++ b/osduplicate/dpkg/README.md @@ -0,0 +1,26 @@ +# DPKG OS Testbed + +This testbed is developed to verify that scalibr adds the exploitability signals (related to `osduplicate`) only to packages which were added through default repositories. + +Inventories returned from files which are part of `dpkg` packages installed via default repository receive an OS-level advisory which is more precise and less prone to false positives, while packages installed by other means do not receive any OS-level advisory and should be covered using language-level extractors. + +In this example the `Dockerfile` installs: + +- `fzf` from a default repository +- `spark-core` from a custom repository + +## Verification Steps + +Build the image and run the testbed: + +```sh +docker build --platform linux/amd64 -t osduplicate-dpkg -f Dockerfile .. +docker run --rm -it --platform linux/amd64 -v /tmp/scalibr:/usr/bin/scalibr osduplicate-dpkg /usr/bin/verify.sh +``` + +For manual verification launch: + +```sh +docker build --platform linux/amd64 -t osduplicate-dpkg -f Dockerfile .. +docker run --rm -it --platform linux/amd64 -v /tmp/scalibr:/usr/bin/scalibr osduplicate-dpkg +``` diff --git a/osduplicate/rpm/README.md b/osduplicate/rpm/README.md new file mode 100644 index 00000000..9c3ad7d6 --- /dev/null +++ b/osduplicate/rpm/README.md @@ -0,0 +1,21 @@ +# RPM OS Testbed + +This testbed is developed to verify that scalibr adds the exploitability signals (related to `osduplicate`) only to packages which were added through default repositories. + +Inventories returned from files which are part of `rpm` packages installed via default repository receive an OS-level advisory which is more precise and less prone to false positives, while packages installed by other means do not receive any OS-level advisory and should be covered using language-level extractors. + +The docker-compose contains multiple docker images which install a pkg from a default repository and from one from an extra one. Then the `verify.sh` script is used to ensure that scalibr correctly adds `exploitability_signals` only to the pkg installed via the default repo. + +## Verification Steps + +Build the image and run the testbed: + +```sh +docker compose up --build +``` + +For manual verification launch: + +```sh +docker compose run amazonlinux2 bash +``` diff --git a/osduplicate/rpm/common.env b/osduplicate/rpm/common.env new file mode 100644 index 00000000..082d5c18 --- /dev/null +++ b/osduplicate/rpm/common.env @@ -0,0 +1,7 @@ +DEFAULT_PKG=runc +DEFAULT_PKG_FILE=usr/bin/runc +EXTRA_PKG=gh +EXTRA_PKG_FILE=usr/bin/gh +LANGUAGE_EXTRACTORS=go/binary +OS_EXTRACTOR=os/rpm +DUP_ANNOTATOR=vex/os-duplicate/rpm diff --git a/osduplicate/rpm/docker-compose.yml b/osduplicate/rpm/docker-compose.yml new file mode 100644 index 00000000..8a5e681b --- /dev/null +++ b/osduplicate/rpm/docker-compose.yml @@ -0,0 +1,130 @@ +x-common: &common + volumes: + - /tmp/scalibr:/usr/bin/scalibr + command: ["/bin/sh", "-c", "/usr/bin/verify.sh || sleep infinity"] + env_file: + - common.env + +services: + rhel: + <<: *common + image: osduplicate-rpm-rhel + build: + context: .. + dockerfile_inline: | + FROM registry.access.redhat.com/ubi9/ubi:latest + RUN dnf install -y runc + RUN dnf install -y 'dnf-command(config-manager)' && \ + dnf config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo && \ + dnf install -y gh + COPY verify.sh /usr/bin/verify.sh + RUN chmod +x /usr/bin/verify.sh + + centos: + <<: *common + image: osduplicate-rpm-centos + build: + context: .. + dockerfile_inline: | + FROM quay.io/centos/centos:stream9 + RUN dnf install -y runc + RUN dnf install -y 'dnf-command(config-manager)' && \ + dnf config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo && \ + dnf install -y gh + COPY verify.sh /usr/bin/verify.sh + RUN chmod +x /usr/bin/verify.sh + + almalinux: + <<: *common + image: osduplicate-rpm-almalinux + build: + context: .. + dockerfile_inline: | + FROM almalinux:9 + RUN dnf install -y runc + RUN dnf install -y 'dnf-command(config-manager)' && \ + dnf config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo && \ + dnf install -y gh + COPY verify.sh /usr/bin/verify.sh + RUN chmod +x /usr/bin/verify.sh + + rockylinux: + <<: *common + image: osduplicate-rpm-rockylinux + build: + context: .. + dockerfile_inline: | + FROM rockylinux:9 + RUN dnf install -y runc + RUN dnf install -y 'dnf-command(config-manager)' && \ + dnf config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo && \ + dnf install -y gh + COPY verify.sh /usr/bin/verify.sh + RUN chmod +x /usr/bin/verify.sh + + sles: + <<: *common + image: osduplicate-rpm-sles + build: + context: .. + dockerfile_inline: | + FROM registry.suse.com/bci/bci-base:15.6 + RUN zypper install -y runc awk + RUN zypper addrepo https://cli.github.com/packages/rpm/gh-cli.repo && \ + zypper --gpg-auto-import-keys refresh && \ + zypper install -y gh + COPY verify.sh /usr/bin/verify.sh + RUN chmod +x /usr/bin/verify.sh + environment: + - DEFAULT_PKG_FILE=usr/sbin/runc + + opensuse: + <<: *common + image: osduplicate-rpm-opensuse + build: + context: .. + dockerfile_inline: | + FROM opensuse/leap:15.6 + RUN zypper install -y runc + RUN zypper addrepo https://cli.github.com/packages/rpm/gh-cli.repo && \ + zypper --gpg-auto-import-keys refresh && \ + zypper install -y gh + COPY verify.sh /usr/bin/verify.sh + RUN chmod +x /usr/bin/verify.sh + environment: + - DEFAULT_PKG_FILE=usr/sbin/runc + + amazonlinux2: + <<: *common + image: osduplicate-rpm-amazonlinux2 + build: + context: .. + dockerfile_inline: | + FROM amazonlinux:2 + RUN yum install -y tomcat + RUN amazon-linux-extras enable docker && \ + yum install -y runc + COPY verify.sh /usr/bin/verify.sh + RUN chmod +x /usr/bin/verify.sh + environment: + - DEFAULT_PKG=tomcat + - DEFAULT_PKG_FILE=usr/share/java/avalon-framework-api.jar + - EXTRA_PKG=runc + - EXTRA_PKG_FILE=usr/sbin/runc + - LANGUAGE_EXTRACTORS=go/binary,java/archive + + amazonlinux2023: + <<: *common + image: osduplicate-rpm-amazonlinux2023 + build: + context: .. + dockerfile_inline: | + FROM amazonlinux:2023 + RUN dnf install -y runc + RUN dnf install -y 'dnf-command(config-manager)' && \ + dnf config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo && \ + dnf install -y gh + COPY verify.sh /usr/bin/verify.sh + RUN chmod +x /usr/bin/verify.sh + environment: + - DEFAULT_PKG_FILE=usr/sbin/runc diff --git a/osduplicate/verify.sh b/osduplicate/verify.sh new file mode 100644 index 00000000..51c43f70 --- /dev/null +++ b/osduplicate/verify.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env sh + +set -e + +# Verify Language Extractors +scalibr -plugins="$LANGUAGE_EXTRACTORS" -o textproto=/tmp/result.textproto +grep -q "$DEFAULT_PKG_FILE" /tmp/result.textproto || { echo "FAIL: DEFAULT_PKG_FILE: $DEFAULT_PKG_FILE not found"; exit 1; } +grep -q "$EXTRA_PKG_FILE" /tmp/result.textproto || { echo "FAIL: EXTRA_PKG_FILE: $EXTRA_PKG_FILE not found"; exit 1; } + +echo "----" + +# Verify OS Extractor +scalibr -plugins="$OS_EXTRACTOR" -o textproto=/tmp/result.textproto +grep -qE "\sname:\s+\"$DEFAULT_PKG\"" /tmp/result.textproto || { echo "FAIL: DEFAULT_PKG: $DEFAULT_PKG not detected by OS extractor"; exit 1; } +grep -qE "\sname:\s+\"$EXTRA_PKG\"" /tmp/result.textproto || { echo "FAIL: EXTRA_PKG: $EXTRA_PKG not detected by OS extractor"; exit 1; } + +echo "----" + +has_exploitability_signals() { + local target="$1" + local file=/tmp/result.textproto + + # Fast-fail: If the target isn't in the file at all, return 1 (false) + if ! grep -q "$target" "$file"; then + return 1 + fi + + # Use awk to check the packages content: + # - treat `packages: {` as separator + # - check if the package block ($0) contains both the target file and exploitability_signals + # - if so return 0 (true) + # - if no match is found by the end return 1 (false) + awk -v tgt="$target" ' + # We escape the { to treat it as a literal character + BEGIN { RS = "packages:[[:space:]]*\\{" } + + $0 ~ tgt && /exploitability_signals/ { + found = 1; + exit 0 + } + END { exit (found ? 0 : 1) } + ' $file +} + + +# Verify that exploitability_signals are only added to default pkg related packages: + +scalibr -plugins="$OS_EXTRACTOR,$DUP_ANNOTATOR,$LANGUAGE_EXTRACTORS" -o textproto=/tmp/result.textproto +if ! has_exploitability_signals "$DEFAULT_PKG_FILE"; then + echo "FAIL: exploitability_signals missing from $DEFAULT_PKG_FILE" >&2 + exit 1 +fi +if has_exploitability_signals "$EXTRA_PKG_FILE"; then + echo "FAIL: exploitability_signals unexpectedly found in $EXTRA_PKG_FILE" >&2 + exit 1 +fi + + +echo "--- All Tests Passed ---"