DEV Community

Mo Rizal
Mo Rizal

Posted on

Building A Secure CI Pipeline With GitHub Actions, SonarQube, And Trivy

A CI pipeline can tell us that an application works, but can it tell us that the application is safe to ship?

A successful build does not necessarily mean that the software is secure.

An application can pass its tests while still containing insecure and bad code patterns. Other than that container image built from the application may inherit vulnerable packages.

The problem is that these security issues exist at different layers:

Checking only one of these layers is not enough.

For example, static code analysis can help identify security issues in the source code, but it does not tell us whether the packages inside our container image contain known vulnerabilities.

Likewise, scanning a container image does not tell us whether the application source itself contains problematic code.

A traditional CI workflow might look like this:

The application is tested and, if everything works, the resulting artifact is published. But there is no explicit security check between the code and the artifact.

That is what we are going to address in this article.

The complete implementation is available in repository bellow:

Github Repository

What We Are Building

We will build a small Go API and place it behind an automated CI pipeline.

The application itself is intentionally simple. There is no database, authentication system, or external service. The purpose of the application is to provide an artifact that we can test, analyze, containerize, and scan.

Application

The application is a small HTTP API written in Go.

It provides two endpoints:

GET /health
GET /api/users
Enter fullscreen mode Exit fullscreen mode

The /health endpoint is used to verify that the application is running, while /api/users returns a small list of users.

This gives us enough application code to run unit tests and static analysis without introducing unnecessary application complexity.

The application is located under the cmd/ directory, with the HTTP handlers implemented in cmd/main.go.

The application is then packaged as a Docker image.

CI Pipeline

The application is connected to GitHub Actions that automate the entire process.

The final pipeline looks like this:

Each stage has a specific responsibility.

  1. API Test, verifies that the application behaves as expected before any further analysis or packaging takes place.

  2. SonarQube Scan, analyzes the source code for potential security, reliability, and maintainability issues.

  3. Docker Build, packages the application and its runtime environment into a Docker image.

  4. Trivy Scan, scans the resulting container image for known vulnerabilities in the image's operating-system packages, dependencies, and other components.

  5. Push, publishes the container image to GitHub Container Registry after it has passed the preceding stages.

Project Structure

Each part of the repository has a specific responsibility.

  1. .github/workflows/ci.yml defines the CI workflow and orchestrates the entire process.

  2. cmd/main.go contains the Go API itself, including the HTTP server and API endpoints.

  3. Dockerfile defines how the Go API is compiled and packaged into a Docker image.

  4. sonar-project.properties contains the SonarQube project configuration, including which directories should be analyzed and how test files should be identified.

Implementation

The application in this project intentionally contains several code patterns that can be detected by static analysis.

The Docker image also uses an intentionally outdated base image so that the container scan has meaningful vulnerabilities to report.

The goal is not to demonstrate how to write perfect Go code. The goal is to demonstrate how this CI pipeline can identify problematic code and image automatically.

Go API

The API implementation itself is straightforward, but several functions in main.go contain intentionally problematic code.

For example the processUser function.

func processUser(user User) string {
    if user.Name == "" {
        if user.Email == "" {
            if user.ID > 0 {
                return "invalid-user"
            }
        } else if user.ID > 0 {
            return "invalid-user"
        }
    }

    if user.Email == "" {
        if user.ID > 0 {
            return "invalid-user"
        }
    }

    if user.ID > 0 {
        if user.Name == "Alice" {
            if user.Email == "alice@example.com" {
                return "valid-user"
            }
        }

        if user.Name == "Bob" {
            if user.Email == "bob@example.com" {
                return "valid-user"
            }
        }

        return "valid-user"
    }

    return "invalid-user"
}
Enter fullscreen mode Exit fullscreen mode

The function intentionally contains deeply nested conditional statements and duplicated logic.

For example, the same user.ID > 0 condition is evaluated in multiple branches.

This makes the function harder to understand and maintain than necessary.

The same idea is used in getUserRole.

func getUserRole(role string) string {
    switch role {
    case "admin":
        return "Administrator"
    case "manager":
        return "Manager"
    case "developer":
        return "Developer"
    // ...
    default:
        return "Unknown"
    }
}
Enter fullscreen mode Exit fullscreen mode

The function contains a long list of similar branches.

This is not necessarily a security vulnerability by itself. It is included to give the static analyzer code that can be evaluated for maintainability and code-quality issues.

Another example is unreachableCode:

func unreachableCode() string {
    return "valid"

    log.Println("this code is never reached")

    return "unreachable"
}
Enter fullscreen mode Exit fullscreen mode

The statements after the first return can never be executed.

This is exactly the kind of implementation problem that static analysis can identify without executing the application.

We also intentionally introduce duplicated return values:

func duplicatedStrings(value int) string {
    if value == 1 {
        return "invalid-user"
    }

    if value == 2 {
        return "invalid-user"
    }

    if value == 3 {
        return "invalid-user"
    }

    return "valid-user"
}
Enter fullscreen mode Exit fullscreen mode

The same string literal is repeated across multiple branches.

Why Introduce Problems Intentionally?

At this point, it may seem strange to deliberately write bad code.

In a normal development project, we would try to remove these problems before merging the code.

But this project has a different purpose.

If a developer introduces problematic code into the repository, will our CI pipeline detect it automatically?

That means the vulnerable or low quality code becomes part of our test scenario.

Container Image

The same approach is used for the container image.

The Dockerfile intentionally uses an older Go base image:

FROM golang:1.20
Enter fullscreen mode Exit fullscreen mode

Once the application is built, the final image contains more than our own code. It also contains the software and packages provided by the base image.

This gives Trivy something different to analyze from what SonarQube sees.

SonarQube is primarily concerned with the source code, while Trivy examines the resulting container image and looks for known vulnerabilities in its components.

API Test

Before running the security analysis, the pipeline first runs the API test.

The workflow uses the standard Go test command:

go test ./...
Enter fullscreen mode Exit fullscreen mode

The repository includes tests for the API handlers, giving the pipeline a basic verification that the application still behaves as expected.

If the tests fail, the following stages should not proceed.

SonarQube Scan

After the test pass, the pipeline moves to static analysis.

The SonarQube configuration is stored in:

sonar-project.properties
Enter fullscreen mode Exit fullscreen mode

The important part is defining the application source and test locations:

sonar.sources=cmd
sonar.tests=cmd
Enter fullscreen mode Exit fullscreen mode

Because we intentionally introduced problematic code into main.go, this stage gives us an opportunity to verify that the static analysis can actually identify those problems.

For example, the unnecessary nesting in processUser, unreachable statements in unreachableCode, and repeated string literals in duplicatedStrings provide concrete code quality problems for the analyzer to inspect.

Trivy Scan

After the Docker image has been built, Trivy scans the resulting image for known vulnerabilities.

The workflow specifically checks for:

HIGH
CRITICAL
Enter fullscreen mode Exit fullscreen mode

severity vulnerabilities.

The scan is performed against the image that was just built, rather than against an unrelated image.

This is where the intentionally older base image becomes relevant.

Even though the Go application itself is small, the container inherits software from its base image. Trivy can therefore identify vulnerabilities that exist below the application layer.

SonarQube tells us about problems in the code we write. Trivy tells us about vulnerabilities in the artifact we are about to distribute.

There is one important configuration detail in this project.

The Trivy action currently uses:

exit-code: 0
Enter fullscreen mode Exit fullscreen mode

This means the scan reports the detected vulnerabilities but does not fail the workflow because of them.

That is intentional for this demonstration.

We want to see the vulnerabilities produced by our intentionally vulnerable image while still allowing the rest of the workflow to demonstrate the complete delivery process.

This leads to an important distinction that we will revisit later:

A scanner can detect a vulnerability without necessarily enforcing a policy against it

Push the Image

The final stage publishes the container image to GitHub Container Registry.

The workflow is structured so that the push stage depends on the previous stages completing successfully.

The workflow also defines limited permissions for the GitHub Actions job:

permissions:
  contents: read
  packages: write
Enter fullscreen mode Exit fullscreen mode

The workflow needs read access to the repository and write access to GitHub Packages in order to publish the image.

With the image push as the final stage, the pipeline now has a complete path from source code to a published container artifact.

The interesting part is that we can now observe whether the security checks actually detected the intentionally introduced problems.

Result

With everthing setup the scanners now can found the intentionally introduced problems.

SonarQube Result

The SonarQube analysis identified four issues in the Go application.

The most significant finding was in processUser:

Refactor this method to reduce its Cognitive Complexity from 21 to the 15 allowed.

SonarQube assigned this issue a High maintainability impact.

The function has a Cognitive Complexity score of 21, while the configured threshold is 15.

This is a good example of why static analysis is useful.

The application can still compile and the API can still work correctly, but the implementation is unnecessarily difficult to reason about. The CI analysis makes this problem visible without requiring a reviewer to manually inspect every conditional branch.

SonarQube also detected duplicated literals:

Define a constant instead of duplicating this literal "invalid-user" 7 times.

These findings map directly to the intentionally duplicated values in processUser and duplicatedStrings.

Finally, SonarQube identified the unreachable code in unreachableCode function:

Refactor this piece of code to not have any dead code after this "return".

This issue was classified as a Medium reliability issue.

Quality Gate Enforcement

There is another important detail in the workflow.

The SonarQube Quality Gate step is intentionally commented out to allow the pipeline to keep running, as this project is currently focused on detecting code quality issues rather than enforcing them.

# - name: SonarQube quality gate
#   uses: SonarSource/sonarqube-quality-gate-action@cf038b0e0cdecfa9e56c198bbb7d21d751d62c3b # v1.2.0
#   timeout-minutes: 5
#   env:
#     SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
#     SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
Enter fullscreen mode Exit fullscreen mode

Trivy Results

Trivy analyzed the container image:

secure-ci-pipeline:latest
Enter fullscreen mode Exit fullscreen mode

The image was based on Debian 12.4 and Trivy reported:

1641 vulnerabilities
Enter fullscreen mode Exit fullscreen mode

at the Debian package layer.

Trivy also identified vulnerabilities in the Go binaries contained in the image:

app/app       → 25 vulnerabilities
/usr/local/go/bin/go       → 25 vulnerabilities
/usr/local/go/bin/gofmt    → 25 vulnerabilities
/usr/local/go/pkg/tool/... → 25 vulnerabilities
---------------
---------------
Enter fullscreen mode Exit fullscreen mode

The current Trivy configuration uses:

exit-code: 0
Enter fullscreen mode Exit fullscreen mode

So although Trivy discovered a large number of vulnerabilities, the workflow does not stop because of those findings.

A mature pipeline could eventually evolve from:

Into:

The benefit of transitioning to this approach is that security findings become part of the release decision instead of being treated as information that developers may review later.

Without enforcement, a pipeline can report a critical vulnerability while still allowing the image to be published. This creates a gap between knowing about a problem and preventing the problem from reaching the next stage.

Conclusion

In this project, we addressed the gap described in the introduction by adding security checks directly into the CI workflow.

Instead of only asking whether the application works, the pipeline also checks whether the source code contains quality or reliability problems and whether the resulting container image contains known vulnerabilities.

The key benefit is moving security checks earlier in the delivery process, so problems can be identified before they become production problems.

The code snippets in this article focus on the important parts of the implementation. For the complete source code, you can find it through repository below.

Github Repository

Top comments (0)