Skip to main content
Integrate Binarly security scanning into your GitHub Actions workflow.

Workflow File

Create .github/workflows/binarly-scan.yml:
name: Binarly Firmware Scan

on:
  push:
    paths:
      - 'firmware/**'
  workflow_dispatch:

env:
  # Replace {slug} with your organization's tenant identifier
  BINARLY_AUTH_URL: https://auth-{slug}.binarly.cloud/realms/BinarlyRealm/protocol/openid-connect/token
  BINARLY_API_URL: https://dashboard-{slug}.binarly.cloud

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build Firmware
        run: make firmware  # Your build command

      - name: Authenticate with Binarly
        id: auth
        run: |
          TOKEN=$(curl -s --fail-with-body \
            -H "Content-Type: application/x-www-form-urlencoded" \
            --data-urlencode "grant_type=client_credentials" \
            --data-urlencode "client_id=${{ secrets.BINARLY_CLIENT_ID }}" \
            --data-urlencode "client_secret=${{ secrets.BINARLY_CLIENT_SECRET }}" \
            "$BINARLY_AUTH_URL" | jq -r '.access_token')
          echo "token=$TOKEN" >> $GITHUB_OUTPUT

      - name: Upload Firmware
        id: upload
        run: |
          # Generate pre-signed URL
          UPLOAD_RES=$(curl -s -H "Authorization: Bearer ${{ steps.auth.outputs.token }}" \
            "$BINARLY_API_URL/api/v4/products/${{ secrets.BINARLY_PRODUCT_ID }}/tempFiles:generateUploadUrl")
          UPLOAD_URL=$(echo $UPLOAD_RES | jq -r '.uploadUrl')
          TEMP_ID=$(echo $UPLOAD_RES | jq -r '.id')

          # Upload binary
          curl -s -X PUT --data-binary @./build/firmware.bin "$UPLOAD_URL"

          # Finalize
          IMAGE_RES=$(curl -s -H "Authorization: Bearer ${{ steps.auth.outputs.token }}" \
            -F "tempFileId=$TEMP_ID" \
            -F "imageName=GitHub Build ${{ github.run_number }}" \
            -F "version=${{ github.sha }}" \
            -F "file=@./build/firmware.bin" \
            "$BINARLY_API_URL/api/v4/products/${{ secrets.BINARLY_PRODUCT_ID }}/images:upload")
          IMAGE_ID=$(echo $IMAGE_RES | jq -r '.id')
          echo "image_id=$IMAGE_ID" >> $GITHUB_OUTPUT

      - name: Wait for Scan
        run: |
          STATUS="running"
          while [ "$STATUS" != "completed" ]; do
            sleep 30
            SCAN_RES=$(curl -s -H "Authorization: Bearer ${{ steps.auth.outputs.token }}" \
              "$BINARLY_API_URL/api/v4/products/${{ secrets.BINARLY_PRODUCT_ID }}/images/${{ steps.upload.outputs.image_id }}/scans?status=true")
            STATUS=$(echo $SCAN_RES | jq -r '.scans[0].latestScanState.type')
            echo "Scan status: $STATUS"
            [ "$STATUS" == "failed" ] && exit 1
          done
          echo "Scan completed successfully"

      - name: Verify Results
        id: verify
        env:
          FAIL_ON_STATUS: "new,inProgress"  # Configurable: new,inProgress,rejected,remediated
        run: |
          echo "Binarly Security Verification"
          echo "Failing on statuses: $FAIL_ON_STATUS"

          TOKEN="${{ steps.auth.outputs.token }}"
          BINARLY_PRODUCT_ID="${{ secrets.BINARLY_PRODUCT_ID }}"
          IMAGE_ID="${{ steps.upload.outputs.image_id }}"

          # Get all images for the product
          IMAGES_RES=$(curl -s -H "Authorization: Bearer $TOKEN" \
            "$BINARLY_API_URL/api/v4/products/$BINARLY_PRODUCT_ID/images")
          IMAGE_COUNT=$(echo "$IMAGES_RES" | jq '.images | length')
          echo "Product has ${IMAGE_COUNT} image(s)"

          # If multiple images exist, compare latest with previous
          if [ "$IMAGE_COUNT" -gt 1 ]; then
            # Sort images by createTime descending
            LATEST_ID=$(echo "$IMAGES_RES" | jq -r '[.images | sort_by(.createTime) | reverse][0][0].id')
            PREVIOUS_ID=$(echo "$IMAGES_RES" | jq -r '[.images | sort_by(.createTime) | reverse][0][1].id')

            echo "Comparing: $PREVIOUS_ID → $LATEST_ID"

            # Count RESOLVED findings
            RESOLVED=$(curl -s -X POST \
              -H "Authorization: Bearer $TOKEN" \
              -H "Content-Type: application/json" \
              -d '{"filters":[
                {"field":"compareLeftImageId","value":"'"$PREVIOUS_ID"'","comparator":"equals"},
                {"field":"compareRightImageId","value":"'"$LATEST_ID"'","comparator":"equals"},
                {"field":"compareSide","value":"left","comparator":"equals"}
              ]}' \
              "$BINARLY_API_URL/api/v4/grids/findings:gridList" | jq '.total // 0')

            # Count UNCHANGED findings
            UNCHANGED=$(curl -s -X POST \
              -H "Authorization: Bearer $TOKEN" \
              -H "Content-Type: application/json" \
              -d '{"filters":[
                {"field":"compareLeftImageId","value":"'"$PREVIOUS_ID"'","comparator":"equals"},
                {"field":"compareRightImageId","value":"'"$LATEST_ID"'","comparator":"equals"},
                {"field":"compareSide","value":"both","comparator":"equals"}
              ]}' \
              "$BINARLY_API_URL/api/v4/grids/findings:gridList" | jq '.total // 0')

            # Count NEW findings
            NEW_ISSUES=$(curl -s -X POST \
              -H "Authorization: Bearer $TOKEN" \
              -H "Content-Type: application/json" \
              -d '{"filters":[
                {"field":"compareLeftImageId","value":"'"$PREVIOUS_ID"'","comparator":"equals"},
                {"field":"compareRightImageId","value":"'"$LATEST_ID"'","comparator":"equals"},
                {"field":"compareSide","value":"right","comparator":"equals"}
              ]}' \
              "$BINARLY_API_URL/api/v4/grids/findings:gridList" | jq '.total // 0')

            echo "Resolved: $RESOLVED | Unchanged: $UNCHANGED | New: $NEW_ISSUES"
          fi

          # Check for findings matching configured statuses (using 'in' comparator with array)
          STATUSES_JSON=$(echo "$FAIL_ON_STATUS" | tr ',' '\n' | jq -R . | jq -s .)

          # Use LATEST_ID if available (from comparison), otherwise use IMAGE_ID from upload step
          CHECK_IMAGE_ID="${LATEST_ID:-$IMAGE_ID}"

          TOTAL_ACTIONABLE=$(curl -s -X POST \
            -H "Authorization: Bearer $TOKEN" \
            -H "Content-Type: application/json" \
            -d '{"filters":[
              {"field":"imageId","value":"'"$CHECK_IMAGE_ID"'","comparator":"equals"},
              {"field":"issueStatus","value":'"$STATUSES_JSON"',"comparator":"in"}
            ]}' \
            "$BINARLY_API_URL/api/v4/grids/findings:gridList" | jq '.total // 0')

          echo "   Statuses checked: $FAIL_ON_STATUS"

          echo "Total actionable findings: $TOTAL_ACTIONABLE"

          if [ "$TOTAL_ACTIONABLE" -gt 0 ]; then
            echo "BUILD FAILED: $TOTAL_ACTIONABLE actionable finding(s)"
            exit 1
          fi
          echo "BUILD PASSED: No actionable findings"

Required Secrets

Configure these in Settings → Secrets and variables → Actions:
SecretDescription
BINARLY_CLIENT_IDKeycloak API Client ID
BINARLY_CLIENT_SECRETKeycloak API Client Secret
BINARLY_PRODUCT_IDTarget product ID

Triggering the Workflow

The workflow runs automatically when:
  • Files in firmware/ are pushed
  • Manually triggered via workflow_dispatch
Modify the on: section to match your project structure. For detailed explanation of the verification logic, see Verify Results.