Automate SonarQube Hotspot Triage

Build a Python CLI tool to fetch and resolve SonarQube security hotspots via API.

Introduction

30 Second Summary

When your team writes cloud infrastructure as code, every misconfiguration is a potential security hole. Reviewing those findings one by one in a web dashboard is slow and repetitive.

In this project, you will build a Python command-line tool that connects to a SonarQube REST API to fetch, categorize, and bulk-resolve security hotspots in your Infrastructure as Code files automatically.

What You'll Build

Picture running a single command in your terminal and instantly seeing a prioritized breakdown of every security issue in your cloud infrastructure code. Then resolving them all in one batch without ever opening the SonarQube UI.

By the end of this project, you'll have:

  • A working CLI tool that fetches security hotspots from SonarQube and displays them grouped by rule and sorted from high to low priority.
  • A bulk-resolve workflow where you define review decisions in a JSON file and your script applies them all via the API in one command.
  • A locally running SonarQube instance scanning intentionally insecure Bicep files, giving you real security findings to practice against.
  • Secret Mission: Add OWASP Top 10 category filtering and CSV export so you can share triage reports with stakeholders who don't use SonarQube.

Do I need anything installed before starting?

This project assumes you have Docker and Python 3.10+ already installed on your Mac. Step 1 walks you through setting up everything else, including SonarQube and the Python libraries you need.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up SonarQube and Your Python Environment

Your Python triage tool needs a live SonarQube instance to talk to. SonarQube exposes a REST API for querying security hotspots, but first the server needs to be running and your Python environment needs the right libraries to make HTTP calls.

In this step, you will spin up SonarQube locally using Docker, set up a Python virtual environment with the requests library, and create the SonarQube project that your script will query later.

In this step, get ready to:
  • Start a SonarQube Community Build container and confirm it is accessible.
  • Create a Python virtual environment and install the requests library.
  • Create a SonarQube project and generate an authentication token.
Start SonarQube Community Build via Docker

SonarQube Community Build runs as a Docker container. One command pulls the image and starts the server on port 9000.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
  • Start the SonarQube container by running this command:
docker run -d --name sonarqube -e SONAR_ES_BOOTSTRAP_CHECKS_DISABLE=true -p 9000:9000 sonarqube:community

What does this command do?

  • docker run -d starts a container in the background so your terminal stays free.
  • --name sonarqube gives the container a friendly name you can reference later.
  • -e SONAR_ES_BOOTSTRAP_CHECKS_DISABLE=true disables Elasticsearch bootstrap checks that would block SonarQube from starting in a dev environment.
  • -p 9000:9000 maps port 9000 on your machine to port 9000 inside the container.
  • sonarqube:community is the free Community Build image.
  • Confirm the container is running by running:
docker ps

You should see a row with sonarqube in the NAMES column and Up in the STATUS column.

Why does SonarQube take a moment to start?

SonarQube launches an embedded Elasticsearch index and initializes its database on first boot. This typically takes 30 to 60 seconds. Wait until http://localhost:9000 loads before proceeding.

  • Open your browser and navigate to http://localhost:9000.
  • Wait up to 60 seconds for the page to load. If you see a loading screen, refresh after a few seconds.
  • Log in with username admin and password admin.
  • SonarQube will ask you to change the default password. Choose a new password and click Update.

You should now see the SonarQube dashboard. Your local instance is ready.

Page not loading at localhost:9000?

  • Run docker ps to confirm the container status is Up. If it says Exited, remove it with docker rm sonarqube and re-run the docker run command.
  • SonarQube needs about 60 seconds to fully start. If the page shows a connection error, wait and refresh.
  • Make sure no other application is using port 9000. Run lsof -i :9000 to check.

Still stuck? Help me troubleshoot SonarQube not starting in Docker

Create a Python virtual environment and install requests

Your triage script needs the requests library to make HTTP calls to the SonarQube API. A virtual environment keeps these dependencies isolated from your system Python.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir sonarqube-triage
cd sonarqube-triage
  • Create a Python virtual environment by running:
python3 -m venv venv
  • Activate the virtual environment by running:
source venv/bin/activate

Your terminal prompt now shows (venv) at the beginning, confirming the virtual environment is active.

  • Install the requests library by running:
pip install requests
  • Verify the installation by running:
python3 -c "import requests; print(requests.__version__)"

You should see a version number printed (such as 2.34.2). This confirms requests is installed and importable.

Seeing ModuleNotFoundError?

  • Make sure your terminal prompt starts with (venv). If not, run source venv/bin/activate again.
  • Confirm you ran pip install requests inside the activated environment, not before activating.

Still stuck? Help me fix my Python virtual environment setup

Create a SonarQube project and generate a token

Your script needs two things to talk to SonarQube: a project key that identifies which project to query, and an authentication token that proves you have permission to access the API.

  • In your browser, go back to the SonarQube dashboard at http://localhost:9000.
  • Select Create new project.
  • In the Project key field, enter bicep-iac-security.
  • In the Display name field, enter Bicep IaC Security Demo.
  • Click Set up.

What is a project key?

The project key is a unique identifier that the SonarQube API uses to locate your project's analysis results. Your Python script will pass this key as a query parameter when fetching security hotspots.

  • Under Provide a token, select Generate a token.
  • Give your token a name (for example, triage-tool).
  • Click Generate.
  • Copy the token value that appears and save it somewhere safe (a text file or note-taking app). You will not be able to see it again after leaving this page.
  • Click Continue.

Save your token now

SonarQube only displays the token value once. If you lose it, you will need to generate a new one. Copy it to a safe place before clicking Continue.

SonarQube will show options for running an analysis. You can ignore these for now. Your project is created and your token is ready.

What will we use the token for?

The token authenticates your Python script with the SonarQube Web API. Instead of sending a username and password with every request, you send the token. SonarQube uses HTTP Basic authentication with the token as the username and an empty password.

Before you run your next command, what do you think docker ps will show now that SonarQube has been running for a few minutes?

  • Verify everything is in place by running:
docker ps

You should see the sonarqube container still running. Your SonarQube instance is live, your Python environment is ready, and you have a project key and token saved.

Your foundation is set. Next up, you will write intentionally insecure Bicep code and scan it with SonarQube to generate the security hotspots your script will triage.

Scan Insecure Bicep Code and See Hotspots

Your Python script needs real security findings to pull from the SonarQube API. An empty project dashboard gives you nothing to fetch, parse, or update.

In this step, you will create an intentionally insecure Bicep file, scan it with the SonarScanner CLI, and confirm that SonarQube populates security hotspots for your script to work with.

In this step, get ready to:
  • Create a Bicep file packed with deliberate security misconfigurations.
  • Run the SonarScanner CLI via Docker to analyze the code.
  • Confirm that Security Hotspots appear in the SonarQube dashboard.
Create the insecure Bicep file

Bicep is an Infrastructure as Code language for deploying Azure resources. You are going to write a file with several intentional vulnerabilities: hard-coded credentials, disabled HTTPS, public network access, and weak TLS.

  • Create a new file called main.bicep in your project directory by running:
touch main.bicep
  • Confirm the file was created by running:
ls

You should see main.bicep listed in the output.

  • Open main.bicep in your text editor and paste the following parameters and hard-coded credential:
// Intentionally insecure Bicep file for SonarQube analysis training
// DO NOT use this in production!

param location string = resourceGroup().location
param storageAccountName string = 'insecurestorage${uniqueString(resourceGroup().id)}'

// Security Issue: Hard-coded connection string
var sqlConnectionString = 'Server=myserver.database.windows.net;Database=mydb;User Id=admin;Password=P@ssw0rd123!'

What does this code do?

This section declares two parameters for the deployment location and storage account name. The sqlConnectionString variable stores a plain-text password directly in the code. SonarQube flags this as a credential leak vulnerability.

  • Below the connection string variable, paste the storage account resource with disabled HTTPS and public access:
// Security Issue: Storage account without HTTPS enforcement and public access
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
  name: storageAccountName
  location: location
  sku: {
    name: 'Standard_LRS'
  }
  kind: 'StorageV2'
  properties: {
    supportsHttpsTrafficOnly: false
    allowBlobPublicAccess: true
    minimumTlsVersion: 'TLS1_0'
    networkAcls: {
      defaultAction: 'Allow'
    }
  }
}

// Security Issue: Blob service without soft delete
resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-01-01' = {
  parent: storageAccount
  name: 'default'
  properties: {
    deleteRetentionPolicy: {
      enabled: false
    }
  }
}

What security issues does this introduce?

Setting supportsHttpsTrafficOnly: false allows unencrypted HTTP traffic. Setting allowBlobPublicAccess: true exposes blobs to the internet. TLS1_0 permits outdated, vulnerable encryption.

The blob service has deleteRetentionPolicy disabled, meaning deleted data cannot be recovered. Each of these triggers a separate SonarQube hotspot.

  • Below the blob service resource, paste the SQL server, firewall rule, and outputs:
// Security Issue: SQL Server with public network access
resource sqlServer 'Microsoft.Sql/servers@2023-05-01-preview' = {
  name: 'insecure-sql-server'
  location: location
  properties: {
    administratorLogin: 'adminuser'
    administratorLoginPassword: 'P@ssw0rd123!'
    publicNetworkAccess: 'Enabled'
    minimalTlsVersion: '1.0'
  }
}

// Security Issue: Firewall rule allowing all Azure IPs
resource firewallRule 'Microsoft.Sql/servers/firewallRules@2023-05-01-preview' = {
  parent: sqlServer
  name: 'AllowAllAzureIps'
  properties: {
    startIpAddress: '0.0.0.0'
    endIpAddress: '255.255.255.255'
  }
}

output storageId string = storageAccount.id
output connectionString string = sqlConnectionString

What does the firewall rule expose?

The IP range 0.0.0.0 to 255.255.255.255 opens the SQL server to the entire internet. Combined with publicNetworkAccess: 'Enabled' and weak TLS, this creates multiple high-severity findings.

  • Save main.bicep.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.bicep with all three sections: the parameters and connection string at the top, the storage and blob resources in the middle, and the SQL server with firewall and outputs at the bottom.

ⓧ I'd like to double check the full code

Scroll up and confirm your main.bicep contains all three code chunks in this order:

  • Chunk 1: Two comment lines, two param declarations, and the sqlConnectionString variable.
  • Chunk 2: The storageAccount resource (with supportsHttpsTrafficOnly: false) and the blobService resource.
  • Chunk 3: The sqlServer resource, the firewallRule resource, and the two output lines at the bottom.
Run the SonarScanner via Docker

The SonarScanner CLI analyzes your files and uploads results to SonarQube. You will run it from a Docker image so you do not need to install Java or the scanner binary locally.

  • Create a sonar-project.properties file in the same directory as main.bicep and paste the following configuration:
sonar.projectKey=bicep-iac-security
sonar.projectName=Bicep IaC Security Demo
sonar.sources=.
sonar.inclusions=**/*.bicep

What does this configuration do?

The sonar.projectKey must match the project key you created in the SonarQube UI. The sonar.inclusions line tells the scanner to only analyze .bicep files, ignoring everything else in the directory.

  • Save sonar-project.properties.
  • Run the SonarScanner CLI via Docker by running this command (replace your-token-here with the authentication token you saved in Step 1):
docker run --rm \
  -e SONAR_HOST_URL="http://host.docker.internal:9000" \
  -e SONAR_TOKEN="your-token-here" \
  -v "$(pwd):/usr/src" \
  sonarsource/sonar-scanner-cli

What does this command do?

  • SONAR_HOST_URL uses host.docker.internal so the scanner container can reach SonarQube running on your Mac.
  • SONAR_TOKEN authenticates the scanner with your SonarQube instance.
  • -v "$(pwd):/usr/src" mounts your current project directory into the container at the default scan path.
  • --rm removes the container after the scan finishes.

The scan takes 30-60 seconds. You should see log output ending with EXECUTION SUCCESS.

Scan failed or timed out?

  • If you see an authentication error, double check that the token you pasted matches the one generated in Step 1.
  • If you see a connection refused error, confirm SonarQube is still running with docker ps and that the sonarqube container is listed.
  • If you see a project key mismatch, verify that sonar.projectKey in your properties file is exactly bicep-iac-security.

Help me troubleshoot my SonarScanner Docker scan failing.

View security hotspots in the SonarQube dashboard

The scanner uploaded its findings to SonarQube. Now confirm the hotspots exist so your Python script has data to pull.

  • Switch back to your browser at http://localhost:9000.
  • Click on your Bicep IaC Security Demo project.
  • Click the Security Hotspots tab in the project dashboard.

Before you look at the results, how many security issues do you think SonarQube found in your Bicep file?

You should see multiple hotspots listed with a status of To Review. Each one corresponds to a vulnerability you intentionally introduced: the hard-coded credential, disabled HTTPS, public blob access, weak TLS, missing soft delete, public SQL access, and the wide-open firewall rule.

Your SonarQube instance now has real security findings ready to be queried. Next up, you will write the Python script that pulls these hotspots from the API and prints them in your terminal.

Fetch Hotspots from the SonarQube API

In the previous step you scanned your Bicep files and confirmed that SonarQube found security hotspots. Now you need a way to pull those findings out of the UI and into your own code.

Manually clicking through each hotspot in the SonarQube dashboard does not scale. The REST API lets you fetch every hotspot programmatically so you can filter, sort, and act on them from a Python script.

In this step, get ready to:
  • Write a Python script that authenticates with the SonarQube Web API and fetches hotspots.
  • Print each hotspot's key, rule, message, file location, and vulnerability probability.
  • Add error handling for authentication failures and empty responses.
Create the script skeleton and connect to the API

Your script needs three things before it can talk to SonarQube: the server URL, an authentication token, and the project key to query. You will wrap these into a configuration block and a helper function.

  • Create a new file called hotspot_triage.py in your project folder by running:
touch hotspot_triage.py
  • Confirm the file exists by running:
ls

You should see hotspot_triage.py alongside main.bicep and sonar-project.properties in the output.

  • Open hotspot_triage.py in your editor and paste the following configuration and authentication code:
"""SonarQube Security Hotspot Triage Tool for Bicep IaC projects."""

import sys
import json
import requests

# Configuration
SONAR_URL = "http://localhost:9000"
SONAR_TOKEN = "your-token-here"
PROJECT_KEY = "bicep-iac-security"


def get_auth():
    """Return auth tuple for SonarQube API (token as username, empty password)."""
    return (SONAR_TOKEN, "")

What does this code do?

SONAR_URL points at your local SonarQube instance. SONAR_TOKEN holds the authentication token you generated in Step 1. PROJECT_KEY matches the project key you created in the SonarQube UI.

The get_auth() function returns a tuple the requests library uses for HTTP Basic authentication. SonarQube accepts the token as the username with an empty password.

  • Replace your-token-here in the SONAR_TOKEN line with the actual token you saved from the previous step.
  • Below get_auth(), add the function that calls the hotspots search API and a minimal main() to test the connection:
def fetch_hotspots():
    """Fetch all security hotspots for the project from SonarQube API."""
    url = f"{SONAR_URL}/api/hotspots/search"
    params = {"project": PROJECT_KEY, "ps": 100}

    response = requests.get(url, params=params, auth=get_auth())
    data = response.json()
    hotspots = data.get("hotspots", [])
    return hotspots


def main():
    """Main entry point - dispatch based on command."""
    if len(sys.argv) < 2:
        print("Usage: python3 hotspot_triage.py <command>")
        print("Commands: fetch, report, resolve")
        sys.exit(1)

    command = sys.argv[1]

    if command == "fetch":
        hotspots = fetch_hotspots()
        print(f"Found {len(hotspots)} hotspot(s)")
    else:
        print(f"Unknown command: {command}")
        sys.exit(1)


if __name__ == "__main__":
    main()

What does this code do?

fetch_hotspots() sends a GET request to /api/hotspots/search with your project key and a page size of 100. It parses the JSON response and returns the list of hotspots.

main() reads the command from the terminal arguments. For now it just prints a count so you can verify the API connection works.

  • Save hotspot_triage.py.
  • Test the API connection by running:
python3 hotspot_triage.py fetch

You should see output like Found 5 hotspot(s) (the exact number depends on how many issues SonarQube detected in your Bicep file).

Seeing a traceback or zero hotspots?

  • If you see a ConnectionError, make sure SonarQube is still running. Run docker ps and confirm the sonarqube container is listed.
  • If the count is 0, confirm your PROJECT_KEY matches exactly. Open http://localhost:9000 and check the project key in the project settings.
  • If you get a JSON decode error, your token may be wrong. Double-check the SONAR_TOKEN value.

Help me debug my SonarQube API connection issue.

Print each hotspot with its details

A raw count proves the connection works, but you need to see the actual hotspot data to triage it. Now you will add a display function that formats each hotspot's key fields.

  • In hotspot_triage.py, add this function above main():
def print_hotspots(hotspots):
    """Print a simple list of all hotspots."""
    print(f"\n{'='*60}")
    print(f"  Security Hotspots for: {PROJECT_KEY}")
    print(f"  Total found: {len(hotspots)}")
    print(f"{'='*60}\n")

    for i, hotspot in enumerate(hotspots, 1):
        print(f"  {i}. [{hotspot.get('vulnerabilityProbability', 'N/A')}] "
              f"{hotspot.get('message', 'No message')}")
        print(f"     Key: {hotspot.get('key', 'N/A')}")
        print(f"     Rule: {hotspot.get('ruleKey', 'N/A')}")
        print(f"     File: {hotspot.get('component', 'N/A')}:"
              f"{hotspot.get('line', '?')}")
        print(f"     Status: {hotspot.get('status', 'N/A')}")
        print()

What does this code do?

print_hotspots() loops through the hotspot list and displays each one with its vulnerability probability, message, unique key, rule identifier, source file location, and current status.

The vulnerabilityProbability field tells you how likely this code path is to be exploited (HIGH, MEDIUM, or LOW). The ruleKey identifies which specific security rule was triggered.

Now update main() to call print_hotspots instead of the simple count.

  • In main(), find this line:
        print(f"Found {len(hotspots)} hotspot(s)")
  • Replace it with:
        print_hotspots(hotspots)
  • Also update the else branch to include the full usage message. Find:
        print(f"Unknown command: {command}")
        sys.exit(1)
  • Replace it with:
        print(f"Unknown command: {command}")
        print("Commands: fetch, report, resolve")
        sys.exit(1)
  • Save hotspot_triage.py.
  • Run the script again:
python3 hotspot_triage.py fetch

You should now see a formatted table listing each security hotspot with its priority, message, rule, file path, line number, and status.

Output still shows just a count?

  • Make sure you saved the file after editing. Unsaved changes are not picked up by Python.
  • Confirm print_hotspots is defined above main() in the file. Python reads top to bottom, so the function must exist before it is called.

Help me fix my print_hotspots function not being called.

Add error handling for authentication failures and empty responses

Right now the script assumes every API call succeeds. If your token expires or the project has no hotspots, the script will crash with an unhelpful traceback. Adding explicit checks makes failures clear and actionable.

  • In hotspot_triage.py, find your current fetch_hotspots() function. It looks like this:
def fetch_hotspots():
    """Fetch all security hotspots for the project from SonarQube API."""
    url = f"{SONAR_URL}/api/hotspots/search"
    params = {"project": PROJECT_KEY, "ps": 100}

    response = requests.get(url, params=params, auth=get_auth())
    data = response.json()
    hotspots = data.get("hotspots", [])
    return hotspots
  • Replace the entire fetch_hotspots() function with this version that includes error handling:
def fetch_hotspots():
    """Fetch all security hotspots for the project from SonarQube API."""
    url = f"{SONAR_URL}/api/hotspots/search"
    params = {"project": PROJECT_KEY, "ps": 100}

    response = requests.get(url, params=params, auth=get_auth())

    if response.status_code == 401:
        print("Error: Authentication failed. Check your SONAR_TOKEN.")
        sys.exit(1)

    if response.status_code != 200:
        print(f"Error: API returned status {response.status_code}")
        print(response.text)
        sys.exit(1)

    data = response.json()
    hotspots = data.get("hotspots", [])

    if not hotspots:
        print("No security hotspots found for this project.")
        print("Make sure the project has been scanned.")
        sys.exit(0)

    return hotspots

What does the error handling do?

  • A 401 status means the token is invalid or expired. The script prints a clear message and exits.
  • Any other non-200 status prints the raw error body so you can diagnose unexpected failures.
  • An empty hotspots list means the project exists but has no findings. The script reminds you to run a scan first.
  • Save hotspot_triage.py.

Before you run the script again, what do you expect to see? The same formatted list, or something different?

  • Run the fetch command one more time to confirm everything still works:
python3 hotspot_triage.py fetch

You should see the same formatted hotspot list as before. The error handling only activates when something goes wrong.

Seeing a SyntaxError?

  • Check that you replaced the entire function cleanly. Make sure there are no leftover lines from the old version mixed in with the new one.
  • Confirm the indentation is consistent. Every line inside fetch_hotspots() should be indented with 4 spaces.

Help me fix a SyntaxError in my fetch_hotspots function.

✔️ Awesome, I've got everything!

Great. Double check you have saved hotspot_triage.py and your terminal shows the formatted hotspot list when you run python3 hotspot_triage.py fetch.

ⓧ I'd like to double check the full code

Here is the complete hotspot_triage.py at the end of this step. Compare it with yours:

"""SonarQube Security Hotspot Triage Tool for Bicep IaC projects."""

import sys
import json
import requests

# Configuration
SONAR_URL = "http://localhost:9000"
SONAR_TOKEN = "your-token-here"
PROJECT_KEY = "bicep-iac-security"


def get_auth():
    """Return auth tuple for SonarQube API (token as username, empty password)."""
    return (SONAR_TOKEN, "")

Then fetch_hotspots():

def fetch_hotspots():
    """Fetch all security hotspots for the project from SonarQube API."""
    url = f"{SONAR_URL}/api/hotspots/search"
    params = {"project": PROJECT_KEY, "ps": 100}

    response = requests.get(url, params=params, auth=get_auth())

    if response.status_code == 401:
        print("Error: Authentication failed. Check your SONAR_TOKEN.")
        sys.exit(1)

    if response.status_code != 200:
        print(f"Error: API returned status {response.status_code}")
        print(response.text)
        sys.exit(1)

    data = response.json()
    hotspots = data.get("hotspots", [])

    if not hotspots:
        print("No security hotspots found for this project.")
        print("Make sure the project has been scanned.")
        sys.exit(0)

    return hotspots

Then print_hotspots():

def print_hotspots(hotspots):
    """Print a simple list of all hotspots."""
    print(f"\n{'='*60}")
    print(f"  Security Hotspots for: {PROJECT_KEY}")
    print(f"  Total found: {len(hotspots)}")
    print(f"{'='*60}\n")

    for i, hotspot in enumerate(hotspots, 1):
        print(f"  {i}. [{hotspot.get('vulnerabilityProbability', 'N/A')}] "
              f"{hotspot.get('message', 'No message')}")
        print(f"     Key: {hotspot.get('key', 'N/A')}")
        print(f"     Rule: {hotspot.get('ruleKey', 'N/A')}")
        print(f"     File: {hotspot.get('component', 'N/A')}:"
              f"{hotspot.get('line', '?')}")
        print(f"     Status: {hotspot.get('status', 'N/A')}")
        print()

Then main() and the entry point:

def main():
    """Main entry point - dispatch based on command."""
    if len(sys.argv) < 2:
        print("Usage: python3 hotspot_triage.py <command>")
        print("Commands: fetch, report, resolve")
        sys.exit(1)

    command = sys.argv[1]

    if command == "fetch":
        hotspots = fetch_hotspots()
        print_hotspots(hotspots)
    else:
        print(f"Unknown command: {command}")
        print("Commands: fetch, report, resolve")
        sys.exit(1)


if __name__ == "__main__":
    main()

Your script can now reach into SonarQube and pull out every security hotspot programmatically. Next up, you will turn that flat list into a categorized triage report that groups findings by rule and sorts them by priority.

Generate a Categorized Triage Report

Your fetch command works. You can pull every hotspot from the SonarQube API and print it as a flat list.

A flat list is hard to act on when there are dozens of findings. Grouping hotspots by security rule and sorting by priority tells you which issues to address first and which patterns repeat across your codebase.

In this step, get ready to:
  • Add a report command that groups hotspots by rule and sorts them by priority.
  • Display a summary with total count, per-priority counts, and unique rules.
  • Print the detailed grouped report with file paths and line numbers.
Add the grouping and sorting logic

The REST API returns hotspots in whatever order the server stored them. To make the data actionable, you need to group findings by their rule key and sort those groups so the most critical appear first.

  • In hotspot_triage.py, find the print_hotspots() function. Add the following new function directly below it:
def generate_report(hotspots):
    """Generate a categorized triage report grouped by rule and priority."""
    # Group hotspots by rule key
    by_rule = {}
    for hotspot in hotspots:
        rule = hotspot.get("ruleKey", "unknown")
        if rule not in by_rule:
            by_rule[rule] = []
        by_rule[rule].append(hotspot)

    # Sort rules by priority (HIGH first)
    priority_order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}

    def get_priority(rule_key):
        items = by_rule[rule_key]
        prob = items[0].get("vulnerabilityProbability", "LOW")
        return priority_order.get(prob, 3)

    sorted_rules = sorted(by_rule.keys(), key=get_priority)

What does this code do?

This builds a dictionary where each key is a SonarQube rule (like S6437 for hard-coded credentials) and the value is every hotspot triggered by that rule.

The nested get_priority() function looks at the first hotspot in each group to determine its priority level. Sorting by that value puts HIGH-priority rules at the top of the report.

Display the summary and wire up the report command

Now add the code that counts hotspots by priority level and prints a summary header. This goes directly below the sorted_rules line you just wrote, still inside the generate_report function.

  • Add the priority counting and summary display by pasting this code below sorted_rules = sorted(by_rule.keys(), key=get_priority):
    # Count by priority
    priority_counts = {"HIGH": 0, "MEDIUM": 0, "LOW": 0}
    for hotspot in hotspots:
        prob = hotspot.get("vulnerabilityProbability", "LOW")
        priority_counts[prob] = priority_counts.get(prob, 0) + 1

    # Print summary
    print(f"\n{'='*60}")
    print(f"  TRIAGE REPORT: {PROJECT_KEY}")
    print(f"{'='*60}")
    print(f"\n  Summary:")
    print(f"    Total hotspots: {len(hotspots)}")
    print(f"    HIGH priority:  {priority_counts.get('HIGH', 0)}")
    print(f"    MEDIUM priority: {priority_counts.get('MEDIUM', 0)}")
    print(f"    LOW priority:   {priority_counts.get('LOW', 0)}")
    print(f"    Unique rules:   {len(by_rule)}")

What does this code do?

The counting loop tallies how many hotspots fall into each priority bucket (HIGH, MEDIUM, LOW). The print statements then render a concise summary so you can immediately see the risk distribution across your Bicep code.

The function is ready to produce output. Now you need to wire it into the main() dispatch so you can call it from the terminal.

  • Scroll down to the main() function. Find this block inside it:
    if command == "fetch":
        hotspots = fetch_hotspots()
        print_hotspots(hotspots)
    else:
        print(f"Unknown command: {command}")
        print("Commands: fetch, report, resolve")
        sys.exit(1)
  • Replace that entire block with the following, which adds the report command:
    if command == "fetch":
        hotspots = fetch_hotspots()
        print_hotspots(hotspots)
    elif command == "report":
        hotspots = fetch_hotspots()
        generate_report(hotspots)
    else:
        print(f"Unknown command: {command}")
        print("Commands: fetch, report, resolve")
        sys.exit(1)
  • Save hotspot_triage.py.
  • Test the report command by running:
python3 hotspot_triage.py report

You should see a summary header showing total hotspot count, HIGH/MEDIUM/LOW counts, and the number of unique rules. The report ends abruptly after the summary because you have not added the detailed section yet.

Seeing a syntax error or indentation error?

  • Make sure the summary code is indented one level inside generate_report() (4 spaces). It must be at the same level as the sorted_rules line above it.
  • Check that the elif command == "report": line is at the same indent level as the if command == "fetch": line above it.
  • help me debug a Python indentation error in my generate_report function
Print the detailed grouped report

The summary tells you the overall risk distribution. Now add the detailed breakdown that shows each rule group with its affected files and line numbers.

  • In hotspot_triage.py, find the last line of generate_report() (the print(f" Unique rules: {len(by_rule)}") line). Add the following code directly below it, still inside the function:
    # Print grouped report
    print(f"\n  {'─'*56}")
    print(f"  Hotspots by Rule (sorted by priority):")
    print(f"  {'─'*56}\n")

    for rule in sorted_rules:
        items = by_rule[rule]
        prob = items[0].get("vulnerabilityProbability", "LOW")
        print(f"  [{prob}] {rule} ({len(items)} hotspot(s))")
        for item in items:
            component = item.get("component", "")
            filename = component.split(":")[-1] if ":" in component else component
            line = item.get("line", "?")
            print(f"      - {filename}:{line} - {item.get('message', '')}")
        print()

What does this code do?

This iterates through the sorted rules and prints each group with its priority label, rule key, and count. Under each group heading, it lists every affected file with its line number and the hotspot message.

The component.split(":")[-1] extracts just the filename from the full component path that SonarQube returns (which looks like bicep-iac-security:main.bicep).

  • Save hotspot_triage.py.

Before you run this, what do you expect the output to look like now compared to the summary-only run you did a moment ago?

  • Run the report command again:
python3 hotspot_triage.py report

You should now see the full report: a summary section at the top followed by hotspots grouped by rule key, sorted from HIGH to LOW priority, with file paths and line numbers under each group.

Report still ends after the summary?

  • Confirm the grouped report code is indented at the same level as the summary print statements (4 spaces inside the function). If it is at the module level, Python treats it as a separate block that never runs.
  • Make sure there is no early return statement between the summary section and the grouped report section.
  • help me debug why my grouped report section is not printing

✔️ Awesome, I've got everything!

Great. Double check you have saved hotspot_triage.py.

ⓧ I'd like to double check the full code

Scroll up and confirm you have these sections in order inside hotspot_triage.py:

  • The imports and configuration block at the top.
  • get_auth() function.
  • fetch_hotspots() function.
  • print_hotspots() function.
  • generate_report() function with grouping, sorting, summary, and grouped report sections.
  • main() function with fetch and report commands handled.
  • The if __name__ == "__main__": block at the bottom.

You now have a triage report that surfaces the highest-risk patterns first. Next up, you will use the SonarQube API to bulk-update hotspot statuses so you can resolve them without clicking through the UI one by one.

Bulk-Update Hotspot Statuses via the API

You have a categorized report of every security hotspot in your Bicep code. Now comes the tedious part: resolving them.

Clicking through each hotspot in the SonarQube UI works for one or two findings, but your scan produced several. In this step, you will define review decisions in a JSON file and let your Python script apply them all at once via the REST API.

In this step, get ready to:
  • Create a decisions.json file mapping hotspot keys to review outcomes.
  • Write a resolve command that bulk-updates hotspot statuses via POST /api/hotspots/change_status.
  • Discover the status/resolution validation rule the API enforces and fix your decisions file accordingly.
Create a decisions file with your hotspot keys

Before you can bulk-update hotspots, you need to know their keys. Each hotspot has a unique key that the API uses to identify it.

  • Grab a hotspot key from the fetch output by running this command:
python3 hotspot_triage.py fetch

You will see the list of hotspots printed with their keys. Copy one of the Key: values from the output. It will be a long alphanumeric string.

  • Create a file called decisions.json in your project folder with the following content:
[
  {
    "hotspot": "[[HOTSPOT_KEY=\"your hotspot key from the fetch output\"]]",
    "status": "REVIEWED",
    "comment": "Reviewed: this is a training project with intentional misconfigurations"
  }
]

What does this file do?

Each object in the array represents one review decision. The hotspot field identifies which hotspot to update. The status field tells the API what state to move it to.

Notice that this version only has status and comment. There is no resolution field. You will see why that matters shortly.

  • Save decisions.json.
  • Confirm the file exists by running:
ls decisions.json

You should see decisions.json listed in the output.

Write the resolve command

The resolve command reads your decisions file and calls the SonarQube change_status endpoint for each entry. It tracks successes and failures so you know exactly what happened.

  • In hotspot_triage.py, find the generate_report function. Add the following resolve_hotspots function below it (before the main function):
def resolve_hotspots():
    """Bulk-update hotspot statuses from decisions.json."""
    try:
        with open("decisions.json", "r") as f:
            decisions = json.load(f)
    except FileNotFoundError:
        print("Error: decisions.json not found.")
        print("Create a decisions.json file with your review decisions.")
        sys.exit(1)

    url = f"{SONAR_URL}/api/hotspots/change_status"
    success_count = 0
    error_count = 0

    print(f"\n  Applying {len(decisions)} review decisions...")
    print()

What does this code do?

This first half of the function opens decisions.json and parses it into a Python list. If the file does not exist, it exits with a helpful error message.

It also sets up the API endpoint URL and two counters to track how many updates succeed or fail.

  • Add the loop that processes each decision directly below the code you just added (still inside the resolve_hotspots function):
    for decision in decisions:
        hotspot_key = decision.get("hotspot")
        status = decision.get("status")
        resolution = decision.get("resolution")
        comment = decision.get("comment", "")

        payload = {
            "hotspot": hotspot_key,
            "status": status,
        }
        if resolution:
            payload["resolution"] = resolution
        if comment:
            payload["comment"] = comment

        response = requests.post(url, data=payload, auth=get_auth())

        if response.status_code == 200 or response.status_code == 204:
            success_count += 1
            print(f"    Updated: {hotspot_key} -> {status}/{resolution}")
        else:
            error_count += 1
            print(f"    FAILED:  {hotspot_key} - {response.status_code}: "
                  f"{response.text[:100]}")

    print(f"\n  Done! Updated {success_count} hotspots. "
          f"Errors: {error_count}.")

What does this code do?

For each decision, the loop extracts the hotspot key, status, resolution, and comment. It builds a payload dictionary and only includes resolution and comment if they are present.

It then sends a POST request to /api/hotspots/change_status and checks whether the response indicates success (200 or 204) or failure.

Now you need to wire this function into the main function so the resolve command actually calls it.

  • In the main function, find this block:
    elif command == "report":
        hotspots = fetch_hotspots()
        generate_report(hotspots)
    else:
        print(f"Unknown command: {command}")
        print("Commands: fetch, report, resolve")
        sys.exit(1)
  • Add an elif branch for resolve between the report branch and the else, so the section now looks like this:
    elif command == "report":
        hotspots = fetch_hotspots()
        generate_report(hotspots)
    elif command == "resolve":
        resolve_hotspots()
    else:
        print(f"Unknown command: {command}")
        print("Commands: fetch, report, resolve")
        sys.exit(1)
  • Save hotspot_triage.py.

Seeing a syntax error when you save?

Make sure the resolve_hotspots function is defined at the same indentation level as generate_report and fetch_hotspots. The elif command == "resolve" line must be indented to match the other elif branches inside main.

help me fix an indentation error in my resolve_hotspots function

Run the resolve command and discover the validation rule

Your script is ready and your decisions.json maps a hotspot key to status REVIEWED. Time to see what happens.

Before you run this, do you think the API will accept the request and update the hotspot? Or will something go wrong?

  • Run the resolve command:
python3 hotspot_triage.py resolve

You will see a FAILED line with an error status code. The API rejected your request.

Why did it fail?

The SonarQube change_status API enforces a validation rule: when you set status to REVIEWED, you must also provide a resolution field. Valid values are SAFE or FIXED.

Your decisions.json only specified status without a resolution. The API cannot determine whether you consider the hotspot safe or fixed, so it rejects the call.

Now that you understand the validation rule, fix the decisions file.

  • Open decisions.json and find this line:
    "status": "REVIEWED",
    "comment": "Reviewed: this is a training project with intentional misconfigurations"
  • Add a resolution field between status and comment, so the file now looks like this:
[
  {
    "hotspot": "[[HOTSPOT_KEY=\"your hotspot key from the fetch output\"]]",
    "status": "REVIEWED",
    "resolution": "SAFE",
    "comment": "Reviewed: this is a training project with intentional misconfigurations"
  }
]

What does the resolution field mean?

Setting resolution to SAFE tells SonarQube that you reviewed the hotspot and determined no code change is needed. Use FIXED when you have actually applied a code fix. Since this is a training project with intentional vulnerabilities, SAFE is the correct choice.

  • Save decisions.json.
  • Run the resolve command again:
python3 hotspot_triage.py resolve

This time you should see output like:

  Applying 1 review decisions...

    Updated: <your-key> -> REVIEWED/SAFE

  Done! Updated 1 hotspots. Errors: 0.
  • Open the SonarQube UI at http://localhost:9000 and navigate to your project's **Security Hotspots** tab.
  • Confirm that the hotspot you resolved now shows **Safe** status instead of **To Review**.

Still seeing the hotspot as To Review?

Make sure the hotspot key in decisions.json exactly matches a key from your fetch output. Keys are case-sensitive alphanumeric strings. An extra space or missing character causes the API to reject silently.

Also confirm your SONAR_TOKEN in hotspot_triage.py has not expired. If the token was revoked, generate a new one from **My Account** > **Security** in the SonarQube UI.

help me debug why my resolve command is not updating hotspots in SonarQube

✔️ Awesome, I've got everything!

Great. Double check you have saved both hotspot_triage.py and decisions.json.

ⓧ I'd like to double check the full code

The full hotspot_triage.py is over 30 lines, so confirm each section is present in order:

  • Imports (sys, json, requests) and configuration block at the top.
  • get_auth() function.
  • fetch_hotspots() function.
  • print_hotspots() function.
  • generate_report() function.
  • resolve_hotspots() function (the new one from this step).
  • main() with fetch, report, and resolve branches.

Your decisions.json should look like this:

[
  {
    "hotspot": "replace-with-actual-hotspot-key",
    "status": "REVIEWED",
    "resolution": "SAFE",
    "comment": "Reviewed: this is a training project with intentional misconfigurations"
  }
]

Your triage tool is fully operational. You can fetch, report on, and resolve security hotspots without ever clicking through the SonarQube UI. Next up, extend it with OWASP filtering and CSV export in the Secret Mission.

Secret mission

Add OWASP Filtering and CSV Export

Real-world security teams don't review every hotspot at once. They filter by compliance framework (like OWASP Top 10) and share triage reports with stakeholders who never log into SonarQube. Can you extend your tool with OWASP category filtering and CSV export?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally using Docker and Python, so there are no ongoing cloud costs.

Resources you used:

  • Docker container sonarqube running on port 9000.
  • Docker volumes sonarqube_data, sonarqube_extensions, and sonarqube_logs.
  • Docker images sonarqube:community and sonarsource/sonar-scanner-cli cached locally.
  • Project folder on your Desktop containing hotspot_triage.py, main.bicep, decisions.json, sonar-project.properties, report.csv, and the Python virtual environment.

Keep everything running

No action needed. Choose this if you want to keep experimenting with the SonarQube API or scanning additional Bicep files for practice.

  • Your SonarQube container continues running on port 9000.
  • There are no paid services involved, so nothing will incur charges.
  • Docker images stay cached locally, using a few hundred MB of disk space.

Pause - I'll come back to this later

Stop the SonarQube container to free up memory. Your data persists in the Docker volumes so you can pick up where you left off.

  • Stop the container by running:
docker stop sonarqube
  • When you are ready to resume, restart the container by running:
docker start sonarqube

SonarQube will be accessible again at http://localhost:9000 after a minute or two of startup time.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes the container, its data, the Docker images, and your local project files.

  • Stop and remove the SonarQube container by running:
docker rm -f sonarqube
  • Remove the Docker volumes by running:
docker volume rm sonarqube_data sonarqube_extensions sonarqube_logs
  • Remove the Docker images by running:
docker rmi sonarqube:community sonarsource/sonar-scanner-cli
  • Deactivate your Python virtual environment (if still active) by running:
deactivate
  • Delete your project folder from the Desktop.

macOS

  • Remove the project folder by running:
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your project folder name"]]

Windows

  • Remove the project folder by running:
cd ~\Desktop
rmdir /s /q [[PROJECT_FOLDER="your project folder name"]]
  • Confirm everything is gone by running:
docker ps -a | grep sonarqube

You should see no output, confirming the container has been removed.

Nice Work!

Nice Work!

Nice work. You just built a Python CLI tool that automates security hotspot triage for Infrastructure as Code projects using the SonarQube Web API.

You've learned how to:

  • Set up a local SonarQube instance via Docker and scan Bicep Infrastructure as Code files for security issues.
  • Built a Python script that authenticates with the SonarQube REST API, fetches security hotspots programmatically, and generates categorized triage reports grouped by rule and sorted by priority.
  • Automated bulk status updates via the hotspots API, learning the status/resolution validation rules by hitting the designed obstacle and fixing it.
  • Secret Mission: Added OWASP Top 10 category filtering and CSV export for stakeholder-friendly reporting.

Ready to quiz yourself?