Configuring a secured GitHub workflow to launch a Sonar analysis for PR from forks

Hi there :slight_smile:

We are facing an issue in our Java project with PR coming from forks.

Objective

We would like to run the Sonar analysis on PR coming from forks and have them validate the “SonarCloud Code Analysis” check on the PR, as well as add the decoration with the analysis results.

Example of a PR NOT coming from a fork, with Sonar decoration and check:

We also need to keep the workflow secure, which means not executing in our environment code based on fork, to avoid any risk of secret being revealed.

What we had until recently

In our usual CI, we require the “SonarCloud Code Analysis” check to be validated for a PR to be merged. To do this, we use the Maven plugin which works very well in the case of PRs coming from our own repository.

However, as the Sonar analysis requires the Sonar token to connect with sonarcloud.io and since the secrets are not made available to forks for security reasons, the analysis cannot run and the PR checks cannot be validated.

See for example this PR where the step with the Sonar analysis failed:

What we recently tried

After some intensive research on what is available to us, I tried to solve the issue by adding a new CI workflow specific to forks. This new workflow is divided in two parts:

  • The first part checks out the project, builds it and runs the tests with coverage. It is only ran when PR are opened via forks. It does not have any access to secrets as it does not require any. At the end of this workflow, artifacts are generated with the classes, the coverage report and the dependencies, as well as some information about the PR.

  • The second part is launched when it detects that the first workflow has succeeded. It has access to the secrets and has some permissions.

    • It checks out the project (in order to have the sources available)
    • It then downloads the artifacts previously generated and either extract the useful information or put the data where it belongs (classes in the target directories, etc.)
    • It then prepares the different values that will be used for the SonarSource/sonarqube-scan-action parameters: lists of sources/tests/binaries/generated/libraries directories
    • The Sonar analysis is then ran using the GitHub action SonarSource/sonarqube-scan-action and all the data previously gathered
    • Once the analysis is done, the workflow deletes the artifacts on the first workflow

I opened a PR using a fork in order to test the workflow.
The result is: it works until the Sonar analysis, but then there is no decoration nor check on the PR and the link from SonarCloud to the GitHub PR (the small GitHub icon usually next the the PR name) is not present.

No decoration nor check on the PR:

Note: we used the SonarSource/sonarqube-scan-action GitHub action and not the Maven plugin as we thought the Maven plugin represented a risk of code execution. But is that really the case? When one use mvn sonar:sonar (or mvn org.sonarsource.scanner.maven:sonar-maven-plugin:sonar), is the code built/executed or not at all? I’m not really sure how the plugin works.

The question

The questions are:

  • How can we make it work?
  • Is there a parameter I forgot or a wrong configuration in the workflow?
  • Is there any other way to run these analysis on forks while keeping the secrets secure? Would the Maven plugin work and be secure?

Some resources used

Other information

  • ALM used: GitHub
  • CI system used: the usual GitHub CI
  • Scanner command used: see workflow
  • Languages of the repository: Java
  • SonarCloud project key: com.powsybl:powsybl-afs (and other com.powsybl projects)

Hey @rolnico

I don’t think anything has changed since this last time I gave an update to another user.

Hey @Colin
Yes, that’s what I understood and why I’m trying to find a way to make it work.

By any chance, do you have an answer for those questions:

  • When one use mvn sonar:sonar (or mvn org.sonarsource.scanner.maven:sonar-maven-plugin:sonar), is the code built/executed or not at all? I’m not really sure how the plugin works.
  • Do you know why SonarCloud does not add the decoration on the PR? Is there a parameter I forgot or a wrong configuration in the workflow?

Is there maybe an API to force the decoration and check?

You can run mvn sonar:sonar standalone, but it will not compile your code by default. You need to add tasks beforehand to make sure your code is compiled, otherwise you’ll run into an error like this.

[**ERROR**] Failed to execute goal org.sonarsource.scanner.maven:sonar-maven-plugin:3.10.0.2594:sonar **(default-cli)** on project sonarscanner-maven-basic: **Your project contains .java files, please provide compiled classes with sonar.java.binaries property, or exclude them from the analysis with sonar.exclusions property.** -> **[Help 1]**

That’s why our tutorials show mvn clean verify sonar:sonar :slight_smile:

I would first suggest you make sure that your project is bound to your GitHub repo.

The “by default” is my concern. The thing is, if I use maven, i fear that an attacker could add some malicious plugin/code to the pom.xml that would then be executed when we use the mvn sonar:sonar command. This would be even more an issue if I use mvn clean verify sonar:sonar (that’s why I build and test the project in a separate workflow that does not have access to our GitHub secrets).

Our project seems well bound to our GitHub repo: the decorations work well for usual PR coming from inside our repo (see for example this random PR).

Is there a way to trigger the decoration/check manually? Either directly by API (preferably), or by another way?

Some good news and some bad news:

  • I manage to get the Sonar decoration on a PR from a fork! :partying_face: Apparently the SonarSource/sonarqube-scan-action needed some additional parameters and some more permissions:
permissions:
  actions: write
  checks: write
  contents: read
  issues: read
  pull-requests: write
  statuses: write
[...]
      - name: Run Sonar Analysis
        uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf # v5.2.0
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          args: >
            -Dsonar.projectKey=com.powsybl:powsybl-afs
            -Dsonar.organization=powsybl-ci-github
            -Dsonar.sources="${{ env.SOURCES }}"
            -Dsonar.generatedSources="${{ env.GENERATED }}"
            -Dsonar.tests="${{ env.TESTS }}"
            -Dsonar.java.binaries="${{ env.BINARIES }}"
            -Dsonar.java.libraries="${{ env.LIBRARIES }}"
            -Dsonar.java.test.libraries="${{ env.LIBRARIES }}"
            -Dsonar.coverage.jacoco.xmlReportPaths="afs-distribution/target/site/jacoco-aggregate/jacoco.xml"
            -Dsonar.pullrequest.key="${{ env.PR_NUMBER }}"
            -Dsonar.pullrequest.branch="${{ env.HEAD_REF }}"
            -Dsonar.pullrequest.base="${{ env.BASE_REF }}"
            -Dsonar.pullrequest.provider=github
            -Dsonar.pullrequest.github.repository=${{ github.repository }}
            -Dsonar.host.url=https://sonarcloud.io
            -Dsonar.scm.provider=git
            -Dsonar.qualitygate.wait=true
  • However I still don’t have the “SonarCloud Code Analysis” check on the PR :frowning: Looking at this topic, I though adding the sonar.qualitygate.wait=true parameter would be enough but it didn’t change anything. Here are the last lines of the “Run Sonar Analysis” step:
13:29:09.796 INFO  Analysis report generated in 2606ms, dir size=499 KB
13:29:10.017 INFO  Analysis report compressed in 220ms, zip size=245 KB
13:29:11.237 INFO  Analysis report uploaded in 1221ms
13:29:11.239 INFO  ------------- Check Quality Gate status
13:29:11.239 INFO  Waiting for the analysis report to be processed (max 300s)
13:29:17.142 INFO  QUALITY GATE STATUS: PASSED - View details on https://sonarcloud.io/dashboard?id=com.powsybl%3Apowsybl-afs&pullRequest=183
13:29:17.166 INFO  Analysis total time: 38.929 s
13:29:17.168 INFO  SonarScanner Engine completed successfully
13:29:17.511 INFO  EXECUTION SUCCESS
13:29:17.512 INFO  Total time: 47.212s

Any idea on how to get this check on the PR?

Another good news: I found the problem!
As in this topic, the SHA visible on SonarCloud was not the right one, so I had to specify the one from the PR.
I now have a check and a decoration on my PR from a fork :partying_face:

I’ll add a message tomorrow with the whole solution for future reference

So, as promised, here is how we managed to get a Sonar analysis on a PR based on a fork.
We now have 3 CI workflows:

  • “CI”: the usual one that works only on PR from inside our repository and therefore can access the secrets
  • “CI on forks - build and tests”: the workflow that run only on PR from forks. It does all the parts that do not require access to the secrets (building and testing)
  • “CI on forks - Sonar analysis”: this workflow is triggered only when the “CI on forks - build and tests” successfully completed. Since it’s run from inside the repository, it has access to the secrets and therefore can run the Sonar analysis. However, since it’s not triggered by the PR, the difficulty was to properly link the Sonar analysis to the PR.

First workflow triggered by usual PRs:

name: CI

on:
  push:
    branches:
      - 'main'
      - 'release-v**'
      - 'full-sonar-analysis-**'
    tags:
      - 'v[0-9]+.[0-9]+.[0-9]+*'
  pull_request:

permissions: {}

jobs:
  build:
    name: Build OS ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    if: github.event.pull_request.head.repo.fork == false
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]

    steps:
      - name: Checkout sources
        uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

      - name: Set up JDK 17
        uses: actions/setup-java@387ac29b308b003ca37ba93a6cab5eb57c8f5f93 # v4.0.0
        with:
          distribution: 'temurin'
          java-version: '17'

      - name: Build with Maven (Ubuntu)
        if: matrix.os == 'ubuntu-latest'
        run: ./mvnw --batch-mode -Dpowsybl.docker-unit-tests.skip=false -Pjacoco install

      - name: Build with Maven (Windows)
        if: matrix.os == 'windows-latest'
        run: mvnw.cmd --batch-mode install
        shell: cmd

      - name: Build with Maven (MacOS)
        if: matrix.os == 'macos-latest'
        run: ./mvnw --batch-mode install

      - name: Run SonarCloud analysis
        if: matrix.os == 'ubuntu-latest'
        run: >
          ./mvnw --batch-mode -DskipTests sonar:sonar
          -Dsonar.host.url=https://sonarcloud.io
          -Dsonar.organization=<your-sonar-organisation>
          -Dsonar.projectKey=<your-sonar-project-key>
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

Note the condition if: github.event.pull_request.head.repo.fork == false to not run this workflow if the PR is from a fork.

Second workflow triggered by PRs from forks:

name: CI on forks - build and tests

on:
  pull_request:

permissions: {}

jobs:
  build:
    name: Build OS ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    if: github.event.pull_request.head.repo.fork == true
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]

    steps:
      - name: Checkout sources
        uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

      - name: Set up JDK 17
        uses: actions/setup-java@387ac29b308b003ca37ba93a6cab5eb57c8f5f93 # v4.0.0
        with:
          distribution: 'temurin'
          java-version: '17'

      - name: Build with Maven (Ubuntu)
        if: matrix.os == 'ubuntu-latest'
        run: ./mvnw --batch-mode -Dpowsybl.docker-unit-tests.skip=false -Pjacoco install

      - name: Build with Maven (Windows)
        if: matrix.os == 'windows-latest'
        run: mvnw.cmd --batch-mode install
        shell: cmd

      - name: Build with Maven (MacOS)
        if: matrix.os == 'macos-latest'
        run: ./mvnw --batch-mode install

      - name: Regroup dependencies in target folders
        if: matrix.os == 'ubuntu-latest'
        run: ./mvnw dependency:copy-dependencies

      # adapt the paths to your project
      - name: Save classes and Jacoco report
        if: matrix.os == 'ubuntu-latest'
        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
        with:
          name: data-for-sonar-analysis-${{ github.event.pull_request.number }}
          retention-days: 1
          path: |
            */target/classes
            */*/target/classes
            */*/*/target/classes
            */target/generated-sources
            */*/target/generated-sources
            */*/*/target/generated-sources
            distribution-core/target/dependency
            distribution-core/target/site/jacoco-aggregate/jacoco.xml

      - name: Save PR Information
        if: matrix.os == 'ubuntu-latest'
        run: |
          mkdir -p pr-info
          echo "${{ github.event.pull_request.head.repo.full_name }}" > pr-info/repo-name
          echo "${{ github.event.pull_request.head.ref }}" > pr-info/head-ref
          echo "${{ github.event.pull_request.head.sha }}" > pr-info/head-sha
          echo "${{ github.event.pull_request.number }}" > pr-info/pr-number
          echo "${{ github.event.pull_request.base.ref }}" > pr-info/base-ref

      - name: Upload PR Information
        if: matrix.os == 'ubuntu-latest'
        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
        with:
          name: pr-info-${{ github.event.pull_request.number }}
          path: pr-info/
          retention-days: 1

Here we build the project, do the tests and then upload an artifact with all the data required for the Sonar Analysis. Note the same line if: github.event.pull_request.head.repo.fork == true with the inverted condition to run this workflow only for PRs from forks.

Third workflow triggered once the second one is completed:

name: CI on forks - Sonar analysis

on:
  workflow_run:
    workflows: [CI on forks - build and tests]
    types:
      - completed

permissions:
  actions: write
  checks: write
  contents: read
  issues: read
  pull-requests: write
  statuses: write

jobs:
  sonar:
    name: Run Sonar Analysis for forks
    runs-on: ubuntu-latest
    if: >
      github.event.workflow_run.event == 'pull_request' && 
      github.event.workflow_run.conclusion == 'success'
    steps:
      - name: Download PR information
        uses: actions/github-script@v7
        with:
          script: |
            let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
               owner: context.repo.owner,
               repo: context.repo.repo,
               run_id: context.payload.workflow_run.id,
            });
            let prInfoArtifact = allArtifacts.data.artifacts.filter((artifact) => {
              return artifact.name.startsWith("pr-info-")
            })[0];
            if (!prInfoArtifact) {
              core.setFailed("❌ No PR info artifact found");
              return;
            }
            let download = await github.rest.actions.downloadArtifact({
               owner: context.repo.owner,
               repo: context.repo.repo,
               artifact_id: prInfoArtifact.id,
               archive_format: 'zip',
            });
            const fs = require('fs');
            const path = require('path');
            const temp = '${{ runner.temp }}/pr-info';
            if (!fs.existsSync(temp)){
              fs.mkdirSync(temp, { recursive: true });
            }
            fs.writeFileSync(path.join(temp, 'pr-info.zip'), Buffer.from(download.data));
            console.log("PR information downloaded");

      - name: Extract PR Information
        run: |
          mkdir -p ${{ runner.temp }}/pr-info-extracted
          unzip -q ${{ runner.temp }}/pr-info/pr-info.zip -d ${{ runner.temp }}/pr-info-extracted
          REPO_NAME=$(cat ${{ runner.temp }}/pr-info-extracted/repo-name)
          HEAD_REF=$(cat ${{ runner.temp }}/pr-info-extracted/head-ref)
          HEAD_SHA=$(cat ${{ runner.temp }}/pr-info-extracted/head-sha)
          PR_NUMBER=$(cat ${{ runner.temp }}/pr-info-extracted/pr-number)
          BASE_REF=$(cat ${{ runner.temp }}/pr-info-extracted/base-ref)
          echo "REPO_NAME=$REPO_NAME" >> $GITHUB_ENV
          echo "HEAD_REF=$HEAD_REF" >> $GITHUB_ENV
          echo "HEAD_SHA=$HEAD_SHA" >> $GITHUB_ENV
          echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV 
          echo "BASE_REF=$BASE_REF" >> $GITHUB_ENV
          echo "PR information extracted: $REPO_NAME $HEAD_REF $HEAD_SHA $PR_NUMBER $BASE_REF"

      - name: Checkout sources
        uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
        with:
          ref: ${{ env.HEAD_REF }}
          repository: ${{ env.REPO_NAME }}
          fetch-depth: 0

      - name: Set up JDK 17
        uses: actions/setup-java@387ac29b308b003ca37ba93a6cab5eb57c8f5f93 # v4.0.0
        with:
          distribution: 'temurin'
          java-version: '17'

      - name: Download artifact
        uses: actions/github-script@v7
        with:
          script: |
            let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
               owner: context.repo.owner,
               repo: context.repo.repo,
               run_id: context.payload.workflow_run.id,
            });
            let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => {
              return artifact.name.startsWith("data-for-sonar-analysis-")
            })[0];
            if (!matchArtifact) {
              core.setFailed("❌ No matching artifact found");
              return;
            }
            const prNumber = matchArtifact.name.replace("data-for-sonar-analysis-", "");
            console.log(`PR number: ${prNumber}`);
            core.exportVariable('PR_NUMBER', prNumber);
            let download = await github.rest.actions.downloadArtifact({
               owner: context.repo.owner,
               repo: context.repo.repo,
               artifact_id: matchArtifact.id,
               archive_format: 'zip',
            });
            const fs = require('fs');
            const path = require('path');
            const temp = '${{ runner.temp }}/artifacts';
            if (!fs.existsSync(temp)){
              fs.mkdirSync(temp);
            }
            fs.writeFileSync(path.join(temp, 'sonar-data.zip'), Buffer.from(download.data));

      # adapt the paths to your project
      - name: Extract Sonar Analysis Data
        run: |
          mkdir -p ${{ runner.temp }}/extracted
          unzip -q ${{ runner.temp }}/artifacts/sonar-data.zip -d ${{ runner.temp }}/extracted
          cp -r ${{ runner.temp }}/extracted/* .
          ls -la distribution-core/target/site/jacoco-aggregate/ || echo "Jacoco report directory not found"

      # adapt the paths to your project
      - name: Prepare Sonar Analysis
        run: |
          echo "Checking required directories..."
          if [ -f "distribution-core/target/site/jacoco-aggregate/jacoco.xml" ]; then
            echo "Jacoco report found"
          else
            echo "Warning: Jacoco report not found at expected location"
          fi
          echo "Finding sources and binaries..."
          SOURCES=$(find . -type d -path "*/main/java" | sort -u | paste -sd "," -)
          if [ -z "$SOURCES" ]; then
            echo "Warning: No source directories found!"
          else
            echo "SOURCES : $SOURCES"
            echo "SOURCES=$SOURCES" >> $GITHUB_ENV
          fi
          GENERATED=$(find . -type d -path "*/target/generated-sources" | sort -u | paste -sd "," -)
          if [ -z "$GENERATED" ]; then
            echo "Warning: No generated source directories found!"
          else
            echo "GENERATED : $GENERATED"
            echo "GENERATED=$GENERATED" >> $GITHUB_ENV
          fi
          TESTS=$(find . -type d -path "*/test/java" | sort -u | paste -sd "," -)
          if [ -z "$TESTS" ]; then
            echo "Warning: No test directories found!"
          else
            echo "TESTS : $TESTS"
            echo "TESTS=$TESTS" >> $GITHUB_ENV
          fi
          BINARIES=$(find . -type d -path "*/target/classes" | sort -u | paste -sd "," -)
          if [ -z "$BINARIES" ]; then
            echo "Warning: No binaries directories found!"
          else
            echo "BINARIES : $BINARIES"
            echo "BINARIES=$BINARIES" >> $GITHUB_ENV
          fi
          LIBRARIES="distribution-core/target/dependency"
          if [ -z "$LIBRARIES" ]; then
            echo "Warning: No libraries directory found!"
          else
            echo "LIBRARIES : $LIBRARIES"
            echo "LIBRARIES=$LIBRARIES" >> $GITHUB_ENV
          fi

      # This sonar action should NOT be replaced by a direct use of the mvn verify command since we don't want external
      # code to run in a workflow_run workflow (it may lead to security issues).
      # adapt the paths to your project
      - name: Run Sonar Analysis
        uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf # v5.2.0
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          args: >
            -Dsonar.projectKey=<your-sonar-project-key>
            -Dsonar.organization=<your-sonar-organisation>
            -Dsonar.sources="${{ env.SOURCES }}"
            -Dsonar.generatedSources="${{ env.GENERATED }}"
            -Dsonar.tests="${{ env.TESTS }}"
            -Dsonar.java.binaries="${{ env.BINARIES }}"
            -Dsonar.java.libraries="${{ env.LIBRARIES }}"
            -Dsonar.java.test.libraries="${{ env.LIBRARIES }}"
            -Dsonar.coverage.jacoco.xmlReportPaths="distribution-core/target/site/jacoco-aggregate/jacoco.xml"
            -Dsonar.pullrequest.key="${{ env.PR_NUMBER }}"
            -Dsonar.pullrequest.branch="${{ env.HEAD_REF }}"
            -Dsonar.pullrequest.base="${{ env.BASE_REF }}"
            -Dsonar.pullrequest.provider=github
            -Dsonar.pullrequest.github.repository=${{ github.repository }}
            -Dsonar.host.url=https://sonarcloud.io
            -Dsonar.scm.provider=git
            -Dsonar.qualitygate.wait=true
            -Dsonar.scm.revision=${{ env.HEAD_SHA }}

      - name: Delete artifacts used in analysis
        if: always()
        uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            let artifacts = await github.rest.actions.listWorkflowRunArtifacts({
               owner: context.repo.owner,
               repo: context.repo.repo,
               run_id: context.payload.workflow_run.id,
            });
            for (const artifact of artifacts.data.artifacts) {
              if (
                artifact.name.startsWith("data-for-sonar-analysis-") ||
                artifact.name.startsWith("pr-info-")
              ) {
                console.log(`Deleting artifact: ${artifact.name} (ID: ${artifact.id})`);
                await github.rest.actions.deleteArtifact({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  artifact_id: artifact.id
                });
              }
            }

In this last one we checkout the PR branch (but we do not run it in any way), download the data previously gathered in the previous workflow, put it back where it should be as if we had build the project in this workflow, and then run the Sonar analysis using the SonarSource/sonarqube-scan-action GitHub action. Since it does not use Maven, it is safe to run. Last step is here to delete the artifacts in order to avoid storing them (they can take a lot of space, depending on your project and its dependencies).

Enjoy!

2 Likes

This topic was automatically closed 7 days after the last reply. New replies are no longer allowed.