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:
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
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.
API Test, verifies that the application behaves as expected before any further analysis or packaging takes place.
SonarQube Scan, analyzes the source code for potential security, reliability, and maintainability issues.
Docker Build, packages the application and its runtime environment into a Docker image.
Trivy Scan, scans the resulting container image for known vulnerabilities in the image's operating-system packages, dependencies, and other components.
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.
.github/workflows/ci.ymldefines the CI workflow and orchestrates the entire process.cmd/main.gocontains the Go API itself, including the HTTP server and API endpoints.Dockerfiledefines how the Go API is compiled and packaged into a Docker image.sonar-project.propertiescontains 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"
}
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"
}
}
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"
}
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"
}
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
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 ./...
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
The important part is defining the application source and test locations:
sonar.sources=cmd
sonar.tests=cmd
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
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
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
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 }}
Trivy Results
Trivy analyzed the container image:
secure-ci-pipeline:latest
The image was based on Debian 12.4 and Trivy reported:
1641 vulnerabilities
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
---------------
---------------
The current Trivy configuration uses:
exit-code: 0
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.








Top comments (0)