# Plumber Platform Source: https://getplumber.io/docs/getting-started Get started with the Plumber Platform: connect GitHub or GitLab, audit your CI/CD pipelines for security gaps, and remediate drift continuously. Plumber is a CI/CD security platform that continuously maps, audits, and remediates security gaps in your GitHub Actions workflows and GitLab CI/CD pipelines, so you stay ready for ISO 27001, NIS2, DORA, and SOC 2 audits. ## Secure Your CI/CD Pipelines **CI/CD pipelines are the backbone of your software supply chain, and ensuring their security is a challenging and time-consuming task. Plumber automates this process for you.** - Your CI/CD mapped and fully monitored - 90% less manual effort to keep CI/CD secure - Always audit-ready ## Quick Installation Guide
🐳 Docker Compose
Production-ready deployment with automatic Let's Encrypt or custom certificates.
πŸš€ Kubernetes
Enterprise-grade deployment on Kubernetes using Helm charts.
⏱️ Docker Compose Local
Quick local setup for testing and development on your computer.
πŸ•Έ Podman
Community-supported deployment using Podman containers (production and local).
## Frequently Asked Questions open-source CLI uses the same controls and is handy for scanning a single repository locally or in a CI job.`, }, { question: "Which providers does Plumber support?", answer: `Plumber audits GitLab CI/CD pipelines (self-managed and gitlab.com) and GitHub Actions workflows, with a shared catalog of security controls across both providers.`, }, { question: "Where should I start?", answer: `Pick an installation method (Docker Compose, Kubernetes, or Podman), connect your first group or organization, and review the issues raised by your first audit. Each issue links to a step-by-step remediation guide.`, }, ]} /> ## Community - We love talking with our users. Join our [Discord community](/discord) ## Support - Open a ticket by sending an email to [help@plumber.helpscoutapp.com](mailto:help@plumber.helpscoutapp.com) - Ask help to community on [Discord Server](/discord) --- # Installation Introduction Source: https://getplumber.io/docs/installation This section describes how to setup your self-managed instance of Plumber. This section describes how to setup your self-managed instance of Plumber. ## Installation methods - [🐳 Docker Compose](/docs/installation/docker-compose) (recommended) - [🐳 Docker Compose Local](/docs/installation/docker-compose-local) (for testing on your computer) - [πŸš€ Kubernetes with Helm](/docs/installation/kubernetes) - [πŸ•Έ Podman](/docs/installation/podman) (community-supported) ## Infrastructure The Plumber Infrastructure is composed of the following components:
![Plumber infrastructure](./img/plumber-infra.svg)
- **Plumber frontend**: the Plumber interface - **Plumber backend**: the Plumber backend and API - **Plumber worker**: used to run the tasks of asynchronous requests - **[PostgreSQL](https://github.com/postgres/postgres)**: used to store Plumber backend data - **[Redis](https://github.com/redis/redis)**: used to cache Plumber data and create tasks lists for workers --- # Docker compose Source: https://getplumber.io/docs/installation/docker-compose This page describes how to set up a self-managed instance of Plumber using Docker-compose. This page describes how to set up a self-managed instance of Plumber using **Docker-compose**. ## πŸ’» Requirements - **GitLab instance version >=17.7** - The system requires a Linux server. It runs in 🐳 Docker containers using a docker-compose configuration. Specifications: - OS: **Ubuntu** or **Debian** (tested and supported) - Hardware - CPU x86_64/amd64 with at least 2 cores - 4 GB RAM - 100 GB of storage for Plumber - Network - Users must be able to reach the Plumber server on TCP ports 80 and 443 - The Plumber server must be able to access internet - The Plumber server must be able to communicate with GitLab instance - The installation process requires write access to the DNS Zone to set up Plumber domain - Installed software - [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) - [Docker](https://docs.docker.com/engine/install/) **>= 20.10**
⚠️ Windows / WSL Plumber's install scripts are designed for native Linux environments (Ubuntu, Debian). Some users have successfully installed Plumber through **WSL (Windows Subsystem for Linux)**, but we do not officially support this setup. Depending on your WSL configuration, how you cloned the repository, and your Git settings, you may need to adjust script files manually. If you see CRLF-related errors (for example `bad interpreter: /bin/bash^M`), or `\r` convert line endings with `dos2unix`.
## πŸš€ Installation ### 1. Configure your DNS Create a DNS `A` record pointing your chosen domain to your server's public IP address: - **Name**: `` (e.g. `plumber.mydomain.com`) - **Type**: `A` - **Content**: `` ### 2. Setup Plumber ⚑ Quick install (recommended) πŸ”§ Manual install Run the interactive installer on your server: ```bash curl -fsSL https://raw.githubusercontent.com/getplumber/platform/main/install.sh | bash ``` Choose **"Production"** when prompted. The installer will guide you through: 1. **Domain name** configuration 2. **GitLab OIDC** application setup 3. **TLS certificate** method selection 4. **Database**: internal PostgreSQL or your own external database 5. **Secrets**: automatically generated At the end, the installer will start Plumber for you. #### Step 1: Setup your environment Clone the repository on your server ```sh git clone https://github.com/getplumber/platform.git plumber-platform cd plumber-platform ``` Create your configuration file ```sh cp .env.example .env cat versions.env >> .env ``` #### Step 2: Configure Organization **In your `.env` file:** - **If you want to connect Plumber to a specific GitLab group only**: add the **group path as defined in GitLab** in the `ORGANIZATION` variable (to run the onboarding, you must be at least **Maintainer in this group**). On GitLab.com or non‑self‑hosted instances, this path may differ from the display name of the group, and GitLab can append extra characters to make it unique. Check the group settings (or the GitLab API `full_path`) to copy the exact value. ```bash title=".env" ORGANIZATION="" ``` - **If you want to connect Plumber to the whole GitLab instance**: let the `ORGANIZATION` variable empty (to run the onboarding, you must be a **GitLab instance Admin**) ```bash title=".env" ORGANIZATION="" ``` #### Step 3: Configure Domain name and GitLab URL Edit the `.env` file by updating value of `DOMAIN_NAME` and `JOBS_GITLAB_URL` variables ```bash title=".env" DOMAIN_NAME="" JOBS_GITLAB_URL="https://" ``` ```bash title="Example" DOMAIN_NAME="plumber.mydomain.com" JOBS_GITLAB_URL="https://gitlab.mydomain.com" ``` #### Step 4: Configure GitLab OIDC Plumber uses GitLab as an OAuth2 provider to authenticate users. **Create an application** on your GitLab instance. Open any group and navigate to `Settings > Applications`. Create an application with the following information: 1. Name: `Plumber` 2. Redirect URI: `https:///api/auth/gitlab/callback` 3. Confidential: `true` (keep the box checked) 4. Scopes: `api` Click `Save Application`, then copy the `Application ID` and `Secret` into your `.env` file: ```bash title=".env" GITLAB_OAUTH2_CLIENT_ID="" GITLAB_OAUTH2_CLIENT_SECRET="" ``` #### Step 5: Generate secrets Generate random secrets and set them in your `.env` file: ```bash sed -i."" "s/^SECRET_KEY=.*/SECRET_KEY=\"$(openssl rand -hex 32)\"/" .env sed -i."" "s/^JOBS_DB_PASSWORD=.*/JOBS_DB_PASSWORD=\"$(openssl rand -hex 16)\"/" .env sed -i."" "s/^JOBS_REDIS_PASSWORD=.*/JOBS_REDIS_PASSWORD=\"$(openssl rand -hex 16)\"/" .env ``` #### Step 6: Configure deployment profile **Option A: Let's Encrypt (automatic certificates)** ```bash title=".env" COMPOSE_PROFILES="letsencrypt,internal-db" CERT_RESOLVER="le" ``` No further action needed! Certificates will be automatically generated at first launch. **Option B: Custom certificates** ```bash title=".env" COMPOSE_PROFILES="custom-certs,internal-db" CERT_RESOLVER="" ``` Copy your certificate files: ```bash cp /path/to/your/fullchain.pem .docker/traefik/certs/plumber_fullchain.pem cp /path/to/your/privkey.pem .docker/traefik/certs/plumber_privkey.pem ``` > If you are using an external PostgreSQL database, remove `,internal-db` from `COMPOSE_PROFILES` and configure your database connection by uncommenting and updating the following variables in your `.env` file: > > ```bash title=".env" > JOBS_DB_HOST="your-db-host" > JOBS_DB_PORT="5432" > JOBS_DB_USER="your-db-user" > JOBS_DB_NAME="plumber" > JOBS_DB_SSLMODE="disable" # Options: disable, require, verify-ca > JOBS_DB_TIMEZONE="Europe/Paris" > ``` #### Step 7: (Optional) Add your custom CA If your GitLab instance (or Plumber with custom certificates) uses a TLS certificate signed by your own Certificate Authority (CA), add the CA certificate file (`.pem` or `.crt`) in `.docker/ca-certificates/`: ```bash cp /path/to/your/ca.pem .docker/ca-certificates/ca.pem ``` #### Step 8: Launch the application ```bash docker compose up -d ``` ## ⏫ Update To update your self-managed instance to a new version, run the update script: ```bash ./scripts/update.sh ``` The script will: 1. Stop the running containers 2. Pull the latest changes from the git repository 3. Load the new image versions 4. Migrate your `.env` configuration if needed 5. Start the containers with the new images ## πŸ”„ Backup and restore Data required to fully backup and restore a Plumber system are the following: - Configuration file: `.env` - Databases: - PostgreSQL database of Jobs service - Files data: - File storing data about certificate for Traefik service All these data can be easily backup and restored using 2 scripts from the installation git repository: - `scripts/backup.sh` - `scripts/restore.sh` ### πŸ’½ Backup To backup the system, go to your installation git repository and run the following command: ```bash ./scripts/backup.sh 18 ``` The script will create a `backups` directory and create a backup archive inside it prefixed with the date (`backup_plumber-$DATE`) ### πŸ›³οΈ Restore To restore a backup from scratch on a new system, follow this process: 1. Be sure that your new system is compliant with [requirements](#-requirements) 2. Copy the backup file on your new server 3. Clone the installation repository ```bash git clone https://github.com/getplumber/platform.git plumber-platform cd plumber-platform ``` 4. If the IP address of your server changed from your previous installation, update your DNS records. See [Configure your DNS](#1-configure-your-dns) 5. Launch the restore script ```bash ./scripts/restore.sh 18 ``` --- # Docker compose Local Source: https://getplumber.io/docs/installation/docker-compose-local This page describes how to quickly setup a self-managed instance of Plumber for testing purposes using Docker-compose on your local computer. ## πŸ’» Requirements - **GitLab instance version >=17.7** - [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) - [Docker](https://docs.docker.com/engine/install/) **>= 20.10** - **Supported platforms**: macOS (bash) and Ubuntu (bash). These are tested and supported.
⚠️ Windows / WSL Plumber's install scripts are designed for native Linux and macOS environments. Some users have successfully installed Plumber through **WSL (Windows Subsystem for Linux)**, but we do not officially support this setup. Depending on your WSL configuration, how you cloned the repository, and your Git settings, you may need to adjust script files manually. If you see CRLF-related errors (for example `bad interpreter: /bin/bash^M`), or `\r` convert line endings with `dos2unix`.
## πŸš€ Installation ⚑ Quick install (recommended) πŸ”§ Manual install Run the interactive installer: ```bash curl -fsSL https://raw.githubusercontent.com/getplumber/platform/main/install.sh | bash ``` Choose **"Local"** when prompted. The installer will guide you through: 1. **GitLab OIDC** application setup 2. **Secrets**: automatically generated At the end, Plumber will start on [http://localhost:3000](http://localhost:3000). #### Step 1: Create GitLab Application On your GitLab instance, open the `Applications` page: `Your picture > Preferences > Applications` (example: [gitlab.com](https://gitlab.com/-/profile/applications)) Create an application with the following information: 1. Name: `Plumber` 2. Redirect URI: `http://localhost:3001/api/auth/gitlab/callback` 3. Confidential: `true` (keep the box checked) 4. Scopes: `api` Click on `Save Application`. Keep this GitLab tab open, the `Application ID` and `Secret` will be used in the configuration step. #### Step 2: Setup your environment Clone the repository locally ```sh git clone https://github.com/getplumber/platform.git plumber-platform cd plumber-platform ``` Create your configuration file ```sh cp .env.local.example .env cat versions.env >> .env ``` #### Step 3: Update the configuration Edit the `.env` file: - Copy/paste the `Application ID` and the `Secret` from the GitLab application you just created ```bash title=".env" GITLAB_OAUTH2_CLIENT_ID="" GITLAB_OAUTH2_CLIENT_SECRET="" ``` - Replace `` by domain of your GitLab server ```bash title=".env" JOBS_GITLAB_URL="https://" ``` - **If you want to connect Plumber to a specific GitLab group only**: add the **group path as defined in GitLab** in the `ORGANIZATION` variable (to run the onboarding, you must be at least **Maintainer in this group**). On GitLab.com or non‑self‑hosted instances, this path may differ from the display name of the group, and GitLab can append extra characters to make it unique. Check the group settings (or the GitLab API `full_path`) to copy the exact value. ```bash title=".env" ORGANIZATION="" ``` - **If you want to connect Plumber to the whole GitLab instance**: let the `ORGANIZATION` variable empty (to run the onboarding, you must be a **GitLab instance Admin**) ```bash title=".env" ORGANIZATION="" ``` #### Step 4: Generate secrets Generate random secrets and set them in your `.env` file: ```bash sed -i."" "s/^SECRET_KEY=.*/SECRET_KEY=\"$(openssl rand -hex 32)\"/" .env sed -i."" "s/^JOBS_DB_PASSWORD=.*/JOBS_DB_PASSWORD=\"$(openssl rand -hex 16)\"/" .env sed -i."" "s/^JOBS_REDIS_PASSWORD=.*/JOBS_REDIS_PASSWORD=\"$(openssl rand -hex 16)\"/" .env ``` #### Step 5: Launch Plumber ```bash docker compose -f compose.local.yml up -d ``` Open your Plumber test instance at [http://localhost:3000](http://localhost:3000) ## ⏫ Update To update your local instance to a new version, run the update script: ```bash ./scripts/update.sh ``` The script will pull the latest changes, load new image versions, and restart the containers. --- # Kubernetes Installation Source: https://getplumber.io/docs/installation/kubernetes This page describes how to run a self-managed instance of Plumber on Kubernetes. This page describes how to run a self-managed instance of Plumber on **Kubernetes**. ## πŸ’» Requirements - **GitLab instance version >=17.7** - **A PostgreSQL instance version >= 13** (or let the chart deploy one for you) - **A Redis instance version >= 6** (or let the chart deploy one for you) - A Kubernetes cluster with: - One ingress controller(ex: [Nginx](https://artifacthub.io/packages/helm/ingress-nginx/ingress-nginx) or [Traefik](https://artifacthub.io/packages/helm/traefik/traefik)) - A certificate manager with a ACME provider: [cert-manager](https://artifacthub.io/packages/helm/cert-manager/cert-manager) - _If you let the chart deploy PostgreSQL or Redis, or if you run external services in Kubernetes_: the ability to provision persistent volumes in your cluster - Your local environment with CLI to interact with Kubernetes API: - [Helm](https://github.com/helm/helm) - [Kubectl](https://github.com/kubernetes/kubectl) - Write access to the DNS zone of the domain to use with Plumber - A user account on the GitLab instance ## πŸ› οΈ Installation The Helm chart used in this documentation allows installing all these services embedded in the chart as dependencies or to use external `PostgreSQL` and/or `Redis`. The chart can optionally deploy a standalone PostgreSQL or Redis instance for you. Both alternatives are detailed below. 1. ### πŸ“₯ Initialize your cluster Create the namespace for Plumber ```sh kubectl create ns plumber ``` Add Plumber repo ```sh helm repo add plumber https://charts.getplumber.io/ ``` 2. ### πŸ“„ Configure Domain name Create DNS record 1. Name: `` 2. Type: `A` 3. Content: `` 3. ### 🦊 Configure GitLab OIDC Plumber uses GitLab as an OAuth2 provider to authenticate users. Let's see how to connect it to your GitLab instance. Choose a group on your GitLab instance to create an application. It can be any group. Open the chosen group in GitLab interface and navigate through `Settings > Applications`:
![Profile_Menu](./img/profile_menu_gitlab.png)
Then, create an application with the following information 1. Name: `Plumber self-managed` 2. Redirect URI : `https:///api/auth/gitlab/callback` 3. Confidential: `true` (let the box checked) 4. Scopes: `api` Click on `Save Application` and you should see the following screen: ![Application](./img/application_created_gitlab.png) Store `Application ID` and `Secret` somewhere safe, we will need to use them in next step 4. ### βš™οΈ Configure your values This section describes how to configure your custom values file. The default `values.yaml` is available [here](https://github.com/getplumber/platform/blob/main/charts/plumber/values.yaml). An [example](#-configuration-example) is available at the end of this documentation. **Secrets** **This section is optional**. You need to follow this section only if you want to store secrets values as kubernetes secrets instead of writing them in your custom value file. **Plumber secret** Replace all occurrences of `REDACTED` by your Plumber secrets encoded in base64 and create following secret: 1. `secret-key`: 256 bit secret key used to encrypt sensitive data (`openssl rand -hex 32`) 2. `gitlab-oauth2-client-id`: Application ID of the GitLab application 3. `gitlab-oauth2-client-secret`: Secret of the GitLab application ```yaml apiVersion: v1 kind: Secret metadata: name: plumber-secret namespace: plumber type: Opaque data: secret-key: REDACTED gitlab-oauth2-client-id: REDACTED gitlab-oauth2-client-secret: REDACTED ``` **PostgreSQL secret** Replace `REDACTED` by your postgres password encoded in base64. If you want to use postgres embedded in this chart, choose the value. ```yaml apiVersion: v1 kind: Secret metadata: name: postgresql-secret namespace: plumber type: Opaque data: password: REDACTED ``` **Redis secret** Replace `REDACTED` by your redis password encoded in base64. If you want to use redis embedded in this chart, choose the value. ```yaml apiVersion: v1 kind: Secret metadata: name: redis-secret namespace: plumber type: Opaque data: password: REDACTED ``` **Plumber** Add Plumber related configuration in your new values file `custom_values.yaml`: Add Plumber domain ```yaml front: host: 'plumber.mydomain.com' jobs: host: 'plumber.mydomain.com' # Not using secret for configuration (comment if you use secret) extraEnv: - name: SECRET_KEY value: '' - name: GITLAB_OAUTH2_CLIENT_ID value: '' - name: GITLAB_OAUTH2_CLIENT_SECRET value: '' # Using existing secret for configuration (uncomment if you use secret) #extraEnv: # - name: SECRET_KEY # valueFrom: # secretKeyRef: # name: "plumber-secret" # key: "secret-key" # - name: GITLAB_OAUTH2_CLIENT_ID # valueFrom: # secretKeyRef: # name: "plumber-secret" # key: "gitlab-oauth2-client-id" # - name: GITLAB_OAUTH2_CLIENT_SECRET # valueFrom: # secretKeyRef: # name: "plumber-secret" # key: "gitlab-oauth2-client-secret" worker: replicaCount: 5 # Default is 5. Increase it depending of your needs ``` Add your GitLab instance domain and organization 1. **If you want to connect Plumber to a specific GitLab group only**: add the path of the group in `organization` (to run the onboarding, you must be at least **Maintainer in this group**) ```yaml gitlab: domain: 'https://gitlab.mydomain.com' organization: '' ``` 2. **If you want to connect Plumber to the whole GitLab instance**: let `organization` empty (to run the onboarding, you must be a **GitLab instance Admin**) ```yaml gitlab: domain: 'https://gitlab.mydomain.com' organization: '' ``` Add your Ingress configuration ```yaml ingress: enabled: true className: '' # Add class name for your ingress controller annotations: {} # Add annotation required by your ingress controller or certificate manager ``` (Optional) Add your custom Certificate Authority You can either: 1. Reference an existing secret containing your CA public root certificate using the `existingSecret` key. 2. Or manually add your CA public root certificate in the values using the `certificates` key. ```yaml customCertificateAuthority: existingSecret: "" certificates: [] # - name: rootCA.crt # Must have the .crt extension # value: | # -----BEGIN CERTIFICATE----- # (SNIPPED FOR BREVITY) # -----END CERTIFICATE----- ``` **PostgreSQL** You can either let the chart deploy a standalone PostgreSQL instance or use an external one. **Option A: Deploy PostgreSQL via the chart** When `postgresql.deploy: true`, the chart provisions a single-replica PostgreSQL `StatefulSet`, a headless `Service` named `-postgresql`, and a `PersistentVolumeClaim` for the data directory. The backend and worker pods automatically get an `initContainer` that waits for PostgreSQL to be ready before starting. ```yaml postgresql: deploy: true custom: dbName: 'plumber' sslmode: 'disable' port: 5432 global: postgresql: # Not using secret for auth (comment if you use secret) auth: username: REPLACE_ME_BY_POSTGRES_USERNAME postgresPassword: REPLACE_ME_BY_POSTGRES_PASSWORD # Using existing secret for auth password (uncomment if you use secret) #auth: # username: plumber # existingSecret: "postgresql-secret" # secretKeys: # adminPasswordKey: "password" # userPasswordKey: "password" # Persistence for the PostgreSQL data directory persistence: size: '10Gi' storageClass: '' # leave empty to use the cluster default StorageClass accessMode: ReadWriteOnce # To pull from a private registry or pin an exact version, uncomment and configure: #image: # registry: my-private-registry.example.com # omit to use Docker Hub # repository: postgres # defaults to official Postgres image # tag: "18" # digest: "" # optional: pin exact version with sha256:... # pullPolicy: IfNotPresent ``` **Option B: Use an external PostgreSQL** ```yaml postgresql: deploy: false custom: host: REPLACE_ME_BY_POSTGRES_HOST dbName: REPLACE_ME_BY_POSTGRES_DB_NAME sslmode: 'require' port: 5432 global: postgresql: # Not using secret for auth (comment if you use secret) auth: username: REPLACE_ME_BY_POSTGRES_USERNAME postgresPassword: REPLACE_ME_BY_POSTGRES_PASSWORD # Using existing secret for auth password (uncomment if you use secret) #auth: # username: plumber # existingSecret: "postgresql-secret" # secretKeys: # adminPasswordKey: "password" # userPasswordKey: "password" ``` **Redis** You can either let the chart deploy a standalone Redis instance or use an external one. **Option A: Deploy Redis via the chart** ```yaml redis: deploy: true # Not using secret for auth (comment if you use secret) auth: password: REPLACE_ME_BY_REDIS_PASSWORD # Using existing secret for auth (uncomment if you use secret) #auth: # existingSecret: "redis-secret" # existingSecretPasswordKey: "password" # To pull from a private registry or pin an exact version, uncomment and configure: #image: # registry: my-private-registry.example.com # omit to use Docker Hub # repository: redis # defaults to official Redis image # tag: "8.4" # digest: "" # optional: pin exact version with sha256:... # pullPolicy: IfNotPresent ``` **Option B: Use an external Redis** ```yaml redis: deploy: false custom: port: 6379 host: REPLACE_ME_BY_REDIS_HOST user: REPLACE_ME_BY_REDIS_USENAME cert: | REPLACE_ME_BY_REDIS_TLS_CERTIFICATE # Not using secret for auth (comment if you use secret) auth: password: REPLACE_ME_BY_REDIS_PASSWORD # Using existing secret for auth (uncomment if you use secret) #auth: # existingSecret: "redis-secret" # existingSecretPasswordKey: "password" ``` 5. ### πŸš€ Install the chart ```sh helm upgrade -n plumber --create-namespace --install plumber plumber/plumber -f custom_values.yaml ```
### πŸ“š Configuration example ## ⏫ Update 1. Update Plumber Helm repository ```sh helm repo update ``` 2. Run the helm upgrade ```sh helm upgrade -n plumber --install plumber plumber/plumber -f custom_values.yaml ``` 3. You have successfully updated Plumber πŸŽ‰ --- # Podman Source: https://getplumber.io/docs/installation/podman This page describes how to set up a self-managed instance of Plumber using podman. This page describes how to set up a self-managed instance of Plumber using **podman**, for both production servers and local testing. ## πŸ’» Requirements Production Local (testing only) - **GitLab instance version >=17.7** - The system requires a Linux server running in πŸ•Έ podman containers. Specifications: - OS: Ubuntu or Debian - Hardware - CPU x86_64/amd64 with at least 2 cores - 4 GB RAM - 100 GB of storage for Plumber - Network - Users must be able to reach the Plumber server on TCP ports 80 and 443 - The Plumber server must be able to access internet - The Plumber server must be able to communicate with GitLab instance - The installation process requires write access to the DNS Zone to set up Plumber domain - Installed software - [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) - [podman](https://podman.io/docs/installation) - **GitLab instance version >=17.7** - [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) - [podman](https://podman.io/docs/installation) Docker hub registry must be resolved by podman in file **/etc/containers/registries.conf**: ```bash title="/etc/containers/registries.conf" unqualified-search-registries = ["docker.io"] ``` ## πŸ› οΈ Installation Production Local (testing only) 1. ### πŸ“₯ Setup your environment Clone the repository on your server ```sh git clone https://github.com/getplumber/platform.git plumber-platform cd plumber-platform ``` Create your configuration file ```sh cp .env.example .env ``` 2. ### πŸ“‹ Configure Organization **In your `.env` file:** - **If you want to connect Plumber to a specific GitLab group only**: add the path of the group in `ORGANIZATION` variable (to run the onboarding, you must be at least **Maintainer in this group**) ```bash title=".env" ORGANIZATION="" ``` - **If you want to connect Plumber to the whole GitLab instance**: let the `ORGANIZATION` variable empty (to run the onboarding, you must be a **GitLab instance Admin**) ```bash title=".env" ORGANIZATION="" ``` 3. ### πŸ“„ Configure Domain name Edit the `.env` file by updating value of `DOMAIN_NAME` and `JOBS_GITLAB_URL` variables ```bash title=".env" DOMAIN_NAME="" JOBS_GITLAB_URL="https://" ``` ```bash title="Example" DOMAIN_NAME="plumber.mydomain.com" JOBS_GITLAB_URL="https://gitlab.mydomain.com" ``` Create DNS record: - Name: `` - Type: `A` - Content: `` 4. ### 🦊 Configure GitLab OIDC Plumber uses GitLab as an OAuth2 provider to authenticate users. **Create an application** on your GitLab instance. Choose any group, then navigate to `Settings > Applications`. Create an application with the following information: 1. Name: `Plumber` 2. Redirect URI: `https:///api/auth/gitlab/callback` 3. Confidential: `true` (keep the box checked) 4. Scopes: `api` Click on `Save Application`, then copy the credentials into your `.env` file: ```bash title=".env" GITLAB_OAUTH2_CLIENT_ID="" GITLAB_OAUTH2_CLIENT_SECRET="" ``` 5. ### πŸ” Generate secrets Generate random secrets for all components: ```bash sed -i "s/REPLACE_ME_BY_SECRET_KEY/$(openssl rand -hex 32)/g" .env sed -i "s/REPLACE_ME_BY_JOBS_DB_PASSWORD/$(openssl rand -hex 16)/g" .env sed -i "s/REPLACE_ME_BY_JOBS_REDIS_PASSWORD/$(openssl rand -hex 16)/g" .env ``` 6. ### πŸ“‹ (Optional) Add your custom CA If your GitLab instance is using a TLS certificate signed with your own Certificate authority (CA), add the CA certificate file in the appropriate directory. 7. ### πŸ“„ Prepare podman for launch Generate podman network: ```bash podman network create intranet ``` Generate podman socket: ```bash systemctl --user start podman.socket systemctl --user enable podman.socket ``` If you encounter the error **Failed to connect to bus: No medium found**, use these commands with your user as sudoer: ```bash sudo loginctl enable-linger sudo systemctl --user -M @ start podman.socket sudo systemctl --user -M @ enable podman.socket ``` Generate podman config files: ```bash set -a; source .env; set +a export uid=$(id -u) envsubst < podman.yml.example > podman.yml envsubst < configmap.yml.example > configmap.yml ``` Allow port 80 and above in system for local user: Add this line to **/etc/sysctl.conf** as sudo user or root: ```title="/etc/sysctl.conf" net.ipv4.ip_unprivileged_port_start=80 ``` Restart sysctl: ```sh sudo systemctl restart systemd-sysctl ``` 8. ### πŸš€ Launch the application ```bash podman play kube podman.yml --configmap configmap.yml --network intranet ``` 1. ### 🦊 Create GitLab Application On your GitLab instance, open the `Applications` page: `Your picture > Preferences > Applications` (example: [gitlab.com](https://gitlab.com/-/profile/applications)) Create an application with the following information: 1. Name: `Plumber` 2. Redirect URI: `http://localhost:3001/api/auth/gitlab/callback` 3. Confidential: `true` (keep the box checked) 4. Scopes: `api` Click on `Save Application`. Keep this GitLab tab open, the `Application ID` and `Secret` will be used in the configuration step. 2. ### πŸ“₯ Setup your environment Clone the repository locally ```sh git clone https://github.com/getplumber/platform.git plumber-platform cd plumber-platform ``` Create your configuration file ```sh cp .env.local.example .env ``` 3. ### πŸ“š Update the configuration Edit the `.env` file: - Copy/paste the `Application ID` and the `Secret` from the GitLab application you just created ```bash title=".env" GITLAB_OAUTH2_CLIENT_ID="" GITLAB_OAUTH2_CLIENT_SECRET="" ``` - Replace `` by domain of your GitLab server ```bash title=".env" JOBS_GITLAB_URL="https://" ``` - **If you want to connect Plumber to a specific GitLab group only**: add the path of the group in `ORGANIZATION` variable ```bash title=".env" ORGANIZATION="" ``` - **If you want to connect Plumber to the whole GitLab instance**: let the `ORGANIZATION` variable empty ```bash title=".env" ORGANIZATION="" ``` Run the following commands to generate random secrets for all components: ```bash sed -i "s/REPLACE_ME_BY_SECRET_KEY/$(openssl rand -hex 32)/g" .env sed -i "s/REPLACE_ME_BY_JOBS_DB_PASSWORD/$(openssl rand -hex 16)/g" .env sed -i "s/REPLACE_ME_BY_JOBS_REDIS_PASSWORD/$(openssl rand -hex 16)/g" .env ``` 4. ### πŸ“„ Prepare podman for launch Generate podman network: ```bash podman network create intranet ``` Generate podman socket: ```bash systemctl --user start podman.socket systemctl --user enable podman.socket ``` Generate podman config files: ```bash set -a; source .env; set +a export uid=$(id -u) envsubst < podman.local.yml.example > podman.yml envsubst < configmap.local.yml.example > configmap.yml ``` 5. ### πŸš€ Launch Plumber! Start Plumber ```bash podman play kube podman.yml --configmap configmap.yml --network intranet ``` Open your Plumber test instance πŸ‘‰ [click here πŸŽ‰](http://localhost:3000) ## ⏫ Update Production Local 1. Navigate to the location of your [`platform`](https://github.com/getplumber/platform/) git repository 2. Update it ```sh git pull ``` 3. Open the `.env.example` file and copy the values of `FRONTEND_IMAGE_TAG` and `BACKEND_IMAGE_TAG` variables 4. Edit the `.env` file by updating those values ```sh title=".env" FRONTEND_IMAGE_TAG="" BACKEND_IMAGE_TAG="" ``` 5. Regenerate config files and restart ```sh set -a; source .env; set +a export uid=$(id -u) envsubst < podman.yml.example > podman.yml envsubst < configmap.yml.example > configmap.yml podman play kube podman.yml --replace --configmap configmap.yml --network intranet ``` 6. You have successfully updated Plumber πŸŽ‰ 1. Navigate to the location of your [`platform`](https://github.com/getplumber/platform/) git repository 2. Update it ```sh git pull ``` 3. Open the `.env.local.example` file and copy the values of `FRONTEND_IMAGE_TAG` and `BACKEND_IMAGE_TAG` variables 4. Edit the `.env` file by updating those values ```sh title=".env" FRONTEND_IMAGE_TAG="" BACKEND_IMAGE_TAG="" ``` 5. Update local yaml files and restart ```sh set -a; source .env; set +a export uid=$(id -u) envsubst < podman.local.yml.example > podman.yml envsubst < configmap.local.yml.example > configmap.yml podman play kube podman.yml --replace --configmap configmap.yml --network intranet ``` 6. You have successfully updated Plumber πŸŽ‰ ## πŸ”„ Backup and restore Data required to fully backup and restore a Plumber system are the following: - Configuration file: `.env` - Databases: - PostgreSQL database of Jobs service - Files data: - File storing data about certificate for Traefik service All these data can be easily backup and restored using 2 scripts from the installation git repository: - `scripts/backup_podman.sh` - `scripts/restore_podman.sh` ### πŸ’½ Backup To backup the system, go to your installation git repository and run the following command: ```bash ./scripts/backup_podman.sh 13 ``` The script will create a `backups` directory and create a backup archive inside it prefixed with the date (`backup_plumber-$DATE`) ### πŸ›³οΈ Restore To restore a backup from scratch on a new system, follow this process: 1. Be sure that your new system is compliant with [requirements](#-requirements) 2. Copy the backup file on your new server 3. Clone the installation repository ```bash git clone https://github.com/getplumber/platform.git plumber-platform cd plumber-platform ``` 4. If the IP address of your server changed from your previous installation, update your DNS records 5. Launch the restore script ```bash ./scripts/restore_podman.sh 13 ``` --- # Configuration Reference Source: https://getplumber.io/docs/installation/reference This reference explains the global configuration options for Plumber. All configuration is managed through environment variables. This reference explains the global configuration options for Plumber. All configuration is managed through environment variables. ## API Configuration - **`JOBS_LISTEN_ADDR`**: The address on which the backend listens (e.g., `localhost`) - **`JOBS_LISTEN_PORT`**: The port on which the backend listens (e.g., `3000`) - **`JOBS_API_DOMAIN`**: The base URL for the backend API (e.g., `https://api.example.com`) - **`JOBS_FRONTEND_URL`**: The URL of the frontend application (e.g., `https://app.example.com`) - **`JOBS_CORS_ORIGIN`**: Specifies allowed CORS origins. Use `*` to allow all origins ## Session and Security - **`JOBS_SESSION_TTL`**: The validity duration of a user session (e.g., `24h`). **Required** - **`SECRET_KEY`**: The encryption key for sensitive data (must be a hexadecimal string). Ensure this is securely generated and kept private. **Required** ## Analysis Configuration - **`JOBS_ANALYSIS_TIMEOUT`**: The duration after which an analysis is considered as failed. Increase this value if you have many projects. Default: `20m` - **`JOBS_ANALYSIS_CLEANUP_RETENTION`**: Time to keep all analyses before cleanup (older analyses are kept at a rate of 1 per week). Minimum value: `48h`. Default: `720h` (30 days) - **`JOBS_ANALYSIS_CLEANUP_CRON_PERIODICITY`**: Cron expression for the analysis cleanup job. Default: `0 4 * * 0` (every Sunday at 04:00) - **`JOBS_ANALYSIS_CLEANUP_DELETION_TIMEOUT`**: Timeout for analysis cleanup deletion operations. Default: `30m` ## Asset Sync Configuration - **`JOBS_ASSET_SYNC_ON_SERVER_STARTUP`**: If set to `true`, assets are synchronized on server startup. Default: `false` - **`JOBS_ASSET_SYNC_TIMEOUT`**: The duration after which an asset sync is considered as failed. Default: `20m` - **`JOBS_ASSET_SYNC_CRON_PERIODICITY`**: Cron expression for the asset sync job. Default: `20 1 * * *` (daily at 01:20) ## Organization - **`ORGANIZATION`**: For self-managed GitLab, leave empty to consider all groups. For SaaS GitLab, specify the path of the top-level group of your organization ## Logging - **`LOG_LEVEL`**: The logging level. Possible values: `error`, `warn`, `info`, `debug`. Default: `info` - **`LOG_FORMATTER`**: The log output format. Possible values: `json`, `text`. Default: `json` ## GitLab Integration - **`JOBS_GITLAB_URL`**: The URL of the GitLab instance (e.g., `https://gitlab.com`). **Required** - **`GITLAB_OAUTH2_CLIENT_ID`**: The client ID for GitLab OAuth2. **Required** - **`GITLAB_OAUTH2_CLIENT_SECRET`**: The client secret for GitLab OAuth2. **Required** - **`JOBS_GITLAB_RETRY_MAX_RETRIES`**: Maximum number of retries for GitLab API requests. Default: `5` ## Security Scanning - **`JOBS_ISSUES_CLEANUP_DATE`**: Date after which to cleanup Plumber issues (RFC3339 format). Default: `2025-10-01T00:00:00Z` ## PostgreSQL Database - **`JOBS_DB_HOST`**: The host address of the PostgreSQL database. **Required** - **`JOBS_DB_PORT`**: The port of the PostgreSQL database. **Required** - **`JOBS_DB_USER`**: The username for database authentication. **Required** - **`JOBS_DB_NAME`**: The name of the PostgreSQL database. **Required** - **`JOBS_DB_PASSWORD`**: The password for database authentication. **Required** - **`JOBS_DB_SSLMODE`**: The SSL mode for database connections (e.g., `disable`, `require`). **Required** - **`JOBS_DB_TIMEZONE`**: The timezone for database operations (e.g., `UTC`). **Required** - **`JOBS_DB_QUERY_TIMEOUT`**: Default timeout for all database operations. Default: `30s` - **`JOBS_DB_ROLLUP_QUERY_TIMEOUT`**: Extended timeout for compliance rollup operations (backfill and daily rollup). Default: `10m` ## Redis Database - **`JOBS_REDIS_HOST`**: The host address of the Redis database. **Required** - **`JOBS_REDIS_PORT`**: The port of the Redis database. **Required** - **`JOBS_REDIS_DB`**: The database index for Redis (e.g., `0`). **Required** - **`JOBS_REDIS_USER`**: The username for Redis authentication. Optional - **`JOBS_REDIS_PASSWORD`**: The password for Redis authentication. Optional - **`JOBS_REDIS_CERT`**: The certificate path for Redis TLS connections. Optional - **`JOBS_REDIS_SET_NAMESPACES_TTL`**: The TTL for cached user namespaces (e.g., `60s`). **Required** - **`JOBS_REDIS_LIST_TASK_ANALYSIS_TTL`**: TTL for analysis task lists. Default: `2h` - **`JOBS_REDIS_LIST_TASK_FIX_TTL`**: TTL for fix task lists. Default: `1h` ## Frontend Configuration - **`DEBUG`**: Enables debug mode for the frontend when set to `true`. Default: `false` - **`ALLOW_EXTERNAL_QUERIES`**: When set to `true`, allows the frontend to perform external queries. Set to `false` to prevent Plumber from initiating queries to sources other than backend and GitLab. Default: `true` ## Advanced ### Timeouts - **`JOBS_HTTP_CLIENT_TIMEOUT`**: Timeout for HTTP clients (REST and GraphQL). Default: `30s` - **`JOBS_DELETION_TIMEOUT`**: Timeout for deletion operations (frameworks, requirements, controls). Default: `10m` - **`JOBS_WORKER_REDIS_BLOCKING_TIMEOUT`**: Maximum time to block on Redis operations before refreshing connection. Default: `30m` ### GitLab GraphQL Rate Limiting These variables limit the number of concurrent calls across all workers for specific GitLab GraphQL query types. Set to `0` for unlimited (no rate limiting). - **`JOBS_PARALLEL_FETCH_MERGED_CI_CONF`**: Maximum number of concurrent `FetchGitlabMergedCIConf` calls across all workers. Default: `3` - **`JOBS_PARALLEL_GET_SECURITY_POLICY`**: Maximum number of concurrent `GetSecurityPolicyProject` calls across all workers. Default: `3` - **`JOBS_PARALLEL_GET_PROJECT_VARIABLES`**: Maximum number of concurrent `GetGitlabProjectVariables` calls across all workers. Default: `3` - **`JOBS_PARALLEL_GET_REPO_FILE_LIST`**: Maximum number of concurrent `FetchGitlabRepoFileList` calls across all workers. Default: `3` - **`JOBS_PARALLEL_FETCH_BRANCH_DATA`**: Maximum number of concurrent `FetchProjectBranchData` calls across all workers. Default: `3` - **`JOBS_PARALLEL_GET_INHERITED_VARIABLES`**: Maximum number of concurrent `GetGitlabProjectInheritedVariables` calls across all workers. Default: `3` - **`JOBS_PARALLEL_GET_INSTANCE_VARIABLES`**: Maximum number of concurrent `GetGitlabInstanceVariables` calls across all workers. Default: `3` - **`JOBS_PARALLEL_GET_CI_COMPONENT_RESOURCES`**: Maximum number of concurrent `GetGitlabCIComponentResources` calls across all workers. Default: `3` ### Sliding Window Rate Limiting These variables enforce "X requests per Y seconds" limits using Redis sorted sets. Requests automatically expire from the window after the duration. - **`JOBS_SLIDING_WINDOW_MAX_PROJECT_MEMBERS`**: Maximum requests per window for project members API (`0` = unlimited). Default: `45` - **`JOBS_SLIDING_WINDOW_MAX_GROUP_MEMBERS`**: Maximum requests per window for group members API (`0` = unlimited). Default: `45` - **`JOBS_SLIDING_WINDOW_DURATION`**: Sliding window duration for rate limiting. Default: `65s` ### Rate Limit Slot TTL - **`JOBS_RATE_LIMIT_SLOT_TTL`**: TTL for rate limit slots (auto-cleanup if worker crashes). Default: `10m` (computed automatically) This value can be overridden via environment variable. The default is automatically computed from the following variables: - `JOBS_HTTP_CLIENT_TIMEOUT`: Timeout for HTTP clients (REST and GraphQL). Default: `30s` - `JOBS_GITLAB_RETRY_MAX_RETRIES`: Maximum number of retries for GitLab API requests. Default: `5` - `GitlabRetryInitialBackoff`: Initial backoff time for GitLab API retries. Hardcoded: `10s` - `GitlabRetryMaxBackoff`: Maximum backoff time for GitLab API retries. Hardcoded: `180s` - `GitlabRetryBackoffFactor`: Backoff multiplication factor for exponential backoff. Hardcoded: `2.5` ### GitLab Merged CI Cache These variables control the caching behavior for merged GitLab CI configurations with spread to prevent cache stampede (all caches expiring at the same time). - **`JOBS_GITLAB_MERGED_CI_CACHE_TTL`**: Base TTL for merged CI config cache. Default: `2160h` (90 days) - **`JOBS_GITLAB_MERGED_CI_CACHE_TTL_SPREAD_MIN`**: Minimum additional spread added to base TTL to prevent cache stampede. Default: `168h` (1 week) - **`JOBS_GITLAB_MERGED_CI_CACHE_TTL_SPREAD_MAX`**: Maximum additional spread added to base TTL to prevent cache stampede. Default: `672h` (4 weeks) The actual cache TTL will be: base TTL + random value between `spread_min` and `spread_max`. --- # Troubleshooting Source: https://getplumber.io/docs/installation/troubleshooting Common installation errors and how to solve them. The installation is not working as expected ? You're at the right place ! Here are the common errors and how to solve them ## General Issues related to all installation methods - **`Redirect URI Invalid` error in GitLab** This error occurs when the Redirect URL set for your GitLab application doesn't correspond to the `API_URL`. Please, ensure you write the correct URL as described in the [section OIDC](docker-compose/#-gitlab-oidc). ## Kubernetes Issues related to installation on Kubernetes using the Helm chart - **No persistent volumes for chart dependencies PostgreSQL and/or Redis** These 3 dependencies requires Persistent Volumes to work. If you haven't a default storage class or if you want to use a specific storage class you need to add an option in all of theses values: ```yaml postgresql: [...] global: storageClass: REPLACE_ME_BY_STORAGE_CLASS [...] redis: [...] global: storageClass: REPLACE_ME_BY_STORAGE_CLASS ``` ## Support **Don't find what you're looking for ?** Reach our support using the `#support` channel on [Discord](/discord) You can also send an email to [help@plumber.helpscoutapp.com](mailto:help@plumber.helpscoutapp.com) --- # Controls Source: https://getplumber.io/docs/cli/controls Browse Plumber's catalog of CI/CD security controls for GitLab pipelines and GitHub Actions workflows. Each control raises a documented issue with remediation guidance when violated. Plumber checks your CI/CD configuration and project settings against a catalog of controls. The catalog is split per provider: GitLab controls inspect `.gitlab-ci.yml` and GitLab project settings, GitHub controls inspect `.github/workflows/*.yml` and GitHub repository settings. Pick the provider tab below to see the relevant catalog. When a control is not respected, an issue is created. Click the `ISSUE-XXX` link in any row to see the full description, impact, and remediation for that issue.

Scope

  • All: Plumber Platform and Open Source CLI
  • Platform: Plumber Platform only
  • CLI: Open Source CLI only (not a Platform control)
--- # Issues Source: https://getplumber.io/docs/cli/issues Complete reference of all CI/CD security issues Plumber detects (ISSUE-XXXX), with severity, category, and step-by-step remediation guides for GitLab and GitHub. ## Issues list When a control detects a violation in your project, Plumber creates an **Issue**. Each issue has a unique identifier following the format `ISSUE-XXXX` and is grouped by control. **All** means the control applies to Plumber Platform and the Open Source CLI. **Platform** means Plumber Platform only (the Open Source CLI does not report this issue). **CLI** means the Open Source CLI reports this issue only (it is not enforced as a Platform control). See [Controls](/docs/use-plumber/controls) for the full table. Click any issue to see the full description, impact, before/after configuration examples, and remediation steps.

Severity

Impact if the issue is present and exploited, not likelihood. Plumber detects; you assess.

  • πŸ”΄ Critical β€” If exploited, immediate severe consequences: pipeline takeover, secrets leak, or supply chain compromise. Address as top priority.
  • 🟠 High β€” Significantly weakens defenses. If exploited or triggered by human error, can lead to a serious incident or a major policy violation.
  • 🟑 Medium β€” Degrades security hygiene. Does not directly expose the pipeline or repo, but creates conditions that may contribute to a future incident or error.
  • πŸ”΅ Low β€” No short-term security impact; deviation from best practices. Address in continuous improvement.

Fix duration

Rough effort to remediate. Your environment and process may differ.

  • πŸ”΄ Extended: More than 2 days to fix.
  • 🟠 Long: 1 to 2 days to fix.
  • 🟑 Medium: 1 to 4 hours to fix.
  • πŸ”΅ Quick: Less than 1 hour to fix.
## Issues status An issue status can be: - **Detected:** The default state for a newly discovered issue. - **In progress:** A user started to work on fixing this issue. - **Dismissed:** A user has evaluated this issue and dismissed it. Dismissed issues are ignored if detected in subsequent analyses. - **Fixed:** The issue has been fixed or is no longer detected. If a fixed issue is reintroduced and detected again, its status is set back to **Detected**. An issue typically goes through the following lifecycle:
Diagram of the Issue status lifecycle on Plumber
--- # CI/CD templates Source: https://getplumber.io/docs/use-plumber/register-templates Learn how to register and manage CI/CD templates in Plumber. ## πŸ”Ž Guides ### πŸ’Ύ Create a catalog 1. Create a GitLab repository 2. In this repository: 1. Create [R2 file](#%EF%B8%8F-template-r2-file) 2. Create [CI/CD configuration](#%EF%B8%8F-template-cicd-conf) 3. _(optional)_ Create [documentation file](#-template-documentation) 4. _(optional)_ Setup [versioning](#-template-versioning) 5. _(optional)_ Create [changelog file](#-template-changelog) 3. Login on Plumber 4. In `CI/CD Catalog > Import R2 templates` page: import your repository ### πŸ”„ Update catalog resources 1. Update your template(s) in the GitLab repository 2. Create new version tag 3. Login on Plumber 4. In "Import templates" page: click on refresh button ### ❌ Delete catalog resources 1. Login on Plumber 2. In "Import templates" page: click on delete button ## βš™οΈ Template R2 file Each template on Plumber must have its own R2 file. It defines template metadata. - It can be located anywhere in a repository - It uses the YAML format - File extension have to be `.r2.yml` - The file name is the template name | Keyword | Description | Default | | --------------------- | ---------------------------------------------------------------------------------------------- | ------- | | `files:template` | (Mandatory) Relative path(\*) to the template CI/CD configuration | ` ` | | `files:documentation` | Relative path(\*) to the documentation file | ` ` | | `files:changelog` | Relative path(\*) to the changelog file | ` ` | | `data:description` | The template description | ` ` | | `data:icon` | Icon of the template (see [emoji list](https://unicode.org/emoji/charts/full-emoji-list.html)) | `πŸ“‹` | | `data:labels` | List of template labels (see list of available labels below) | `[]` | | `data:license` | The template license | ` ` | | `data:deprecated` | Set the template as deprecated | `false` | ## πŸ› οΈ Template CI/CD conf This file, usually named with the template name with `.yml` extension contains GitLab CI/CD configuration in `yaml` format. It can contains any piece of configuration, from one keyword to a full pipeline. More info: - [GitLab CI/CD pipeline configuration reference](https://docs.gitlab.com/ee/ci/yaml/) ## πŸ“š Template documentation This file, usually named `README.md`, contains the documentation of a template in `markdown` format. It should explains what the template does, how to use it and to customize it. A clear documentation is important: no one wants to use a template without it. ## πŸ“„ Template versioning Template versioning rely on source repository git tags. 2 options are supported in Plumber: 1. Tags scoped to all templates of the repository, without prefix. Ex: `1.2.3` 1. Tags scoped to only one template, prefixed with its path. Ex: `docker_build@1.2.3` In order to **create automatically versioning** tags for your templates, you have to create a [changelog file](#-template-changelog) for each templates and add the template [template_release](https://r2devops.io/catalog/gitlab/r2devops/hub/template_release) in your repository CI/CD configuration. ## πŸ— Template changelog This file, usually named `CHANGELOG.md`, contain the changelog of a template following the [keep a changelog](https://keepachangelog.com/en/1.1.0/) structure and using `markdown` syntax. --- # GitLab Integration & Permissions Source: https://getplumber.io/docs/use-plumber/roles-permissions Learn about roles and permissions in Plumber. ## πŸ‘₯ Roles ### Admin - Admins have the highest level of access on the platform. They can manage authorized maintainers, configure policies, and have full control over settings. - **Who is Admin**: - If your **Plumber instance** is connected to an entire **GitLab self-managed instance**: any GitLab instance Admin. - If your **Plumber instance** is connected to a **GitLab group**: any user at least Maintainer in the root group. ### Maintainer - Maintainers can manage projects, configure settings and policies, and run analysis if an organization token is set. They have significant control but cannot manage authorized maintainers. - **Who is Maintainer**: any user (starting from `Guest` level) from a group manually added in the `Settings > Authorization` page. ### Member - Members can view filtered analyses based on their rights on GitLab projects. - **Who is Member**: - If your **Plumber instance** is connected to an entire **GitLab self-managed instance**: any user logged into the GitLab instance. - If your **Plumber instance** is connected to a **GitLab group**: any user between Guest and Developer (included) in the root group. ### No one - This role has no permissions and cannot perform any actions on the platform. - **Who is No one**: any user not in the previously described roles. ## πŸ”’ Permissions | Permission | Admin | Maintainer | Member | No one | |---------------------------------------|-------|------------|--------|--------| | Read policies compliance | βœ… | βœ… | βœ… | ❌ | | Read projects compliance | βœ… | βœ… | 🟑 | ❌ | | Read issues | βœ… | βœ… | 🟑 | ❌ | | Read inventory | βœ… | βœ… | 🟑 | ❌ | | Read settings | βœ… | βœ… | 🟑 | ❌ | | Edit policies | βœ… | βœ… | ❌ | ❌ | | Edit issues | βœ… | βœ… | ❌ | ❌ | | Edit settings | βœ… | βœ… | ❌ | ❌ | | Run new analysis | βœ… | βœ… | ❌ | ❌ | | Edit authorized maintainers settings | βœ… | ❌ | ❌ | ❌ | 🟑 : access filtered on projects and groups that users can read on GitLab ## πŸ”‘ GitLab Token Configuration Plumber uses GitLab tokens to communicate with your GitLab instance: - To run analysis, it uses the **Access Token** added in `Settings > Organization > Analysis Token` - To run all other queries, it uses the **OIDC Token** of the currently logged-in user To create the **Access Token**: - The token should be a Personal Access Token, ideally linked to a Service Account (not a human) - If your **Plumber instance** is connected to an entire **GitLab self-managed instance** - If [Admin Mode](https://docs.gitlab.com/administration/settings/sign_in_restrictions/#admin-mode) is NOT enabled on your instance - Option 1: the token owner must be **Admin** and the token scope must be **`api`** - Option 2: the token owner must be **Maintainer** of all root groups and the token scope must be **`api`** - If [Admin Mode](https://docs.gitlab.com/administration/settings/sign_in_restrictions/#admin-mode) is enabled on your instance - Option 1: the token owner must be **Admin** and the token scope must be **`api`** and **`admin_mode`** - Option 2: the token owner must be **Maintainer** of all root groups and the token scope must be **`api`** - If your **Plumber instance** is connected to a **GitLab group** - The token owner must be **Maintainer** of the root group and the token scope must be **`api`** --- # Open Source CLI Source: https://getplumber.io/docs/cli Plumber is an open-source CLI that audits GitLab CI/CD pipelines and GitHub Actions workflows for security. One policy file, shared controls, letter-grade reports. Plumber is an open-source CLI that scans your **GitLab CI/CD pipelines** and **GitHub Actions workflows** for security problems: - Untrusted dependencies and unverified scripts - Leaked secrets - Missing branch protection - [More…](/docs/use-plumber/controls) It turns them into a [Plumber Score](/docs/plumber-score) from A to E that can block your pipeline below a minimum score you set. It runs with zero configuration out of the box; to tune the policy, write one `.plumber.yaml` that [extends Plumber's baseline](/docs/cli/reference#configuration) with just what you change, and scan both providers with it. Pick your platform: Both providers share one command set and output format. The full **[CLI Reference](/docs/cli/reference)** documents the three [configuration modes](/docs/cli/reference#configuration) (no config, extended, full), every `analyze` flag, the config commands, exit codes, and the JSON / PBOM / CycloneDX output. --- # GitHub Source: https://getplumber.io/docs/cli/github Scan GitHub Actions workflows for security with the open-source Plumber CLI and GitHub Action: authentication, GitHub controls catalog, issues, SARIF and Code Scanning. Plumber scans your **GitHub Actions** workflows and repository configuration for security problems: - Unpinned actions - Untrusted input in shell scripts - Missing branch protection - [More…](/docs/use-plumber/controls?p=github) It turns them into a [Plumber Score](/docs/plumber-score) from A to E that can block CI below a minimum score you set. ## Quick Start Two ways to scan a GitHub repository: ## Run locally Best for trying Plumber on one repo, checks before you push, security-team audits, or scanning upstream repos without changing CI. 1. **Install Plumber**: Homebrew, mise, a prebuilt binary, Docker, or from source (see [Installation](/docs/cli/installation)). 2. **Authenticate**: run `gh auth login`, or set a `GH_TOKEN` with read access to the repo (see [Authentication](#authentication)). 3. **Run the scan** from inside your checked-out repo, or against a remote one (see [Running a scan](#running-a-scan)): ```bash plumber analyze ``` 4. **Read your Plumber score**: an A–E grade with a per-control breakdown, plus an optional JSON report, PBOM, and CycloneDX SBOM (see [Example Output](#example-output)). ## Run with GitHub Actions 1. **Add the action to your workflow** (e.g. `.github/workflows/plumber.yml`): ```yaml name: Plumber on: push: branches: [main] pull_request: null permissions: contents: read security-events: write # id-token: write # uncomment to publish a live Plumber Score badge (see below) jobs: plumber: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 - uses: getplumber/plumber@e81ed4965fd92e0d2b63e95d399ed0419f5de2fa # pinned plumber version: v0.4.36 with: score-push: false # set to "true" to publish a public Plumber Score badge ``` 2. **Push and check results**: findings appear in the **Code Scanning** tab, the job summary, and the downloadable artifact bundle. 3. **Optionally tune the policy with a `.plumber.yaml`** Without one, the action runs Plumber's built-in default policy, so you can stop here. To tune the policy, the recommended way is a small overlay that [extends Plumber's baseline](/docs/cli/reference#configuration) and lists only what you change: ```bash plumber config generate --overlay ``` ```yaml extends: plumber:default version: "2.0" github: controls: actionsMustBePinnedByCommitSha: trustedOwners: - myorg ``` `plumber config generate` (without `--overlay`) writes the full self-contained template instead. See the three configuration modes in the [CLI Reference](/docs/cli/reference#configuration). ### Customizing the action Override any input to fit your needs: ```yaml - uses: getplumber/plumber@e81ed4965fd92e0d2b63e95d399ed0419f5de2fa # pinned plumber version: v0.4.36 with: min-score: "B" config-file: configs/strict.plumber.yaml controls: actionsMustBePinnedByCommitSha,branchMustBeProtected score: "true" soft-fail: "true" upload-sarif: "true" upload-artifacts: "true" ``` ### All inputs | Input | Default | Description | |-------|---------|-------------| | `version` | _(pinned in action.yml)_ | Plumber release tag to install. Downloaded from GitHub Releases and verified against `checksums.txt` | | `verify-attestation` | `true` | Verify the binary's SLSA build-provenance attestation via `gh attestation verify`. Disable for air-gapped or GHES setups | | `github-token` | `${{ github.token }}` | Token for the GitHub API and SARIF upload. Needs `Administration:read` for full `branchMustBeProtected`, and `security-events:write` for SARIF | | `metadata-token` | - | Optional token (public-repo read) used only to resolve third-party action versions for the known-CVE check, when an action is hosted in an org with an IP allow list that blocks the runner's `GITHUB_TOKEN`. Falls back to an anonymous read when unset | | `project` | _(current repo)_ | `owner/repo` to scan remotely (upstream-fetch, no checkout needed) | | `github-url` | _(github.com)_ | GitHub Enterprise Server host (e.g. `ghes.example.com`) | | `min-score` | - | Minimum [Plumber Score](/docs/plumber-score) letter to pass (A-E), e.g. `B` fails on C, D, E. The recommended way to gate CI on the score | | `min-points` | `100` | Fine-grained gate on score points (0-100). The default (100) fails on any finding; not applied when only `min-score` is set | | `threshold` | - | **Deprecated.** Minimum percentage of passing controls (0-100). Use `min-score` / `min-points` instead | | `config-file` | _(auto-detect)_ | Path to a `.plumber.yaml`. Default: the repo's `.plumber.yaml` if present, otherwise the [built-in default config](/docs/cli/reference#no-configuration) (zero-config runs work out of the box) | | `controls` | - | Run only these controls (comma-separated). Mutually exclusive with `skip-controls` | | `skip-controls` | - | Skip these controls (comma-separated). Mutually exclusive with `controls` | | `score` | `true` | Include the full points breakdown. The Plumber score is shown by default; set `false` for the banner without the per-issue-code breakdown | | `score-push` | `false` | Publish this repo's Plumber Score to the hosted badge service ([score.getplumber.io](https://score.getplumber.io)) via CI-native OIDC (**no secret**). The workflow MUST grant `permissions: id-token: write`. Publishes on every run; the service keeps only the default branch for the public badge. Warns (never fails) on error. See [Plumber Score](/docs/plumber-score) | | `score-endpoint` | `https://score.getplumber.io` | Score service base URL. Override **only** for a self-hosted score service; the OIDC audience follows this value so it always matches the target | | `fail-warnings` | `false` | Fail on warnings: unknown configuration keys (exit 2) and "could not verify" checks such as a skipped known-CVE lookup (exit 3). `soft-fail` does not mask exit 3 | | `soft-fail` | `false` | Do not fail the job when the score is below the gate. Findings are still produced and uploaded | | `upload-sarif` | `true` | Upload the SARIF report to GitHub Code Scanning | | `upload-artifacts` | `true` | Upload JSON report, PBOM, and CycloneDX SBOM as a workflow artifact | | `artifact-name` | `plumber-report` | Name of the uploaded artifact bundle | | `output` | `plumber-report.json` | JSON report output path. Empty to skip | | `pbom` | `plumber-pbom.json` | PBOM output path. Empty to skip | | `pbom-cyclonedx` | `plumber-cyclonedx-sbom.json` | CycloneDX SBOM output path. Empty to skip | | `sarif` | `plumber.sarif` | SARIF 2.1.0 output path. Empty to skip | ### Outputs | Output | Description | |--------|-------------| | `passed` | `true` when the run met its gate (`min-score` / `min-points`, or the deprecated `threshold`) | | `report` | Path to the JSON report | | `sarif` | Path to the SARIF report | Use outputs in downstream steps: ```yaml - uses: getplumber/plumber@e81ed4965fd92e0d2b63e95d399ed0419f5de2fa # pinned plumber version: v0.4.36 id: plumber - run: echo "Plumber check passed: ${{ steps.plumber.outputs.passed }}" ``` ### Code Scanning integration When `upload-sarif` is `true` (the default), the action uploads a SARIF 2.1.0 report to [GitHub Code Scanning](https://docs.github.com/en/code-security/code-scanning). Each Plumber finding becomes an alert in the **Security** tab with: - The issue code as the rule ID (e.g. `ISSUE-701`) - Severity mapped to SARIF level (`error`, `warning`, `note`) and a numeric `security-severity` for triage - File location pointing at the workflow YAML line - A `helpUri` linking to the issue's documentation page on getplumber.io ### GitHub Enterprise Server For GHES, pass the host via `github-url`: ```yaml - uses: getplumber/plumber@e81ed4965fd92e0d2b63e95d399ed0419f5de2fa # pinned plumber version: v0.4.36 with: github-url: ghes.example.com verify-attestation: "false" ``` Set `verify-attestation: "false"` if the runner cannot reach the sigstore transparency log or your GHES instance does not support `gh attestation verify`. ### Action examples **Scan a remote repo without cloning:** ```yaml - uses: getplumber/plumber@e81ed4965fd92e0d2b63e95d399ed0419f5de2fa # pinned plumber version: v0.4.36 with: project: my-org/other-repo github-token: ${{ secrets.PLUMBER_PAT }} ``` **Run only SHA-pinning and permissions checks:** ```yaml - uses: getplumber/plumber@e81ed4965fd92e0d2b63e95d399ed0419f5de2fa # pinned plumber version: v0.4.36 with: controls: actionsMustBePinnedByCommitSha,workflowsMustDeclarePermissions ``` **Soft-fail in PRs, hard-fail on main:** ```yaml - uses: getplumber/plumber@e81ed4965fd92e0d2b63e95d399ed0419f5de2fa # pinned plumber version: v0.4.36 with: soft-fail: ${{ github.event_name == 'pull_request' && 'true' || 'false' }} ``` ## Authentication Pick whichever auth flow fits your environment: ```bash # Option 1: GitHub CLI (recommended for local use) gh auth login # Option 2: Fine-grained Personal Access Token # Settings > Developer settings > Personal access tokens > Fine-grained tokens # Repository access: pick the repo(s) to scan # Permissions: Contents = Read, Metadata = Read, Administration = Read export GH_TOKEN=github_pat_xxxx # Option 3: Classic PAT (broader scope, still works) # Permissions: `repo` scope (read access to repo + admin metadata) export GH_TOKEN=ghp_xxxx ``` If a workflow uses an action hosted in an org with an [IP allow list](https://docs.github.com/en/enterprise-cloud@latest/organizations/keeping-your-organization-secure/managing-allowed-ip-addresses-for-your-organization), a runner's `GITHUB_TOKEN` is blocked when Plumber resolves that action's version for the known-CVE check; Plumber falls back to an anonymous read, and skips the check rather than guessing if that is rate-limited too. To resolve those versions reliably, set `PLUMBER_METADATA_TOKEN` (or the action's `metadata-token` input) to a token with public-repository read. When a token-scoped control cannot fully evaluate, Plumber adds a `partialControls` entry to `report.json` so CI gates can tell the difference between "100% compliant" and "100% on what we could see": ```json "partialControls": [ { "control": "branchMustBeProtected", "reason": "Token lacks Administration:Read scope; force-push and code-owner-approval rules (ISSUE-505) not evaluated.", "affectedBranches": 1, "remediation": "Re-run with a token carrying Administration:Read (fine-grained PAT) or `repo` scope (classic PAT)." } ] ``` When this array is non-empty, at least one control abstained on at least part of its input. Treat the run as suspect rather than trusting the reported percentage. On a clean run the array is either omitted or empty. ### Running a scan ```bash # Local clone (auto-detected from git remote) plumber analyze # Upstream-fetch: scan a repo without cloning it plumber analyze --github-url github.com --project myorg/myrepo ``` On GitHub Enterprise Server, pass the GHES host via `--github-url ghes.example.com`. Plumber auto-detects `github.com` from your git remote. ## Examples ### Selective Control Execution You can run or skip specific controls using their YAML key names from `.plumber.yaml`. This is useful for iterative debugging or targeted CI checks. ```bash # Only check SHA pinning and declared permissions plumber analyze --controls actionsMustBePinnedByCommitSha,workflowsMustDeclarePermissions # Run everything except the advisory-database check plumber analyze --skip-controls actionsMustNotCarryKnownCVEs ``` Controls not selected are reported as **skipped** in the output. The `--controls` and `--skip-controls` flags are mutually exclusive. ### Silent Mode (JSON Only) ```bash plumber analyze \ --github-url github.com \ --project myorg/myrepo \ --config .plumber.yaml \ --output results.json \ --print false ``` ### Example Output The CLI output is color-coded in your terminal for easy scanning: green for passing controls, red for failures. ![Plumber CLI output showing analysis results](../img/cli-gh-output.png) ### Example Configuration The `github.controls:` section of a schema v2 `.plumber.yaml`. With `extends: plumber:default` on top, this file is an [overlay](/docs/cli/reference#configuration): every control keeps its baseline setting and you list only what you change. Without `extends`, the file is the complete policy and a control left out does not run. ```yaml extends: plumber:default version: "2.0" github: controls: # Third-party action references must be pinned by 40-character commit SHA. # trustedOwners is the exemption list; first-party `actions/*` and # `github/*` are exempt by default. actionsMustBePinnedByCommitSha: enabled: true trustedOwners: - actions - github # uses: owner/repo@ref pointing at an archived GitHub repository. actionsMustNotBeArchived: enabled: true # uses: owner/repo@ pinned to a commit the upstream repo confirms # does not exist (a typo or impostor commit). Abstains on any SHA it # cannot verify (private repo, rate limit, network). actionRefsMustExistUpstream: enabled: true # Cross-references every pinned action against the GitHub Advisory # Database under the `actions` ecosystem. actionsMustNotCarryKnownCVEs: enabled: true # Reads each third-party action's OWN source: flags an action that # fetches a script from a moving ref (a branch, not a tag/SHA) and runs # it with no checksum β€” a SHA pin on the action cannot reach that code. # anchore/scan-action β†’ grype's install.sh from `main` is the canonical # case. Obfuscated decode-then-run is graded critical; an unfetchable # source is surfaced as could-not-verify, never a silent pass. actionsMustNotExecuteMutableRemoteCode: enabled: true # Release/publish job that restores a build cache whose key is not # scoped to the release ref. Actions caches are shared across branches, # so a PR-populated cache (key or restore-keys prefix) can be injected # into the published output. Weave github.ref_name / github.sha into the # key, or disable caching on publish paths. # # Fully data-driven: the action/script inventory AND the per-action # cache semantics live here, not in code. releaseWorkflowsMustNotRestoreUntrustedCache: enabled: true # Actions that mark a job as a release (so its cache restore matters). publishActions: - JS-DevTools/npm-publish - pypa/gh-action-pypi-publish # - my-org/release-action # Publish commands in run: scripts that also mark release intent. publishScriptPatterns: - '(?i)(npm|pnpm|yarn)\s+publish' - '(?i)cargo\s+publish' # Which actions restore a cache, and when. mode: # always β€” restores whenever present # default β€” restores unless disableInput holds disableValue # opt-in β€” restores only when enableInput names a package manager cacheActions: - {action: actions/cache, mode: always} - {action: Swatinem/rust-cache, mode: always} - {action: actions/setup-go, mode: default, disableInput: cache, disableValue: false} - {action: gradle/actions/setup-gradle, mode: default, disableInput: cache-disabled, disableValue: true} - {action: actions/setup-node, mode: opt-in, enableInput: cache} # Jobs to exempt (glob over /) β€” the escape # hatch for release jobs you have reviewed and accept. allowedJobs: [] # - '*/lint' # Restrict step and reusable-workflow `uses:` to authorized sources. # Trust = official owners (actions/*, github/*), your own org # (trustSameOrgActions), the allowlist below (exact owner/repo or # owner/* wildcard), or a minimum-stars floor. githubActionMustComeFromAuthorizedSources: enabled: true trustGithubOfficialActions: true trustSameOrgActions: true minimumStars: 0 trustedGithubActions: - jdx/mise-action # - mycompany/* # uses: owner/repo@ref where the same name exists upstream as BOTH a # tag and a branch (ref-confusion). Pin by commit SHA to disambiguate. externalRefsMustNotCollide: enabled: true # Same forbidden-tag list as GitLab plus a digest-pinning sub-option. containerImageMustNotUseForbiddenTags: enabled: true tags: - latest - dev - development - staging - main - master containerImagesMustBePinnedByDigest: true # Truthy ACTIONS_STEP_DEBUG / ACTIONS_RUNNER_DEBUG in any merged env # block, expression binding, or runtime $GITHUB_ENV write. pipelineMustNotEnableDebugTrace: enabled: true forbiddenVariables: - ACTIONS_STEP_DEBUG - ACTIONS_RUNNER_DEBUG # Docker-in-Docker services + insecure daemon configuration # (DOCKER_TLS_CERTDIR="" or DOCKER_HOST tcp://...:2375). pipelineMustNotUseDockerInDocker: enabled: true detectInsecureDaemon: true # `jobs..secrets: inherit` hands every secret visible to the # caller (repo, organisation, environment) to the reusable workflow. # Declare each secret explicitly instead. reusableWorkflowsMustNotInheritSecrets: enabled: true # `toJson(secrets)` / `toJSON(secrets)` piped into a run script, env # binding, or action `with:` input. The JSON blob carries every secret # the job can see; log redaction does not cover it. Name secrets instead. workflowMustNotExportEntireSecretsContext: enabled: true # On GitHub a job's name is `/` # (e.g. `codeql-analysis/analyze`). Patterns are globs over that name. securityJobsMustNotBeWeakened: enabled: true securityJobPatterns: - "*codeql*" - "*dependency-review*" - "*trufflehog*" - "*gitleaks*" - "*osv-scanner*" - "*-sast" - "*-sast-*" - "*-scan" - "*scan*" - "*-security" - "*-security-*" - "*-audit" - "*-audit-*" allowFailureMustBeFalse: enabled: true rulesMustNotBeRedefined: enabled: true whenMustNotBeManual: enabled: true # curl | bash, wget | sh, download-then-execute, base64 pipe-to-shell. pipelineMustNotExecuteUnverifiedScripts: enabled: true trustedUrls: [] # - https://internal-artifacts.example.com/* # Attacker-controlled free text (`${{ github.event.* }}` titles, bodies, # branch names, commit messages, or `${{ github.head_ref }}`) # interpolated directly into a `run:` shell. Bind through env: first. workflowMustNotInjectUserInputInScripts: enabled: true # `${{ github.event.* }}` / `${{ github.head_ref }}` written into # $GITHUB_ENV or $GITHUB_PATH is sticky and hijacks every later step. # env: binding stops ISSUE-207 but not this. Base64-encode the value # itself (or use toJSON) so a newline can't open a second variable. workflowMustNotWriteUntrustedContentToGitHubEnv: enabled: true # Secret-bearing triggers an unprivileged caller can fire # (workflow_run, issue_comment, reviews, discussions, gollum, fork) # plus a fork-controlled checkout and no same-repo or trusted-author # guard: a direct exfiltration path (CVE-2025-30066). The # pull_request_target case is owned by ISSUE-804. workflowMustNotUseDangerousTriggers: enabled: true # pull_request_target plus an explicit checkout of the PR head runs # fork code with base-repo secrets (the tj-actions / CVE-2025-30066 # vector). A same-repository job guard is exempt. pullRequestTargetMustNotCheckoutHead: enabled: true # Jobs with no `permissions:` block at either the job or workflow # level fall back to the repo-wide GITHUB_TOKEN default. Declare # `permissions: { contents: read }` at the workflow level for least # privilege. workflowsMustDeclarePermissions: enabled: true # `permissions: write-all` at workflow or job scope. workflowMustNotGrantPermissionsWriteAll: enabled: true # Opt-in. Assert every workflow includes the action(s) your org requires. workflowMustIncludeRequiredActions: enabled: false # requiredGroups: # - ["actions/attest-build-provenance"] # - ["your-org/license-scan", "your-org/sbom"] # Reads both classic Branch Protection and Repository / Organization # Rulesets, unions them, stricter wins. branchMustBeProtected: enabled: true defaultMustBeProtected: true namePatterns: - main - master - release/* - production - dev allowForcePush: false codeOwnerApprovalRequired: true ``` See the [full configuration reference](https://github.com/getplumber/plumber/blob/main/defaultConfig/.plumber.yaml) for every option, the [configuration modes](/docs/cli/reference#configuration) (no config, extended, full) and provider-agnostic commands and output formats in the [CLI Reference](/docs/cli/reference), and the [Installation](/docs/cli/installation) page. ## Reference The complete, always-current catalogs and command reference: ## Troubleshooting | Issue | Solution | |-------|----------| | `no GitHub token found` (upstream-fetch mode) | Run `gh auth login`, or set `GH_TOKEN` / `GITHUB_TOKEN`. Upstream-fetch refuses to start without a token because the anonymous tier is rate-limited | | `401 Unauthorized` | Token is invalid or expired. Fine-grained PAT needs `Contents: Read` + `Metadata: Read`; classic PAT needs `repo` | | `branchMustBeProtected` shows in `partialControls` | Token lacks `Administration: Read` (fine-grained) or `repo` (classic). The rule abstains rather than claim a false pass | | `403` / rate-limit errors | Anonymous or under-scoped token. Authenticate with a PAT or `gh auth login` | | ISSUE-703 fires in CI but not locally, or "could not verify" an action's version | The action's org enforces an IP allow list that blocks the runner's `GITHUB_TOKEN`. Set `metadata-token` (action) or `PLUMBER_METADATA_TOKEN` (CLI) to a public-repo-read token | | `404 Not Found` | Verify `--project owner/repo` and, for GHES, that `--github-url` points at the right host | | Branch protection rule not detected | Plumber reads classic Branch Protection AND Rulesets; confirm the rule is enabled (not in evaluate mode) on a branch matching your `namePatterns` | | Configuration file not found | Ensure `--config` points at the real file (use an absolute path in Docker). Create one with `plumber config generate` or `plumber config init` | | SARIF upload fails with 403 | The job needs `security-events: write` permission. Add it under the workflow or job-level `permissions:` block | | Attestation verification fails | The runner cannot reach sigstore or `gh` CLI is not installed. Set `verify-attestation: "false"` to skip | | Score badge not published | `score-push` needs `permissions: id-token: write` in the workflow. The **public badge** only reflects your default branch; pull request runs can't publish (their OIDC token has no branch ref), so the badge updates when the PR merges. A local run never publishes. The push warns, never fails, when the token or permission is missing. See [Plumber Score](/docs/plumber-score) | --- # GitLab Source: https://getplumber.io/docs/cli/gitlab Scan GitLab CI/CD pipelines for security with the open-source Plumber CLI: authentication, GitLab controls catalog, issues, configuration, and MR/badge integration. Plumber scans your **GitLab CI/CD pipelines** and repository configuration for security problems: - Unverified remote scripts - Weakened security jobs - Missing branch protection - [More…](/docs/use-plumber/controls?p=gitlab) It turns them into a [Plumber Score](/docs/plumber-score) from A to E that can block pipelines below a minimum score you set. ## Quick Start Two ways to scan a GitLab project: ## Run locally 1. **Install Plumber**: Homebrew, mise, a prebuilt binary, Docker, or from source (see [Installation](/docs/cli/installation)). 2. **Authenticate**: create a `GITLAB_TOKEN` with `read_api` + `read_repository` scopes (see [Authentication](#authentication)). 3. **Run the scan** from inside your project, or against a remote one (see [Running a scan](#running-a-scan)): ```bash plumber analyze ``` 4. **Read your Plumber score**: an A–E grade with a per-control breakdown, plus an optional JSON report, PBOM, and CycloneDX SBOM (see [Example Output](#example-output)). ## Run with the GitLab CI component 1. **Create a GitLab token** Use a token with `read_api` + `read_repository` scopes (or `api` if you enable `mr_comment` / `badge`), as described under [Authentication](#authentication) below. 2. **Add the token to your project** Go to **Settings β†’ CI/CD β†’ Variables** and add it as `GITLAB_TOKEN` (masked recommended). 3. **Add the component to your `.gitlab-ci.yml`** ```yaml workflow: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS # prevents duplicate pipelines when: never - if: $CI_COMMIT_BRANCH - if: $CI_COMMIT_TAG include: - component: gitlab.com/getplumber/plumber/plumber@ inputs: score_push: false # if "true": publish a public Plumber Score badge ``` Get the latest version from the [CI/CD Catalog](https://gitlab.com/explore/catalog/getplumber/plumber). 4. **Run your pipeline** Plumber now runs on every pipeline (default branch, tags, and open merge requests) and reports issues. 5. **Optionally tune the policy with a `.plumber.yaml`** Without one, the component runs Plumber's built-in default policy, so you can stop here. To tune the policy, the recommended way is a small overlay that [extends Plumber's baseline](/docs/cli/reference#configuration) and lists only what you change: ```bash plumber config generate --overlay ``` ```yaml extends: plumber:default version: "2.0" gitlab: controls: containerImageMustNotUseForbiddenTags: tags: - latest - dev ``` `plumber config generate` (without `--overlay`) writes the full self-contained template instead. See the three configuration modes in the [CLI Reference](/docs/cli/reference#configuration). ### Hosting on self-hosted GitLab If you run a self-hosted GitLab instance, you need your own copy of the component since `gitlab.com` components can't be accessed from your instance. There are two ways: Direct Import (Simplest) Fork + Mirror (Recommended) Import the upstream repository directly into your GitLab instance. 1. **Import the repository** Go to **New Project β†’ Import project β†’ Repository by URL** and use `https://gitlab.com/getplumber/plumber.git`. Choose a group and project name (e.g., `infrastructure/plumber`). 2. **Enable the CI/CD Catalog** In the imported project, go to **Settings β†’ General**, ensure the project has a **description** (required for the Catalog), expand **Visibility, project features, permissions**, toggle **CI/CD Catalog resource** on, and save. 3. **Publish a release** The imported project comes with upstream tags. Run a pipeline on an existing tag to trigger the release: **CI/CD β†’ Pipelines β†’ Run pipeline**, select an imported tag (e.g., `v0.2.1`), and click **Run pipeline**. This creates a Catalog release for that tag. (Alternatively, create a tag manually under **Code β†’ Tags β†’ New tag**, though that can conflict when fetching remote tags later.) 4. **Add the token** Create a token as described under [Authentication](#authentication), then add it under the scanned project's **Settings β†’ CI/CD β†’ Variables** as `GITLAB_TOKEN` (masked recommended). 5. **Use the component** ```yaml include: - component: gitlab.example.com/infrastructure/plumber/plumber@bec6c5b303bec2b5e18d1883d5eeba6432477a0b # pinned plumber version: v0.4.36 ``` To update: re-import or manually pull upstream changes. Fork on gitlab.com, then set up a pull mirror on your self-hosted instance so it stays in sync automatically. 1. **Fork on gitlab.com** Fork [getplumber/plumber](https://gitlab.com/getplumber/plumber) under your gitlab.com namespace (e.g., `your-org/plumber`). 2. **Create a mirrored project on your instance** **New Project β†’ Import project β†’ Repository by URL** with `https://gitlab.com/your-org/plumber.git`. 3. **Set up pull mirroring** In the project, **Settings β†’ Repository β†’ Mirroring repositories**: add `https://gitlab.com/your-org/plumber.git`, direction **Pull**, plus a gitlab.com token with `read_repository` scope if the fork is private. 4. **Enable the CI/CD Catalog** **Settings β†’ General**: ensure a project description, then toggle **CI/CD Catalog resource** on and save. 5. **Publish a release** Run a pipeline on an existing imported tag (**CI/CD β†’ Pipelines β†’ Run pipeline**) to create a Catalog release. 6. **Add the token** As under [Authentication](#authentication), then add it as `GITLAB_TOKEN` under the scanned project's **Settings β†’ CI/CD β†’ Variables**. 7. **Use the component** ```yaml include: - component: gitlab.example.com/infrastructure/plumber/plumber@bec6c5b303bec2b5e18d1883d5eeba6432477a0b # pinned plumber version: v0.4.36 ``` ### Customizing the component Override any input to fit your needs: ```yaml include: - component: gitlab.com/getplumber/plumber/plumber@bec6c5b303bec2b5e18d1883d5eeba6432477a0b # pinned plumber version: v0.4.36 inputs: # Target (defaults to current project) server_url: https://gitlab.example.com # Self-hosted GitLab project_path: other-group/other-project # Analyze a different project branch: develop # Analyze a specific branch ci_config_path: $CI_CONFIG_PATH # CI config path (GitLab predefined variable) # Gate min_score: "B" # Minimum Plumber Score letter to pass (A-E) config_file: configs/my-plumber.yaml # Custom config path # Output output_file: plumber-report.json # Export JSON report pbom_file: plumber-pbom.json # PBOM artifact pbom_cyclonedx_file: plumber-cyclonedx-sbom.json # CycloneDX SBOM (auto-uploaded as a GitLab report) print_output: true # Job behavior stage: test # Run in a different stage allow_failure: true # Don't block the pipeline on failure gitlab_token: $MY_CUSTOM_TOKEN # Different variable name verbose: true # Selective execution (mutually exclusive) controls: containerImageMustNotUseForbiddenTags,branchMustBeProtected # skip_controls: branchMustBeProtected # MR feedback (require `api` scope, see GitLab Integration above) mr_comment: true badge: true ``` The `controls` / `skip_controls` inputs map to the CLI `--controls` / `--skip-controls` flags ([valid names](#selective-control-execution)). `mr_comment` and `badge` are the component equivalents of the CLI `--mr-comment` / `--badge` flags shown under [GitLab Integration](#gitlab-integration). #### All inputs | Input | Default | Description | |-------|---------|-------------| | `server_url` | `$CI_SERVER_URL` | GitLab instance URL | | `project_path` | `$CI_PROJECT_PATH` | Project to analyze | | `branch` | `$CI_COMMIT_REF_NAME` | Branch to analyze | | `ci_config_path` | `$CI_CONFIG_PATH` | CI configuration file path to analyze. Defaults to the GitLab predefined variable (`.gitlab-ci.yml` unless customized in project settings) | | `gitlab_token` | `$GITLAB_TOKEN` | GitLab API token (`read_api` + `read_repository`, or `api` if `mr_comment` / `badge` is enabled) | | `min_score` | - | Minimum [Plumber Score](/docs/plumber-score) letter to pass (A-E), e.g. `B` fails on C, D, E. The recommended way to gate CI on the score | | `min_points` | `100` | Fine-grained gate on score points (0-100). The default (100) fails on any finding; not applied when only `min_score` is set | | `threshold` | `100` | **Deprecated.** Minimum percentage of passing controls. The default (100) is treated as unset; use `min_score` / `min_points` instead | | `config_file` | *(auto-detect)* | Path to config file (relative to repo root). Auto-detects `.plumber.yaml`, falls back to default | | `output_file` | `plumber-report.json` | Path to write JSON results | | `pbom_file` | `plumber-pbom.json` | Path to write the PBOM | | `pbom_cyclonedx_file` | `plumber-cyclonedx-sbom.json` | Path to write the CycloneDX SBOM (auto-uploaded as a GitLab report) | | `print_output` | `true` | Print the human-readable analysis report in the job log. Does not affect log verbosity (`verbose`) or file outputs | | `stage` | `.pre` | Pipeline stage for the job. `.pre` runs before all other stages but requires at least one job in a regular stage. If Plumber is the only job, set this to `test` or another stage | | `image` | `getplumber/plumber:0.1` | Docker image to use | | `allow_failure` | `false` | Allow the job to fail without blocking | | `verbose` | `false` | Enable debug logging (`level=debug` lines) for troubleshooting. Independent of `print_output` | | `mr_comment` | `false` | Post/update a Plumber comment on the merge request (requires `api` scope) | | `badge` | `false` | Create/update a Plumber letter-score badge (requires `api` scope; default branch only) | | `score` | `false` | Deprecated no-op. The Plumber score is shown by default now; kept so pipelines that already set it do not break. Use `score_point` for the full points breakdown | | `score_point` | `false` | Add the full points breakdown to stdout and the MR comment (the score banner is shown by default) | | `score_push` | `false` | Publish this repo's Plumber Score to the hosted badge service ([score.getplumber.io](https://score.getplumber.io)). Uses CI-native OIDC (**no secret**); the component mints the id-token. Publishes on every run; the service keeps only the default branch for the public badge. Warns (never fails) on error. See [Plumber Score](/docs/plumber-score) | | `score_endpoint` | `https://score.getplumber.io` | Score service base URL. Override **only** for a self-hosted score service; the OIDC audience follows this value so it always matches the target | | `controls` | - | Run only listed controls (comma-separated). Cannot be used with `skip_controls` | | `skip_controls` | - | Skip listed controls (comma-separated). Cannot be used with `controls` | | `fail_warnings` | `false` | Treat configuration warnings (unknown keys) as errors (exit 2) | #### Component configuration resolution The component resolves your configuration in priority order: 1. **`config_file` input set** uses your specified path (relative to repo root). 2. **`.plumber.yaml` in repo root** uses your repo's config file. 3. **No config found** uses the [default](https://github.com/getplumber/plumber/blob/main/defaultConfig/.plumber.yaml) embedded in the container. To author one, run `plumber config generate --overlay` for a minimal file that [extends Plumber's baseline](/docs/cli/reference#configuration) (recommended), or `plumber config generate` for the full self-contained [default config](https://github.com/getplumber/plumber/blob/main/defaultConfig/.plumber.yaml) (see the [CLI Reference](/docs/cli/reference#command-reference)). The CycloneDX SBOM the component writes is automatically uploaded as a [GitLab CycloneDX report](https://docs.gitlab.com/ci/yaml/artifacts_reports/#artifactsreportscyclonedx). ## Authentication In GitLab, go to **User Settings β†’ Access Tokens** ([direct link](https://gitlab.com/-/user_settings/personal_access_tokens)) and create a Personal Access Token with `read_api` + `read_repository` scopes. **Project Access Tokens** also work: create one inside your project under **Settings β†’ Access Tokens** with the same scopes and at least **Maintainer** role. ```bash export GITLAB_TOKEN=glpat-xxxx ``` Use `api` scope instead of `read_api` if you plan to enable `--mr-comment` or `--badge` (see [GitLab Integration](#gitlab-integration) below). ### Running a scan ```bash # Auto-detected from git remote plumber analyze # Explicit project plumber analyze --gitlab-url https://gitlab.com --project mygroup/myproject ``` ### Self-Hosted GitLab ```bash plumber analyze --gitlab-url https://gitlab.example.com --project mygroup/myproject ``` ### Custom CI Configuration Path By default Plumber reads the project's configured CI config path (usually `.gitlab-ci.yml`). Override it when your pipeline file lives elsewhere: ```bash plumber analyze --ci-config-path .gitlab/ci/main.yml ``` ## Examples ### Selective Control Execution You can run or skip specific controls using their YAML key names from `.plumber.yaml`. This is useful for iterative debugging or targeted CI checks. ```bash # Only check image tags and branch protection plumber analyze --controls containerImageMustNotUseForbiddenTags,branchMustBeProtected # Run everything except branch protection plumber analyze --skip-controls branchMustBeProtected ``` Controls not selected are reported as **skipped** in the output. The `--controls` and `--skip-controls` flags are mutually exclusive. ### Silent Mode (JSON Only) ```bash plumber analyze \ --gitlab-url https://gitlab.com \ --project mygroup/myproject \ --config .plumber.yaml \ --output results.json \ --print false ``` ### Output The CLI output is color-coded in your terminal for easy scanning: green for passing controls, red for failures. ![Plumber CLI output showing analysis results](../img/cli-output.png) ### Configuration The `gitlab.controls:` section of a schema v2 `.plumber.yaml`. With `extends: plumber:default` on top, this file is an [overlay](/docs/cli/reference#configuration): every control keeps its baseline setting and you list only what you change. Without `extends`, the file is the complete policy and a control left out does not run. ```yaml extends: plumber:default version: "2.0" gitlab: controls: containerImageMustNotUseForbiddenTags: enabled: true tags: - latest - dev - main # When true, ALL images must be pinned by digest. Takes precedence # over the tags list, so even version tags like alpine:3.19 fail. containerImagesMustBePinnedByDigest: false containerImageMustComeFromAuthorizedSources: enabled: true trustDockerHubOfficialImages: true trustedUrls: - $CI_REGISTRY_IMAGE:* - registry.gitlab.com/security-products/* branchMustBeProtected: enabled: true defaultMustBeProtected: true namePatterns: - main - release/* allowForcePush: false minMergeAccessLevel: 30 # Developer minPushAccessLevel: 40 # Maintainer pipelineMustNotIncludeHardcodedJobs: enabled: true externalRefsMustNotCollide: enabled: true includesMustBeUpToDate: enabled: true includesMustNotUseForbiddenVersions: enabled: true forbiddenVersions: - latest - "~latest" - main - master - HEAD defaultBranchIsForbiddenVersion: false pipelineMustIncludeComponent: enabled: false # Disabled by default. Enable and configure for your org. # Expression syntax (use one, not both): # required: components/sast/sast AND components/secret-detection/secret-detection # Array syntax (OR of ANDs): # requiredGroups: # - ["components/sast/sast", "components/secret-detection/secret-detection"] # - ["your-org/full-security/full-security"] pipelineMustIncludeTemplate: enabled: false # Disabled by default. Enable and configure for your org. # Expression syntax (use one, not both): # required: templates/go/go AND templates/trivy/trivy # Array syntax (OR of ANDs): # requiredGroups: # - ["templates/go/go", "templates/trivy/trivy"] # - ["templates/full-go-pipeline"] # Detect debug trace variables that leak secrets in job logs. pipelineMustNotEnableDebugTrace: enabled: true forbiddenVariables: - CI_DEBUG_TRACE - CI_DEBUG_SERVICES # Detect user-controlled variables in shell re-interpretation contexts # (eval, sh -c, etc.). Safe: echo $CI_COMMIT_BRANCH. Dangerous: eval # "deploy $CI_COMMIT_BRANCH". pipelineMustNotUseUnsafeVariableExpansion: enabled: true dangerousVariables: - CI_MERGE_REQUEST_TITLE - CI_MERGE_REQUEST_DESCRIPTION - CI_COMMIT_MESSAGE - CI_COMMIT_TITLE - CI_COMMIT_TAG_MESSAGE - CI_COMMIT_REF_NAME - CI_COMMIT_REF_SLUG - CI_COMMIT_BRANCH - CI_MERGE_REQUEST_SOURCE_BRANCH_NAME - CI_EXTERNAL_PULL_REQUEST_SOURCE_BRANCH_NAME # Regex patterns to allow specific script lines (escape $ as \\$). allowedPatterns: - "helm.*--set.*\\$CI_" - "terraform workspace select.*\\$CI_" - "docker build.*--build-arg.*\\$CI_" # Detect security scanning jobs that have been silently weakened. securityJobsMustNotBeWeakened: enabled: true securityJobPatterns: - "*-sast" - "secret_detection" - "container_scanning" - "*_dependency_scanning" - "gemnasium-*" - "dast" - "dast_*" - "license_scanning" allowFailureMustBeFalse: enabled: false # opt-in: GitLab templates ship with allow_failure: true rulesMustNotBeRedefined: enabled: true whenMustNotBeManual: enabled: true # Detect controlled variables overridden in .gitlab-ci.yml. pipelineMustNotOverrideJobVariables: enabled: true variables: - SECURE_ANALYZERS_PREFIX - SAST_DISABLED - SAST_EXCLUDED_PATHS - SECRET_DETECTION_DISABLED - CONTAINER_SCANNING_DISABLED - DAST_DISABLED # Detect unverified script downloads and execution (curl|bash, wget|sh). pipelineMustNotExecuteUnverifiedScripts: enabled: true trustedUrls: [] # - https://internal-artifacts.example.com/* # Detect Docker-in-Docker services and insecure daemon configuration. pipelineMustNotUseDockerInDocker: enabled: true detectInsecureDaemon: true ``` ## GitLab Integration Plumber integrates directly with GitLab to provide visual feedback where your team works. ### Merge Request Comments Automatically post Plumber summaries on merge requests to catch issues before they're merged. ```bash plumber analyze --mr-comment ``` Or via the GitLab CI component: ```yaml include: - component: gitlab.com/getplumber/plumber/plumber@bec6c5b303bec2b5e18d1883d5eeba6432477a0b # pinned plumber version: v0.4.36 inputs: mr_comment: true # Requires api scope on token ``` ![Merge request comment showing Plumber results](../img/merge-request-comments.png) **Features:** - Shows the Plumber letter-score badge (A–E) and a short score line; with `score_point`, adds the full points breakdown - Lists all controls with individual passing percentages - Details specific issues found with job names and image references - Automatically updates on each pipeline run (no duplicate comments) ### Project Badges Display a live Plumber letter-score badge on your project's overview page. ```bash plumber analyze --badge ``` Or via the GitLab CI component: ```yaml include: - component: gitlab.com/getplumber/plumber/plumber@bec6c5b303bec2b5e18d1883d5eeba6432477a0b # pinned plumber version: v0.4.36 inputs: badge: true # Requires api scope on token ```
![Plumber score badge on the project overview page](../img/badge-comment.png)
**Features:** - Shows the Plumber letter score (A–E) - Colored by grade: **A/B** green, **C** yellow, **D** orange, **E** red - Only updates on default branch pipelines (not on MRs or feature branches) - Badge appears in GitLab's "Project information" section ## Reference The complete, always-current catalogs and command reference: ## Troubleshooting | Issue | Solution | |-------|----------| | `GITLAB_TOKEN environment variable is required` | Set the `GITLAB_TOKEN` environment variable with a valid GitLab token | | `401 Unauthorized` | Token needs `read_api` + `read_repository` scopes, from a Maintainer or higher | | `403 Forbidden` on MR settings | Expected on non-Premium GitLab; continues without that data | | `403 Forbidden` on MR comment | Token needs `api` scope (not `read_api`) when `--mr-comment` is enabled | | `403 Forbidden` on badge | Token needs `api` scope (not `read_api`) when `--badge` is enabled | | `404 Not Found` | Verify the project path and GitLab URL are correct | | MR comment not posted | `--mr-comment` only works in merge request pipelines (`CI_MERGE_REQUEST_IID` must be set) | | Badge not created/updated | Token needs `api` scope and Maintainer role (or higher) on the project | | Configuration file not found | Ensure `--config` points at the real file (use an absolute path in Docker). Create one with `plumber config generate` or `plumber config init` | | Component not found (self-hosted) | You must import or mirror the component to your instance ([Hosting on self-hosted GitLab](#hosting-on-self-hosted-gitlab)) | | Plumber component job not running | The component's default stage is `.pre`, which requires at least one other job in a regular stage. Override with `inputs: { stage: test }` | | Two pipelines on the same push | Add [`workflow:rules`](https://docs.gitlab.com/ee/ci/yaml/workflow.html#switch-between-branch-pipelines-and-merge-request-pipelines) to prevent duplicate branch + MR pipelines (see [Run with the GitLab CI component](#run-with-the-gitlab-ci-component)) | | Component job skipped on branch | The component runs only on merge request events, the default branch, and tags | | Score badge not published | `score_push` publishes on every CI run, but the **public badge** only reflects your default branch (the service filters by the OIDC branch claim; MR/tag pipelines publish without touching it). A local run never publishes. The push warns, never fails, when the OIDC id-token is unavailable. See [Plumber Score](/docs/plumber-score) | --- # Installation Source: https://getplumber.io/docs/cli/installation Install the open-source Plumber CI/CD security CLI on macOS, Linux, or in CI via Homebrew, mise, prebuilt binary, Docker, or build from source. Homebrew Mise Binary Docker Source ```bash brew tap getplumber/plumber brew install plumber ``` **Install a specific version:** ```bash brew install getplumber/plumber/plumber@v0.4.36 # pinned plumber version ``` ```bash mise use -g github:getplumber/plumber ``` **Linux (amd64)** ```bash curl -LO https://github.com/getplumber/plumber/releases/latest/download/plumber-linux-amd64 chmod +x plumber-linux-amd64 sudo mv plumber-linux-amd64 /usr/local/bin/plumber ``` **Linux (arm64)** ```bash curl -LO https://github.com/getplumber/plumber/releases/latest/download/plumber-linux-arm64 chmod +x plumber-linux-arm64 sudo mv plumber-linux-arm64 /usr/local/bin/plumber ``` **macOS (Apple Silicon)** ```bash curl -LO https://github.com/getplumber/plumber/releases/latest/download/plumber-darwin-arm64 chmod +x plumber-darwin-arm64 sudo mv plumber-darwin-arm64 /usr/local/bin/plumber ``` **macOS (Intel)** ```bash curl -LO https://github.com/getplumber/plumber/releases/latest/download/plumber-darwin-amd64 chmod +x plumber-darwin-amd64 sudo mv plumber-darwin-amd64 /usr/local/bin/plumber ``` **Windows (PowerShell)** ```powershell Invoke-WebRequest -Uri https://github.com/getplumber/plumber/releases/latest/download/plumber-windows-amd64.exe -OutFile plumber.exe ``` **Verify checksum** (optional): ```bash curl -LO https://github.com/getplumber/plumber/releases/latest/download/checksums.txt sha256sum -c checksums.txt --ignore-missing ``` ```bash docker pull getplumber/plumber:latest ``` Run analysis directly with Docker: **GitLab:** ```bash docker run --rm \ -e GITLAB_TOKEN=glpat-xxxx \ getplumber/plumber:latest analyze \ --gitlab-url https://gitlab.com \ --project mygroup/myproject ``` **GitHub:** ```bash docker run --rm \ -e GH_TOKEN=ghp_xxxx \ getplumber/plumber:latest analyze \ --github-url github.com \ --project myorg/myrepo ``` ```bash git clone https://github.com/getplumber/plumber.git cd plumber make build # or: make install (builds and copies to /usr/local/bin/) ``` ## Verify release binaries Every release binary is published with a SLSA build-provenance attestation. Before you run a downloaded binary in production or CI, verify it against the upstream repository. The commands below always resolve to the **latest** release, so you are never pinned to a stale version: ```bash # Downloads the latest release asset, then verifies its provenance gh release download --repo getplumber/plumber --pattern 'plumber-linux-amd64' gh attestation verify plumber-linux-amd64 --repo getplumber/plumber ``` --- # CLI Reference Source: https://getplumber.io/docs/cli/reference Provider-agnostic reference for the open-source Plumber CLI: the three configuration modes (zero-config, extends overlay, full file), the analyze command and flags, config commands, plumber explain, exit codes, PBOM / CycloneDX, and the output JSON shape. The provider-agnostic reference for the Plumber CLI. The `analyze` command takes provider-specific flags and tokens; the config commands, `plumber explain`, exit codes, and output formats behave identically for both GitLab and GitHub. For installation, see the [Installation](/docs/cli/installation) page. For provider specifics, see the [GitHub](/docs/cli/github) and [GitLab](/docs/cli/gitlab) pages. ## Configuration Plumber reads its policy from `.plumber.yaml` (override the path with `--config`). There are three ways to run it: | Case | What you write | What runs | |------|----------------|-----------| | [No configuration](#no-configuration) | Nothing | Plumber's built-in default configuration | | [Extended configuration](#extended-configuration-recommended) (**recommended**) | A few-line overlay starting with `extends: plumber:default` | Your overrides, deep-merged onto Plumber's baseline | | [Full configuration](#full-configuration) | A complete `.plumber.yaml` without `extends` | Exactly your file; the baseline is not consulted | ### No configuration `plumber analyze` works with no setup at all. When there is no `.plumber.yaml` and no explicit `--config`, Plumber runs with the default configuration embedded in the binary and prints a one-line notice on stderr. A terminal and a CI job behave identically: there is no interactive prompt. Inspect what runs in this mode with `plumber config view`. If you pass `--config` and the file does not exist, that is a hard error; Plumber never silently substitutes the default for a file you named. ### Extended configuration (recommended) Add `extends: plumber:default` to make your `.plumber.yaml` a **sparse overlay** on Plumber's shipped baseline. You write only what you change; everything else is inherited, and new controls Plumber ships in future releases apply automatically without touching your file: ```yaml extends: plumber:default version: "2.0" github: controls: githubActionMustComeFromAuthorizedSources: includePlumberDefaults: true # keep Plumber's curated trusted orgs (default) trustedGithubActions: - myorg ``` A section left empty in an overlay inherits the baseline rather than wiping it. On allowlist controls (authorized sources, trusted owners, and so on), `includePlumberDefaults: true` (the default) unions your entries with Plumber's curated list; set it to `false` to use only your own list. The commands built for this mode: - [`plumber config generate --overlay`](#plumber-config-generate) writes a minimal overlay starter. - [`plumber config resolve`](#plumber-config-resolve) prints the full effective configuration an overlay expands to. - [`plumber config view --explain`](#plumber-config-view) shows, per control, whether the value comes from the baseline or your overlay. - [`plumber config slim`](#plumber-config-slim) collapses an existing full config into a minimal overlay. ### Full configuration A `.plumber.yaml` **without** `extends` is a complete, self-contained policy: what is in the file is exactly what runs, and a control you leave out does not run. Nothing is inherited, so new controls Plumber ships stay off until you add them; you own and maintain the whole file (~1000 lines for the complete template). Author one with [`plumber config generate`](#plumber-config-generate) (the full commented template) or [`plumber config init`](#plumber-config-init) (interactive wizard). This is the right mode when your policy must be fully explicit, with nothing inherited; for auditing, [`plumber config resolve`](#plumber-config-resolve) turns an overlay into an equivalent full file you can commit. ## Command Reference ### `plumber analyze` The main command for analyzing GitLab CI/CD pipelines and GitHub Actions workflows. ```bash plumber analyze [flags] ``` **Flags** | Flag | Required | Default | Description | |------|----------|---------|-------------| | `--gitlab-url` | No* | auto-detect | GitLab instance URL (e.g., `https://gitlab.com`). Mutually exclusive with `--github-url`. | | `--github-url` | No* | auto-detect | GitHub host (e.g., `github.com` or `ghes.example.com`). Mutually exclusive with `--gitlab-url`. | | `--provider` | No | auto-detect | Force the provider: `github` or `gitlab` (overrides auto-detection; the host is still auto-detected). | | `--project` | No* | auto-detect | Project / repo path. GitLab: `group/project`. GitHub: `owner/repo`. | | `--config` | No | `.plumber.yaml` | Path to configuration file | | `--min-score` | No | - | Minimum [Plumber Score](/docs/plumber-score) letter to pass (A-E), e.g. `B` fails on C, D, E. The recommended way to gate CI on the score | | `--min-points` | No | `100` | Fine-grained gate on score points (0-100). The default (100) fails on any finding; not applied when only `--min-score` is set | | `--threshold` | No | - | **Deprecated.** Minimum percentage of passing controls (0-100). Use `--min-score` / `--min-points`; cannot be combined with them | | `--branch` | No | Project default | Branch to analyze | | `--print` | No | `true` | Print the human-readable report and, on a terminal, the progress bar. Log verbosity is `--verbose`; file outputs (`--output`, `--sarif`, ...) are unaffected | | `--output`, `-o` | No | - | Write JSON results to file | | `--pbom` | No | - | Write PBOM (Pipeline Bill of Materials) to file | | `--pbom-cyclonedx` | No | - | Write PBOM in CycloneDX SBOM format | | `--sarif` | No | - | Write SARIF 2.1.0 results to file (for GitHub Code Scanning / GitLab Security Dashboard) | | `--glsast` | No | - | Write a GitLab SAST report (`gl-sast-report.json`) for the GitLab Security Dashboard / MR widget | | `--csv` | No | - | Write results to a CSV file: every control with its `status`, plus one row per finding for failing controls | | `--ocsf` | No | - | Write OCSF Compliance Finding results (JSON array, schema 1.8.0) for OCSF consumers and GRC platforms | | `--mr-comment` | No | `false` | Post/update a Plumber comment on the merge request (MR pipelines only; requires `api` scope) | | `--badge` | No | `false` | Create/update a Plumber badge on the project (requires `api` scope; only runs on default branch) | | `--score` | No | `false` | Show the Plumber score banner (letter, points, bar, severity counts) on stdout, and include points + score in the JSON, PBOM, and CycloneDX output | | `--score-point` | No | `false` | Like `--score` plus the full per-issue-code points breakdown in stdout and the MR comment; overrides `--score` when both are set | | `--score-push` | No | `false` | Publish this repo's Plumber Score to the hosted badge service. Runs in **CI only** (needs a CI-native OIDC id-token; a local run is a no-op). Publishes on every run; the score service keeps only your default branch for the public badge. See [Plumber Score](/docs/plumber-score) | | `--score-endpoint` | No | `https://score.getplumber.io` | Score service base URL. Override **only** for a self-hosted score service; the minted OIDC audience follows this value so it always matches the target | | `--controls` | No | - | Run only listed controls (comma-separated). Cannot be used with `--skip-controls` | | `--skip-controls` | No | - | Skip listed controls (comma-separated). Cannot be used with `--controls` | | `--fail-warnings` | No | `false` | Fail on warnings: configuration warnings such as unknown keys (exit 2) and "could not verify" warnings such as a skipped known-CVE check (exit 3) | | `--ci-config-path` | No | auto-detect | Override CI configuration file path. Defaults to project CI config path from GitLab settings (usually `.gitlab-ci.yml`) | | `--verbose`, `-v` | No | `false` | Enable debug logging on stderr (`level=debug`); replaces the progress bar. Independent of `--print` | **Environment variables** | Variable | Required | Description | |----------|----------|-------------| | `GITLAB_TOKEN` | GitLab only | GitLab API token with `read_api` + `read_repository` scopes (from a Maintainer or higher). Use `api` scope instead if `--mr-comment` or `--badge` is enabled. | | `GH_TOKEN` / `GITHUB_TOKEN` | GitHub only | GitHub API token. Fine-grained PAT needs `Contents: Read`, `Metadata: Read`, `Administration: Read`. Classic PAT needs `repo`. Alternatively, run `gh auth login` and Plumber will pick up the gh CLI credential. | | `PLUMBER_METADATA_TOKEN` | No (GitHub) | Token used only to resolve third-party action versions for the known-CVE check (ISSUE-703). Set it when an action is hosted in an org with an IP allow list that blocks the runner's `GITHUB_TOKEN`; a public-repository read scope is enough. Takes precedence over `GH_TOKEN` for that lookup and carries the higher authenticated rate limit. When unset, Plumber falls back to an anonymous read. | | `PLUMBER_NO_UPDATE_CHECK` | No | Set to any value (e.g., `1`) to disable the automatic version check. | ### `plumber config init` Interactive wizard to create a **minimal** `.plumber.yaml`: pick policy areas (container images, pipeline composition, branch protection, variables) and only those controls are written. For each selected area, prompts cover the tunable fields in the schema (lists, booleans, GitLab access levels, `required` expressions for catalog components and file templates, and so on). **Requires an interactive terminal** (TTY). In CI or Docker without a TTY, use [`plumber config generate`](#plumber-config-generate) instead and edit the file. **Contrast:** [`plumber config generate`](#plumber-config-generate) writes the **full** default template **with comments** (or a minimal overlay starter with `--overlay`); `init` writes a **short** file shaped by your answers. Both `init` and the plain `generate` produce a [full configuration](#full-configuration): nothing is inherited from the baseline afterwards. ```bash plumber config init [flags] ``` | Flag | Default | Description | |------|---------|-------------| | `--output`, `-o` | `.plumber.yaml` | Output file path | | `--force`, `-f` | `false` | Overwrite existing file without asking | **Examples:** ```bash plumber config init plumber config init -o configs/plumber.yaml ``` ### `plumber config generate` Writes the **official default** `.plumber.yaml`: the full template Plumber ships with, including comments and every control documented inline. Safe for **scripts and CI** (no prompts). Use [`plumber config init`](#plumber-config-init) when you have a TTY and want a **smaller** file with only the checks you pick. With `--overlay`, writes a minimal [overlay starter](#extended-configuration-recommended) instead: a few commented lines beginning with `extends: plumber:default` that inherit the whole baseline. This is the recommended starting point. ```bash plumber config generate [flags] ``` | Flag | Default | Description | |------|---------|-------------| | `--output`, `-o` | `.plumber.yaml` | Output file path | | `--force`, `-f` | `false` | Overwrite existing file | | `--overlay` | `false` | Write a minimal overlay starter (`extends: plumber:default`) instead of the full template | **Examples:** ```bash plumber config generate --overlay plumber config generate plumber config generate --output my-plumber.yaml plumber config generate --force ``` ### `plumber config migrate` Upgrades a `.plumber.yaml` from schema v1 (top-level `controls:`) to schema v2 (per-provider `gitlab.controls:` / `github.controls:`). Comments and YAML anchors are preserved. The migration is idempotent: running it against a file already on v2 is a no-op with a friendly exit message. By default the tool writes a sibling `.plumber.yaml.v2` so you can diff before swapping. Pass `--in-place` to overwrite the original; the previous file is backed up to `.plumber.yaml.bak`. ```bash plumber config migrate [flags] ``` | Flag | Default | Description | |------|---------|-------------| | `--input` | `.plumber.yaml` | Input config path to read | | `--output` | `.v2` | Output path. Ignored when `--in-place` is set. | | `--in-place` | `false` | Overwrite the input file in place. The original is backed up to `.bak`. | **Examples:** ```bash # Write a sibling .plumber.yaml.v2; diff before swapping. plumber config migrate diff .plumber.yaml .plumber.yaml.v2 mv .plumber.yaml.v2 .plumber.yaml # Or migrate in place, with backup. plumber config migrate --in-place ``` ### `plumber config view` Display a clean, human-readable view of the effective configuration without comments. ```bash plumber config view [flags] ``` | Flag | Default | Description | |------|---------|-------------| | `--config`, `-c` | `.plumber.yaml` | Path to configuration file | | `--no-color` | `false` | Disable colorized output | | `--explain` | `false` | For an [overlay config](#extended-configuration-recommended): also print, per control, whether the value is inherited from `plumber:default` (`base`) or set in your file (`overlay`) | Booleans are colorized for quick scanning: `true` in green, `false` in red. Color is automatically disabled when piping output.
![plumber config view output with colorized booleans](../img/config-view.png)
**Examples:** ```bash # View the default .plumber.yaml plumber config view # View a specific config file plumber config view --config custom-plumber.yaml # View without colors (for piping or scripts) plumber config view --no-color # Show where each control's value comes from (base vs overlay) plumber config view --explain ``` ### `plumber config resolve` Resolve `extends` and `includePlumberDefaults` and print the complete effective `.plumber.yaml`. Use it to see exactly what a scan with an [overlay config](#extended-configuration-recommended) will run, or to materialize an overlay into a full, self-contained file you can commit (nothing left implicit). With no config file and no `--config`, it prints the built-in default configuration, i.e. what a [zero-config run](#no-configuration) uses. ```bash plumber config resolve [flags] ``` | Flag | Default | Description | |------|---------|-------------| | `--config`, `-c` | `.plumber.yaml` | Path to configuration file | | `--output`, `-o` | stdout | Write the resolved config to this file | **Examples:** ```bash plumber config resolve plumber config resolve -c overlay.yaml -o full.plumber.yaml ``` ### `plumber config slim` The inverse of [`plumber config resolve`](#plumber-config-resolve): collapse a full `.plumber.yaml` into a minimal [overlay](#extended-configuration-recommended) that extends `plumber:default`, keeping only the values that differ from the baseline. The right migration path for an existing full config you no longer want to maintain line by line. The result is a fresh minimal file (comments are not preserved) and it is safe by construction: a control your full config disabled or omitted stays disabled, and a trust list you narrowed stays narrowed; `slim` then `resolve` never widens trust or re-enables a control. Review the output, then commit it. ```bash plumber config slim [flags] ``` | Flag | Default | Description | |------|---------|-------------| | `--config`, `-c` | `.plumber.yaml` | Path to the full configuration file | | `--output`, `-o` | stdout | Write the slim overlay to this file | **Examples:** ```bash plumber config slim -o .plumber.yaml plumber config slim -c old.yaml -o slim.yaml ``` ### `plumber config diff` Display a clean, human-readable view of the **differences** between the current configuration and the defaults, so you can see exactly what you have changed. ```bash plumber config diff [flags] ``` | Flag | Default | Description | |------|---------|-------------| | `--config`, `-c` | `.plumber.yaml` | Path to configuration file | | `--no-color` | `false` | Disable colorized output | **Examples:** ```bash plumber config diff plumber config diff --config custom-plumber.yaml plumber config diff --config custom-plumber.yaml --no-color ``` ### `plumber config validate` Validate a configuration file for correctness. Detects unknown control names and sub-keys with typo suggestions using fuzzy matching. ```bash plumber config validate [flags] ``` | Flag | Default | Description | |------|---------|-------------| | `--config`, `-c` | `.plumber.yaml` | Path to configuration file | | `--fail-warnings` | `false` | Treat configuration warnings as errors (exit 2) | Warnings are printed to stderr so they don't interfere with scripted output. Use `--fail-warnings` to exit with code 2 when warnings are found (useful in CI). **Examples:** ```bash # Validate the default .plumber.yaml plumber config validate # Validate a specific config file plumber config validate --config custom-plumber.yaml # Fail on warnings (for CI pipelines) plumber config validate --fail-warnings ``` **Sample output with typos:** ``` Configuration validation warnings: - Unknown control in .plumber.yaml: "containerImageMustNotUseForbiddenTag". Did you mean "containerImageMustNotUseForbiddenTags"? - Unknown key "tag" in control "containerImageMustNotUseForbiddenTags". Did you mean "tags"? - Unknown key "allowForcePushes" in control "branchMustBeProtected". Did you mean "allowForcePush"? ``` ### `plumber explain` Look up detailed information for an issue code directly from the terminal. ```bash plumber explain [ISSUE-CODE] [flags] ``` `ISSUE-CODE` supports both full and shorthand forms: - `ISSUE-412` - `412` | Flag | Default | Description | |------|---------|-------------| | `--list` | `false` | List all issue codes with short descriptions | | `--all` | `false` | Show detailed information for all issue codes | | `--json` | `false` | Output in JSON format | ```bash plumber explain ISSUE-412 plumber explain 412 plumber explain --list plumber explain --all ``` **Sample output** (`plumber explain ISSUE-412`): ``` ISSUE-412: Docker-in-Docker service detected Control: pipelineMustNotUseDockerInDocker Description: A CI/CD job uses a Docker-in-Docker (dind) service. On shared runners running in privileged mode, this enables container escape, lateral movement, and access to secrets from other jobs on the same runner. Remediation: Replace Docker-in-Docker with a safer alternative such as Kaniko or Buildah for building container images. These tools do not require privileged mode and avoid the security risks of running a Docker daemon inside a CI container. Documentation: https://getplumber.io/docs/use-plumber/issues/ISSUE-412 ``` ## Exit Codes | Code | Meaning | |------|---------| | `0` | Passed (the Plumber Score meets the gate) | | `1` | Gate failure (score below `--min-score` / `--min-points`, or the deprecated `--threshold` not met) | | `2` | Runtime error (config error, network failure, missing token, etc.) | | `3` | A check could not be verified and `--fail-warnings` is set (e.g. an action version that could not be resolved) | ## Automatic Version Check When running locally, Plumber checks GitHub for newer releases on every invocation and prints an upgrade notice if one is available. The check runs asynchronously and has a 3-second timeout, so it never slows down the analysis. The check is **automatically skipped** when: - Running in **CI environments** (`CI` or `GITLAB_CI` environment variables are set) - Using a **development build** (version is `dev`) To disable it manually: ```bash export PLUMBER_NO_UPDATE_CHECK=1 ``` ## Output formats & artifacts By default Plumber prints a colorized, human-readable report to your terminal (`--print`, on by default). Every machine-readable format below is opt-in: pass the matching flag with a destination path. Formats can be combined in a single run, so one scan can emit JSON, a SARIF report, and an SBOM at once. | Format | Flag | Spec / shape | What it's for | |--------|------|--------------|---------------| | Terminal report | `--print` (default `true`) | Colorized text on stdout | Human-readable summary: per-control results, findings, and the optional [score banner](#plumber-analyze) (`--score` / `--score-point`) | | JSON report | `--output`, `-o` | Plumber JSON ([structure below](#json-report-structure)) | Full structured result for scripting and CI gates | | Native PBOM | `--pbom` | Plumber Pipeline Bill of Materials ([below](#pbom--cyclonedx)) | Detailed inventory of pipeline dependencies (images, components, templates, includes) | | CycloneDX SBOM | `--pbom-cyclonedx` | CycloneDX 1.5 (JSON) | Standard SBOM for security tooling such as Grype, Trivy, and Dependency-Track | | SARIF | `--sarif` | SARIF 2.1.0 | Findings for GitHub Code Scanning and the GitLab Security Dashboard | | GitLab SAST report | `--glsast` | GitLab SAST report schema v15.0.4 (`gl-sast-report.json`) | Findings for the GitLab Security Dashboard and the merge-request security widget | | CSV | `--csv` | Every control with its status, plus a row per finding ([columns below](#csv-columns)) | Spreadsheet tools, ad-hoc analysis, per-control history | | OCSF | `--ocsf` | OCSF 1.8.0 Compliance Finding (JSON array) | One event per control with an explicit pass/fail/warning/skipped status, for OCSF consumers and GRC platforms | **Outputs that post back to the provider (not files):** | Output | Flag | Notes | |--------|------|-------| | Merge-request comment | `--mr-comment` | Posts/updates a Plumber comment on the GitLab MR. Requires an `api`-scope token; MR pipelines only | | Project badge | `--badge` | Creates/updates a Plumber badge on the GitLab project. Requires an `api`-scope token; runs on the default branch only | | Hosted Plumber Score badge | `--score-push` | Publishes this repo's A-E score to the hosted [score service](/docs/plumber-score). CI only (needs a CI-native OIDC id-token); a local run is a no-op | ### PBOM & CycloneDX `--pbom` writes Plumber's native, pipeline-specific inventory; `--pbom-cyclonedx` writes the same inventory as a [CycloneDX 1.5](https://cyclonedx.org/docs/1.5/json/) SBOM, compatible with tools like Grype, Trivy, and Dependency-Track. With the [GitLab CI component](/docs/cli/gitlab#run-with-the-gitlab-ci-component), the CycloneDX file is automatically uploaded as a [GitLab CycloneDX report](https://docs.gitlab.com/ci/yaml/artifacts_reports/#artifactsreportscyclonedx). ### JSON report structure `plumber analyze --output report.json` writes a single JSON object. The keys below are stable for scripting; additional keys may be added in minor versions, existing keys will not be renamed or removed. **Top-level keys** | Key | Type | Description | |-----|------|-------------| | `projectPath` | string | Path identifying the analyzed project (e.g. `group/project` on GitLab, `owner/repo` on GitHub). | | `projectId` | number | Provider-side project / repo id, when known. | | `defaultBranch` | string | Default branch reported by the provider. | | `analyzeBranch` | string | Branch the analysis actually ran against (`--branch` or the project default). Omitted when it matches `defaultBranch`. | | `headCommitSha` | string | Head commit SHA of the analyzed branch, used to build stable source links. Omitted when it can't be resolved (e.g. local-only runs). | | `ciConfigSource` | string | Where the CI configuration came from: `local` (the working tree) or `remote` (fetched from the provider). | | `ciValid` | boolean | Whether the CI configuration parsed successfully. | | `ciMissing` | boolean | True when no CI configuration file was found. | | `ciErrors` | array | CI configuration parse errors reported by the provider. Omitted when none. | | `pipelineOriginMetrics` | object | Counts and origins of pipeline jobs (hardcoded, from include, from component). | | `pipelineImageMetrics` | object | Counts of container images per source / registry. | | `minScore` | string | Letter gate from `--min-score` (A–E). Present only when set. | | `minPoints` | number | Points gate from `--min-points` (default 100). Omitted when only `--min-score` gates or the deprecated `--threshold` is used. | | `threshold` | number | Deprecated gate from `--threshold`. Present only when that flag is supplied. | | `passed` | boolean | True when the active gate is met. | | `plumberScore` | object | Scored severity summary (raw points, severity buckets, final points). Present with `--score` / `--score-point`. | | `Result` | object | One entry per evaluated control (see below). | | `partialControls` | array | Controls that could not fully evaluate. Empty or omitted on a clean run. | | `warnings` | array | Non-fatal "could not verify" messages (e.g. a known-CVE check that couldn't resolve an action version). Gated by `--fail-warnings` (exit 3). Omitted when none. | | `dataCollectionDegraded` | boolean | True when a collection or enrichment step failed mid-run, so the analysis ran on incomplete data. Treat the run as suspect even if it passed its gate. Omitted when false. | | `degradedReasons` | array | Human-readable reasons behind `dataCollectionDegraded`. Omitted when not degraded. | | `plumberConfig` | object | Self-describing snapshot of the effective policy: `source`, `effectivePolicy` (the parsed config with comments stripped and, for an [overlay config](#extended-configuration-recommended), `extends` fully resolved), and `hash` (sha256 of the canonical policy). Written on every run. | **Per-control `*Result` block** Each `*Result` block has the same baseline shape. Some controls add a few control-specific keys on top. | Key | Type | Description | |-----|------|-------------| | `controlName` | string | The block's stable `.plumber.yaml` control name (e.g. `actionsMustBePinnedByCommitSha`). Together with each issue's `code`, one of the two identifiers safe to build an external mapper against. Lives once on the block; every issue inside it belongs to this control. | | `status` | string | Explicit evaluation verdict: `passed` (evaluated, no findings), `failed` (evaluated, findings raised), `skipped` (never ran: disabled or filtered out), or `error` (could not be fully evaluated: missing/invalid CI config or degraded data collection; an empty `issues` list in this state means "could not tell", not "compliant"). Use this instead of inferring pass from an empty `issues` array. | | `issues` | array | Findings raised by the control (see the entry shape below). | | `metrics` | object | Counts the control collected (jobs scanned, images checked, branches inspected, etc.). | | `skipped` | boolean | True when the control was disabled in `.plumber.yaml` or excluded via `--skip-controls`. Kept for backward compatibility; `status: "skipped"` mirrors it. | | `ciValid` | boolean | Same as the top-level field, scoped to what this control needed. | | `ciMissing` | boolean | Same as the top-level field, scoped to what this control needed. | | `version` | string | Schema version of the control's output block. | **`issues` entry** Each entry describes one finding. Beyond the keys below, a rule adds its own structured payload naming what it flagged (`uses` for an action reference, `tag` and `link` for a container image, `branchName`, `variableName`, and so on). | Key | Type | Description | |-----|------|-------------| | `code` | string | The `ISSUE-XXX` code. | | `fingerprint` | string | Stable identifier for this finding, for tracking it across runs ([see below](#finding-fingerprint)). | | `identity` | object | The exact field set the fingerprint is derived from, as data ([see below](#the-identity-block)): `version` (the identity recipe version), `fields` (the ordered key/value pairs), and `subjectFromMessage` (true when the finding still identifies on its message text). | | `job` | string | The CI job the finding sits in. Empty when the finding is not about a job: a branch, an include, or a required template / component / action names its subject in the structured payload (`branchName`, `includePath`, `templatePath`, `componentPath`, `requiredAction`, ...) instead. | | `step` | string | GitHub only: the workflow step's `name:`, when the author gave the step one. Distinguishes two steps in the same job that reference the same action. Omitted for unnamed steps and on GitLab. | | `url` | string | Clickable link to the affected file and line on the provider, or the local path outside CI. | | `docUrl` | string | Link to the issue's documentation page. | To track a control across runs (history, remediation state, trend dashboards), key on the pair `controlName` + `status`: `controlName` is the check's stable identity and `status` its verdict for that run. To track one individual finding within a control, use its `fingerprint`. **`partialControls` entry** When non-empty, each entry has the shape shown in the [GitHub authentication section](/docs/cli/github#authentication): `control`, `reason`, `affectedBranches` (when relevant), `remediation`. CI gates should fail loud when this array contains anything, even if `passed` reads true. ### CSV columns `plumber analyze --csv results.csv` writes one header row followed by **every control in the catalog**, not just the ones that found something. A control that passed, was skipped, or could not be evaluated gets a single summary row; a control that failed gets one row per finding. Non-failing controls are listed first, then the failing ones with their findings, so the clean posture reads at the top and the problems are grouped at the bottom. Because every control is present on every run, a clean scan is a full report rather than an empty file, and an empty `code` column never has to be interpreted as "compliant". Column order is fixed and will not change: | Column | Description | |--------|-------------| | `code` | `ISSUE-XXX` code. Empty on summary rows (passed, skipped, error), since those describe a control rather than a finding | | `fingerprint` | Stable per-finding identifier ([see below](#finding-fingerprint)). Empty on summary rows | | `controlName` | Stable `.plumber.yaml` control name (e.g. `actionsMustBePinnedByCommitSha`). Always present | | `status` | The control's verdict for this run: `passed`, `failed`, `skipped`, or `error`. Always present. Every row of a failing control carries `failed` | | `severity` | `critical`, `high`, `medium`, or `low`. Empty on summary rows | | `message` | Finding message on a `failed` row; the skip or error reason on a `skipped` / `error` row; empty on a `passed` row | | `context` | The CI job the finding sits in. Empty when the finding is not about a job (a branch, an include, a required template / component / action); those findings name their subject in the JSON report's structured payload and [`identity` block](#the-identity-block) | | `file` | Path of the affected file, relative to the repo root. Empty for repo-level findings (e.g. branch protection) | | `line` | Line number in `file`. Empty when there's no file, or the finding isn't line-scoped | | `url` | Clickable link to the affected file/line on the provider, or the local path outside CI | | `docUrl` | Link to the issue's documentation page | To build per-control history, group on `controlName` and read `status` for each run. To follow one specific finding, use `fingerprint`. Codeless findings (none currently exist) are skipped, since they have no stable identifier to report against. ### Finding fingerprint Every finding carries a `fingerprint`: a short, stable identifier for that one finding, so you can tell across runs whether it is the same problem, a new one, or resolved. `controlName` answers "how is this check doing"; `fingerprint` answers "is this particular finding still there". The same value appears in every format, so a finding can be correlated between them: | Format | Where | |--------|-------| | JSON | `fingerprint` on each issue entry | | CSV | `fingerprint` column | | SARIF | `partialFingerprints["plumber/v1"]` (the field GitHub Code Scanning uses to track an alert across runs) | | GitLab SAST | an `identifiers[]` entry of type `plumber-fingerprint` | | OCSF | `fingerprint` on each `unmapped.plumber_findings[]` record | It is derived from what the finding is *about* rather than how it is worded: the issue code, the file, the job it sits in when there is one, the subject the rule flagged (an action reference, a branch, an image, a variable, an include path), and the workflow step name when there is one. Line numbers are deliberately excluded, so editing unrelated code above a finding does not change its fingerprint. For the exact recipe, the subject-key priority, and worked examples of each case (a rule with a structured subject, the same action used twice in one job, the message fallback, repository-level findings), see [docs/FINGERPRINT.md](https://github.com/getplumber/plumber/blob/main/docs/FINGERPRINT.md). ### The identity block The fingerprint is a hash, so it cannot tell a consumer *which* fields it was built from. Each issue entry in the JSON report therefore also carries an `identity` block: the selected field set itself, as data. Every identity carries `code`, `file`, and `job`, then **exactly one subject key** naming what the rule flagged, and finally `step` when the workflow resolved one. Only the single most specific subject key appears, not every field in the finding's payload: the recipe walks a fixed priority list and takes the first key the finding carries. ```json "identity": { "version": 2, "subjectFromMessage": false, "fields": [ { "key": "code", "value": "ISSUE-701" }, { "key": "file", "value": ".github/workflows/build.yaml" }, { "key": "job", "value": "build/compile" }, { "key": "uses", "value": "some-org/some-action@master" }, { "key": "step", "value": "Build image" } ] } ``` | Key | Type | Description | |-----|------|-------------| | `version` | number | The identity **recipe version** (currently 2). It tracks identity outcomes, not just the algorithm: it is bumped whenever fingerprints can move, so a consumer holding stored fingerprints knows when to expect re-keys. | | `subjectFromMessage` | boolean | True when the rule has no structured subject and the finding identifies on its message text, meaning a wording change in a future release would re-key it. False for the vast majority of findings. | | `fields` | array | The ordered key/value pairs the fingerprint is derived from. Always `code`, `file`, and `job`; then the one winning **subject key**; then `step` when present (see below). | **The `fields`, in order:** | Field | Always present | Description | |-------|:--------------:|-------------| | `code` | yes | The `ISSUE-XXX` code. | | `file` | yes | Affected file, relative to the repo root. Empty for repo-level findings (e.g. branch protection). | | `job` | yes | The CI job the finding sits in: `/` on GitHub, the job name on GitLab. **Empty** for findings that are not about a job (a branch, an include, or a required template / component / action), which name their subject in the key below instead. | | *(subject)* | yes | Exactly one key naming what the rule flagged, chosen as the first present from this priority order: `uses`, `branchName`, `includePath`, `templatePath`, `componentPath`, `requiredAction`, `image`, `serviceImage`, `link`, `tag`, `variableName`, `hardcodedJob`, `scriptLine`, `detail`. When the rule carries none of them the subject key is `message` and `subjectFromMessage` is `true`. | | `step` | no | GitHub only: the workflow step's `name:`, when the author gave the step one. Present only when the workflow resolved a step; it is the last discriminator between two steps of one job that reference the same action. | Everything else in a finding's payload is **deliberately not part of identity**: `line` and `url` move whenever unrelated code above the finding is edited, `advisories` grows as CVEs are published, `latestVersion` moves on any upstream release, and status fields track current settings rather than identity. Any of those in the field set would make an unchanged finding look new. A platform ingesting Plumber reports should **store the identity fields the CLI selected instead of re-deriving them**, so the two sides can never disagree about which findings are the same finding. Go consumers can use the public [`finding/identity`](https://github.com/getplumber/plumber/tree/main/finding/identity) package (`identity.Of`, `identity.Fingerprint`, `identity.FromMap`) to work with the same recipe programmatically.