diff --git a/.github/workflows/improved workflow b/.github/workflows/improved workflow deleted file mode 100644 index 64533c8..0000000 --- a/.github/workflows/improved workflow +++ /dev/null @@ -1,119 +0,0 @@ -## SETUP -## Run before activating pipeline -# nightvision app create javaspringvulny-api -# nightvision target create javaspringvulny-api https://127.0.0.1:9000 --type api -# nightvision auth playwright create javaspringvulny-api https://127.0.0.1:9000 - -## Optional steps can be performed locally or in the pipeline -# nightvision swagger extract . -t javaspringvulny-api --lang java -# nightvision scan javaspringvulny-api --auth javaspringvulny-api - -name: NightVision Security Scan - -on: - push: - branches: - - main - pull_request: - branches: - - main - workflow_dispatch: - -env: - NIGHTVISION_TOKEN: ${{ secrets.NIGHTVISION_TOKEN }} - NIGHTVISION_TARGET: javaspringvulny-api - NIGHTVISION_AUTH: javaspringvulny-api - -jobs: - security-scan: - permissions: - security-events: write - contents: read - runs-on: ubuntu-latest - - steps: - - name: (1) Clone Code - uses: actions/checkout@v4 - - - name: (2) Install NightVision - run: | - wget -c https://downloads.nightvision.net/binaries/latest/nightvision_latest_linux_amd64.tar.gz -O - | tar -xz - sudo mv nightvision /usr/local/bin/ - python -m pip install semgrep --user - - - name: (3) Extract API documentation from code - run: | - nightvision swagger extract . -t ${NIGHTVISION_TARGET} --lang java || true - if [ ! -e openapi-spec.yml ]; then - echo "⚠️ Using backup OpenAPI spec" - cp backup-openapi-spec.yml openapi-spec.yml - fi - - - name: (4) Start the app - run: | - docker compose up -d - - # Wait for app to be healthy (max 60 seconds) - echo "Waiting for app to start..." - for i in {1..12}; do - if curl -k -s -f https://127.0.0.1:9000/ > /dev/null 2>&1; then - echo "✅ App is ready!" - break - fi - echo "Attempt $i/12 - waiting 5 seconds..." - sleep 5 - done - - - name: (5) Scan the API - run: | - nightvision scan ${NIGHTVISION_TARGET} --auth ${NIGHTVISION_AUTH} > scan-results.txt - cat scan-results.txt - nightvision export sarif -s "$(head -n 1 scan-results.txt)" --swagger-file openapi-spec.yml - - - name: (5.5) Remap info-level findings to warning for GitHub visibility - if: success() - run: | - python3 << 'EOF' - import json - - # Read the SARIF file - with open('results.sarif', 'r') as f: - sarif = json.load(f) - - # Count findings by level - note_count = 0 - for run in sarif.get('runs', []): - for result in run.get('results', []): - if result.get('level') == 'note': - result['level'] = 'warning' - note_count += 1 - print(f"Remapped: {result.get('message', {}).get('text', 'Unknown')}") - - # Write back the modified SARIF - with open('results.sarif', 'w') as f: - json.dump(sarif, f, indent=2) - - print(f"\n✅ Successfully remapped {note_count} info-level findings to warning") - EOF - - - name: (6) Upload scan results as artifact - if: always() - uses: actions/upload-artifact@v4 - with: - name: nightvision-scan-results - path: | - scan-results.txt - results.sarif - openapi-spec.yml - retention-days: 30 - - - name: (7) Upload SARIF to GitHub Security - uses: github/codeql-action/upload-sarif@v3 - if: always() - with: - sarif_file: results.sarif - category: nightvision - - - name: (8) Cleanup - if: always() - run: docker compose down -v diff --git a/.github/workflows/nightvision-email.yml b/.github/workflows/nightvision-email.yml index 5db7199..22ba105 100644 --- a/.github/workflows/nightvision-email.yml +++ b/.github/workflows/nightvision-email.yml @@ -7,12 +7,13 @@ on: env: NIGHTVISION_TOKEN: ${{ secrets.NIGHTVISION_TOKEN }} NIGHTVISION_TARGET: javaspringvulny-api - NIGHTVISION_APP: javaspringvulny-api NIGHTVISION_AUTH: javaspringvulny-api SENDER_EMAIL: ${{ secrets.SENDER_EMAIL }} SENDER_PASSWORD: ${{ secrets.SENDER_PASSWORD }} RECEIVER_EMAIL: ${{ secrets.RECEIVER_EMAIL }} + COMPOSE_FILE: docker-compose.yml:docker-compose.ci.yml + jobs: test: permissions: @@ -20,36 +21,40 @@ jobs: runs-on: ubuntu-latest steps: - name: (1) Clone Code - uses: actions/checkout@v3 + uses: actions/checkout@v7 - name: (2) Install NightVision run: | wget -c https://downloads.nightvision.net/binaries/latest/nightvision_latest_linux_amd64.tar.gz -O - | tar -xz sudo mv nightvision /usr/local/bin/ - python -m pip install semgrep --user - name: (3) Extract API documentation from code run: | - nightvision swagger extract ./ -t ${{ env.NIGHTVISION_TARGET }} --lang spring || true + nightvision swagger extract ./ -t ${{ env.NIGHTVISION_TARGET }} --lang java || true if [ ! -e openapi-spec.yml ]; then cp backup-openapi-spec.yml openapi-spec.yml fi - name: (4) Start the app run: | - docker-compose up -d - sleep 10 - + docker compose up -d --build + for i in $(seq 1 36); do curl -kfsS -o /dev/null https://localhost:9000/ && exit 0; sleep 5; done + docker compose logs --tail 100; exit 1 - name: (5) Scan the API run: | - nightvision scan ${{ env.NIGHTVISION_TARGET }} -a ${{ env.NIGHTVISION_APP }} --auth ${{ env.NIGHTVISION_AUTH }} > scan-results.txt + nightvision scan ${{ env.NIGHTVISION_TARGET }} --auth ${{ env.NIGHTVISION_AUTH }} > scan-results.txt nightvision export sarif -s "$(head -n 1 scan-results.txt)" --swagger-file openapi-spec.yml - - name: (6) Upload SARIF file to GitHub Security Alerts if vulnerabilities are found - uses: github/codeql-action/upload-sarif@v2 + - name: (6) Upload findings to GitHub Code Scanning + if: success() + run: python3 scripts/nightvision-sarif-enrich.py results.sarif -o github.sarif + - uses: github/codeql-action/upload-sarif@v4 if: success() with: - sarif_file: results.sarif + sarif_file: github.sarif + # Same category as nightvision.yml, so every workflow updates one set of alerts + # for this app instead of each creating its own duplicates. + category: .github/workflows/nightvision.yml:test - name: (7) Install nightvision mailer reporter and dependencies run: | diff --git a/.github/workflows/nightvision-slack.yml b/.github/workflows/nightvision-slack.yml index 7ded109..4885553 100644 --- a/.github/workflows/nightvision-slack.yml +++ b/.github/workflows/nightvision-slack.yml @@ -1,9 +1,7 @@ name: Test Case - Slack - Java Spring App on: - push: - branches: - - main + # Manual only: nightvision.yml already scans every push. workflow_dispatch: env: @@ -13,6 +11,8 @@ env: SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }} SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }} + COMPOSE_FILE: docker-compose.yml:docker-compose.ci.yml + jobs: test: permissions: @@ -20,12 +20,12 @@ jobs: runs-on: ubuntu-latest steps: - name: (1) Clone Code - uses: actions/checkout@v3 + uses: actions/checkout@v7 - name: (2) Install NightVision run: | - wget -c https://downloads.nightvision.net/binaries/latest/nightvision_latest_linux_amd64.tar.gz -O - | tar -xz; sudo mv nightvision /usr/local/bin/ - python -m pip install semgrep --user + wget -c https://downloads.nightvision.net/binaries/latest/nightvision_latest_linux_amd64.tar.gz -O - | tar -xz + sudo mv nightvision /usr/local/bin/ - name: (3) Extract API documentation from code run: | nightvision swagger extract . -t ${NIGHTVISION_TARGET} --lang java || true @@ -33,17 +33,24 @@ jobs: cp backup-openapi-spec.yml openapi-spec.yml fi - name: (4) Start the app - run: docker compose up -d; sleep 10 - + run: | + docker compose up -d --build + for i in $(seq 1 36); do curl -kfsS -o /dev/null https://localhost:9000/ && exit 0; sleep 5; done + docker compose logs --tail 100; exit 1 - name: (5) Scan the API run: | nightvision scan ${NIGHTVISION_TARGET} --auth ${NIGHTVISION_AUTH} > scan-results.txt nightvision export sarif -s "$(head -n 1 scan-results.txt)" --swagger-file openapi-spec.yml - - name: (6) Upload SARIF file to GitHub Security Alerts if vulnerabilities are found - uses: github/codeql-action/upload-sarif@v2 + - name: (6) Upload findings to GitHub Code Scanning + if: success() + run: python3 scripts/nightvision-sarif-enrich.py results.sarif -o github.sarif + - uses: github/codeql-action/upload-sarif@v4 if: success() with: - sarif_file: results.sarif + sarif_file: github.sarif + # Same category as nightvision.yml, so every workflow updates one set of alerts + # for this app instead of each creating its own duplicates. + category: .github/workflows/nightvision.yml:test - name: (7) Install nightvision slack importer and dependencies run: | git clone https://github.com/ryan1640/nightvision_slack_importer.git diff --git a/.github/workflows/nightvision-teams.yml b/.github/workflows/nightvision-teams.yml index 968e447..4d276a4 100644 --- a/.github/workflows/nightvision-teams.yml +++ b/.github/workflows/nightvision-teams.yml @@ -7,10 +7,11 @@ on: env: NIGHTVISION_TOKEN: ${{ secrets.NIGHTVISION_TOKEN }} NIGHTVISION_TARGET: javaspringvulny-api - NIGHTVISION_APP: javaspringvulny-api NIGHTVISION_AUTH: javaspringvulny-api TEAMS_WEBHOOK_URL: ${{ secrets.TEAMS_WEBHOOK_URL }} + COMPOSE_FILE: docker-compose.yml:docker-compose.ci.yml + jobs: test: permissions: @@ -18,36 +19,40 @@ jobs: runs-on: ubuntu-latest steps: - name: (1) Clone Code - uses: actions/checkout@v3 + uses: actions/checkout@v7 - name: (2) Install NightVision run: | wget -c https://downloads.nightvision.net/binaries/latest/nightvision_latest_linux_amd64.tar.gz -O - | tar -xz sudo mv nightvision /usr/local/bin/ - python -m pip install semgrep --user - name: (3) Extract API documentation from code run: | - nightvision swagger extract ./ -t ${{ env.NIGHTVISION_TARGET }} --lang spring || true + nightvision swagger extract ./ -t ${{ env.NIGHTVISION_TARGET }} --lang java || true if [ ! -e openapi-spec.yml ]; then cp backup-openapi-spec.yml openapi-spec.yml fi - name: (4) Start the app run: | - docker-compose up -d - sleep 10 - + docker compose up -d --build + for i in $(seq 1 36); do curl -kfsS -o /dev/null https://localhost:9000/ && exit 0; sleep 5; done + docker compose logs --tail 100; exit 1 - name: (5) Scan the API run: | - nightvision scan ${{ env.NIGHTVISION_TARGET }} -a ${{ env.NIGHTVISION_APP }} --auth ${{ env.NIGHTVISION_AUTH }} > scan-results.txt + nightvision scan ${{ env.NIGHTVISION_TARGET }} --auth ${{ env.NIGHTVISION_AUTH }} > scan-results.txt nightvision export sarif -s "$(head -n 1 scan-results.txt)" --swagger-file openapi-spec.yml - - name: (6) Upload SARIF file to GitHub Security Alerts if vulnerabilities are found - uses: github/codeql-action/upload-sarif@v2 + - name: (6) Upload findings to GitHub Code Scanning + if: success() + run: python3 scripts/nightvision-sarif-enrich.py results.sarif -o github.sarif + - uses: github/codeql-action/upload-sarif@v4 if: success() with: - sarif_file: results.sarif + sarif_file: github.sarif + # Same category as nightvision.yml, so every workflow updates one set of alerts + # for this app instead of each creating its own duplicates. + category: .github/workflows/nightvision.yml:test - name: (7) Install nightvision MSTeams importer and dependencies run: | diff --git a/.github/workflows/nightvision.yml b/.github/workflows/nightvision.yml index 00c1291..5e38a9a 100644 --- a/.github/workflows/nightvision.yml +++ b/.github/workflows/nightvision.yml @@ -1,80 +1,143 @@ -## SETP +## SETUP (one time, before enabling the pipeline) +# nightvision project create 1-demo +# nightvision target create javaspringvulny-api https://localhost:9000 --type api +# nightvision auth playwright create javaspringvulny-api https://localhost:9000 +# CI serves HTTPS via docker-compose.ci.yml to match that target URL. +# Then add a NightVision API token as the NIGHTVISION_TOKEN repository secret. -## Run before activating pipeline -# nightvision app create javaspringvulny-api -# nightvision target create javaspringvulny-api https://127.0.0.1:9000 --type api -# nightvision auth playwright create javaspringvulny-api https://127.0.0.1:9000 - -## Optional steps can be preformed locally or in the pipeline -# nightvision swagger extract . -t javaspringvulny-api --lang java -# nightvision scan javaspringvulny-api --auth javaspringvulny-api - -name: Test Case - Java Spring App +name: NightVision DAST - Java Spring App on: push: - branches: - - main + branches: [main] + pull_request: + branches: [main] workflow_dispatch: + inputs: + max_duration_minutes: + description: Scan time budget in minutes (30-480). Longer scans crawl deeper. + default: "30" + required: false + +# One scan of the shared target at a time; a newer push queues behind the running scan. +concurrency: + group: nightvision-${{ github.workflow }} + cancel-in-progress: false env: NIGHTVISION_TOKEN: ${{ secrets.NIGHTVISION_TOKEN }} NIGHTVISION_TARGET: javaspringvulny-api NIGHTVISION_AUTH: javaspringvulny-api - + APP_URL: https://localhost:9000 + COMPOSE_FILE: docker-compose.yml:docker-compose.ci.yml + jobs: + # The job id stays "test": Code Scanning keys results on workflow file + job id, + # so renaming it would orphan the alerts from earlier runs. test: + name: NightVision DAST + # Pull requests from forks get no secrets, so they cannot scan. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 120 permissions: + contents: read security-events: write - runs-on: ubuntu-latest + actions: read steps: - - name: (1) Clone Code - uses: actions/checkout@v3 - - - name: (2) Install NightVision + - name: Check out code + uses: actions/checkout@v7 + + - name: Install NightVision CLI run: | - wget -c https://downloads.nightvision.net/binaries/latest/nightvision_latest_linux_amd64.tar.gz -O - | tar -xz; sudo mv nightvision /usr/local/bin/ + wget -qO- https://downloads.nightvision.net/binaries/latest/nightvision_latest_linux_amd64.tar.gz | tar -xz + sudo mv nightvision /usr/local/bin/ + nightvision version - - name: (3) Extract API documentation from code + - name: Map the API from source (source intelligence) run: | - nightvision swagger extract . -t ${NIGHTVISION_TARGET} --lang java || true - if [ ! -e openapi-spec.yml ]; then - cp backup-openapi-spec.yml openapi-spec.yml + nightvision swagger extract . -t "$NIGHTVISION_TARGET" --lang java || true + if [ ! -s openapi-spec.yml ]; then + echo "::warning::Source intelligence produced no spec; falling back to backup-openapi-spec.yml" + cp backup-openapi-spec.yml openapi-spec.yml fi - - name: (4) Start the app - run: docker compose up -d; sleep 10 + - name: Start the app + run: | + docker compose up -d --build + for i in $(seq 1 36); do + if curl -kfsS -o /dev/null "$APP_URL/"; then + echo "App is up after $((i * 5))s" + exit 0 + fi + sleep 5 + done + echo "::error::App did not respond at $APP_URL within 3 minutes" + docker compose logs --tail 100 + exit 1 - - name: (5) Scan the API + - name: Run DAST scan + id: scan + env: + DURATION: ${{ inputs.max_duration_minutes || '30' }} run: | - nightvision scan ${NIGHTVISION_TARGET} --auth ${NIGHTVISION_AUTH} > scan-results.txt - nightvision export sarif -s "$(head -n 1 scan-results.txt)" --swagger-file openapi-spec.yml + set +e + # The scan ID is printed to stdout; logs (which carry other UUIDs) go to stderr. + nightvision scan "$NIGHTVISION_TARGET" --auth "$NIGHTVISION_AUTH" \ + --max-duration-minutes "$DURATION" | tee scan-output.txt + status=${PIPESTATUS[0]} + set -e + scan_id=$(grep -oEm1 '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' scan-output.txt || true) + echo "scan_id=$scan_id" >> "$GITHUB_OUTPUT" + echo "status=$status" >> "$GITHUB_OUTPUT" + if [ -z "$scan_id" ]; then + echo "::error::No scan ID in the NightVision output" + exit 1 + fi + # A scan that ends FAILED can still hold valid findings; keep them as artifacts, then fail the job below. + if [ "$status" -ne 0 ]; then + echo "::warning::nightvision scan exited $status; saving its partial findings as artifacts only" + fi - - name: (5.5) Remap info-level findings to warning for GitHub visibility + - name: Export and enrich SARIF + if: always() && steps.scan.outputs.scan_id != '' + env: + NIGHTVISION_PR_NUMBER: ${{ github.event.pull_request.number }} run: | - python3 << 'EOF' - import json - - # Read the SARIF file - with open('results.sarif', 'r') as f: - sarif = json.load(f) - - # Remap "note" level to "warning" so GitHub displays them - for run in sarif.get('runs', []): - for result in run.get('results', []): - if result.get('level') == 'note': - result['level'] = 'warning' - print(f"Remapped: {result.get('message', {}).get('text', 'Unknown')}") - - # Write back the modified SARIF - with open('results.sarif', 'w') as f: - json.dump(sarif, f, indent=2) - - print("\n✅ Successfully remapped info-level findings to warning") - EOF + # Findings triaged as false positive or won't fix in NightVision stay out of GitHub. + nightvision export sarif -s "${{ steps.scan.outputs.scan_id }}" \ + --swagger-file openapi-spec.yml \ + --exclude-resolution false_positive,wont_fix \ + -o nightvision-raw.sarif + python3 scripts/nightvision-sarif-enrich.py nightvision-raw.sarif -o results.sarif - - name: (6) Upload SARIF file to GitHub Security Alerts if Vulnerabilities are found - uses: github/codeql-action/upload-sarif@v3 - if: success() + # Only a clean scan updates Code Scanning: a partial result would mark every + # finding it missed as fixed. Partial findings still land in the artifacts. + - name: Upload to GitHub Code Scanning + if: always() && hashFiles('results.sarif') != '' && steps.scan.outputs.status == '0' + uses: github/codeql-action/upload-sarif@v4 with: sarif_file: results.sarif + + - name: Keep scan artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: nightvision-scan-${{ github.run_number }} + path: | + scan-output.txt + nightvision-raw.sarif + results.sarif + openapi-spec.yml + if-no-files-found: ignore + retention-days: 30 + + - name: Fail if the scan did not finish cleanly + if: steps.scan.outputs.status != '' && steps.scan.outputs.status != '0' + run: | + echo "::error::nightvision scan exited ${{ steps.scan.outputs.status }}" + exit 1 + + - name: Stop the app + if: always() + run: docker compose down -v diff --git a/.gitignore b/.gitignore index 8ee87de..69b8f1e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,10 @@ src/main/java/nv.config # App-generated at runtime (backs UploadController) uploads/ + +# NightVision CI outputs +scan-output.txt +scan-results.txt +nightvision-raw.sarif +results.sarif +github.sarif diff --git a/README.md b/README.md index 9e1bda0..7792e6c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,75 @@ -# Java GitHub Actions Demo +# NightVision DAST in GitHub Actions (Java / Spring) -A source repository for the GitHub Actions demo of NightVision.foobar -foobar +A deliberately vulnerable Spring Boot app ([JavaSpringVulny](src/main/java/hawk)) wired to +NightVision in GitHub Actions. Every push and pull request builds the app, maps its API from +source, runs a NightVision DAST scan against the running container, and reports what it proved +straight into GitHub Code Scanning, pointing at the controller that handles each vulnerable route. + +```mermaid +flowchart LR + A[push / PR] --> B[Map API from source
openapi-spec.yml] + B --> C[docker compose up
app on :9000] + C --> D[NightVision DAST scan
authenticated] + D --> E[export SARIF
+ enrich for GitHub] + E --> F[Code Scanning alerts
+ PR annotations] + E --> G[Job summary
+ artifacts] +``` + +## What you see after a run + +- **Security → Code scanning**: one alert per finding, ranked by real severity (SQL injection and + Spring4Shell are Critical, not lumped in with missing headers), tagged with CWE, and pinned to + the handler line, for example `SearchController.java:36` for `POST /search`. + Each alert links to the request and response in NightVision that prove it. +- **Pull requests**: new findings show up as annotations on the diff and in the Code scanning check. +- **Run summary**: a severity table with links to the code and to the proof for each finding. +- **Artifacts**: the raw NightVision SARIF, the enriched SARIF, the scan output, and the API spec. + +## Setup + +1. Create the NightVision target and login once (from a machine with the + [NightVision CLI](https://docs.nightviz.ai)): + + ```sh + nightvision target create javaspringvulny-api http://127.0.0.1:9000 --type api + nightvision auth playwright create javaspringvulny-api http://127.0.0.1:9000 + ``` + +2. Add a NightVision API token as the repository secret `NIGHTVISION_TOKEN`. +3. Push to `main`, open a PR, or run **Actions → NightVision DAST → Run workflow**. Manual runs + take a scan time budget; the default is 30 minutes, and longer budgets crawl deeper. + +## How the reporting works + +`nightvision export sarif` writes standard SARIF. [`scripts/nightvision-sarif-enrich.py`](scripts/nightvision-sarif-enrich.py) +then tunes it for how GitHub renders alerts: plain-text messages, rule help with remediation, +CWE tags, `error` level for critical and high findings, and a real file for app-wide findings. +Informational results stay in the raw SARIF artifact and in NightVision but are left out of +Code Scanning. Findings marked false positive or won't fix in NightVision are excluded too, so +triage happens once. + +The script is standard-library Python and works with any NightVision SARIF export; try it locally: + +```sh +python3 scripts/nightvision-sarif-enrich.py results.sarif -o github.sarif --summary summary.md +``` + +## Other workflows + +| Workflow | Trigger | Adds | +|---|---|---| +| [`nightvision.yml`](.github/workflows/nightvision.yml) | push, PR, manual | the main demo | +| [`nightvision-slack.yml`](.github/workflows/nightvision-slack.yml) | manual | posts findings to Slack | +| [`nightvision-teams.yml`](.github/workflows/nightvision-teams.yml) | manual | posts findings to Microsoft Teams | +| [`nightvision-email.yml`](.github/workflows/nightvision-email.yml) | manual | emails findings | + +All four report into the same Code Scanning category, so there is one set of alerts for the app. +The repo also carries equivalent pipelines for GitLab, Azure DevOps, Bitbucket, and Jenkins. + +## Run it locally + +```sh +docker compose up -d --build # app on http://localhost:9000 +``` + +This app is intentionally vulnerable. Do not expose it to the internet. diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml new file mode 100644 index 0000000..23e1a7f --- /dev/null +++ b/docker-compose.ci.yml @@ -0,0 +1,9 @@ +# CI override: serve HTTPS, because the NightVision CI target is https://localhost:9000. +# Local demos keep plain HTTP from docker-compose.yml. +# docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d +services: + javavulny: + environment: + SERVER_SSL_ENABLED: "true" + # javavulny.p12 still carries the password it was created with. + SERVER_SSL_KEY_STORE_PASSWORD: stackhawk diff --git a/scripts/nightvision-sarif-enrich.py b/scripts/nightvision-sarif-enrich.py new file mode 100644 index 0000000..cb264e6 --- /dev/null +++ b/scripts/nightvision-sarif-enrich.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 +"""Make a NightVision SARIF export read well in GitHub Code Scanning. + +`nightvision export sarif` produces valid SARIF, but GitHub renders it poorly: +every rule's help panel is boilerplate, result messages are Markdown (GitHub +shows result messages as plain text, so the reader sees raw ** and emoji), all +findings are "warning" or "note" so SQL injection looks like a missing header, +and findings NightVision could not trace to source point at a file named "/". + +This script rewrites the export in place for GitHub: + * rule help, CWE tags, precision and security-severity per rule + * a plain-text message per result: " on ", the + description, and the NightVision evidence link + * level from NightVision risk (critical/high -> error, medium -> warning) + * untraced findings moved to a real file, and dropped when the same rule + already has a source-mapped result + * informational results dropped (they stay in the raw export) +It also writes a Markdown job summary when GITHUB_STEP_SUMMARY is set. + +Standard library only, so it runs on any GitHub-hosted runner. +""" + +import argparse +import json +import os +import re +import sys + +RISK_ORDER = ["INFO", "LOW", "MEDIUM", "HIGH", "CRITICAL"] +RISK_LEVEL = {"CRITICAL": "error", "HIGH": "error", "MEDIUM": "warning", "LOW": "note", "INFO": "note"} +RISK_SCORE = {"CRITICAL": "9.5", "HIGH": "8.0", "MEDIUM": "5.5", "LOW": "3.0", "INFO": "0.0"} +RISK_ICON = {"CRITICAL": "🟥", "HIGH": "🟧", "MEDIUM": "🟨", "LOW": "🟦", "INFO": "⬜"} +CONFIDENCE_PRECISION = {"Certain": "very-high", "High": "high", "Medium": "medium", "Low": "low"} +PRECISION_ORDER = ["low", "medium", "high", "very-high"] + +# First match wins. Keyed on the NightVision check name. +CWE_BY_NAME = [ + (r"sql injection", 89), + (r"spring4shell", 94), + (r"cross site scripting|xss", 79), + (r"server side request forgery|ssrf", 918), + (r"remote file inclusion", 98), + (r"reflected unencoded", 116), + (r"httponly", 1004), + (r"without secure flag", 614), + (r"samesite", 1275), + (r"error disclosure|stack trace", 209), + (r"header|policy", 693), +] + +# Used when the export carries no description for a finding (it omits one for some +# of the most serious checks). Keyed on CWE. +FALLBACK_HELP = { + 89: ( + "## Summary\n\nNightVision sent SQL injection payloads to this endpoint and the database " + "responses proved the input reaches a SQL query unescaped. An attacker can read or change " + "any data the application's database user can reach.\n\n" + "## Solution\n\nBuild queries with parameters (`PreparedStatement`, JPA named parameters, " + "Spring Data query methods) instead of concatenating request input into SQL strings.\n\n" + "## References\n- https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html" + ), + 94: ( + "## Summary\n\nThe endpoint accepted a Spring4Shell (CVE-2022-22965) data-binding payload. " + "Spring Framework data binding on affected versions lets a request set `class.module.classLoader` " + "properties, which leads to remote code execution on Tomcat deployments.\n\n" + "## Solution\n\nUpgrade Spring Framework to 5.3.18+ or 5.2.20+ (Spring Boot 2.6.6+ or 2.5.12+). " + "Until then, restrict bindable fields with `@InitBinder` / `setDisallowedFields`.\n\n" + "## References\n- https://spring.io/security/cve-2022-22965\n- https://nvd.nist.gov/vuln/detail/CVE-2022-22965" + ), +} + +NV_LINK = re.compile(r"https://app\.nightvision\.net/\S+") +ENDPOINT = re.compile(r"The `(?P[^`]+)` URL path is vulnerable to \*\*.+?\*\* via a `(?P[A-Z]+)` request") +BACKTICK_HEADER = re.compile(r"`([A-Z][A-Za-z]+(?:-[A-Z][A-Za-z]+)+)`") + + +def risk_of(result): + risk = str(result.get("properties", {}).get("nightvision-risk", "")).upper() + return risk if risk in RISK_ORDER else "LOW" + + +def to_plain(markdown): + text = re.sub(r"```(.*?)```", r"\1", markdown, flags=re.S) + text = re.sub(r"^#+\s*", "", text, flags=re.M) + text = text.replace("**", "").replace("`", "") + return re.sub(r"\n{3,}", "\n\n", text).strip() + + +def parse_message(markdown): + """Split NightVision's Markdown message into endpoint, description and link.""" + link = NV_LINK.search(markdown) + endpoint = ENDPOINT.search(markdown) + description = markdown.split("**Description**:", 1)[1] if "**Description**:" in markdown else "" + description = NV_LINK.sub("", description) + description = re.sub(r"🔍.*$", "", description, flags=re.S).strip() + return { + "link": link.group(0).rstrip(").") if link else None, + "method": endpoint.group("method") if endpoint else None, + "path": endpoint.group("path") if endpoint else None, + "description": description, + } + + +def display_name(rule_name, description): + # Generic Nuclei buckets say nothing on their own; name the header they are about. + if rule_name.startswith("Uncategorized Nuclei Finding"): + header = BACKTICK_HEADER.search(description) + if header: + return f"Missing {header.group(1)} header" + return rule_name + + +def cwe_for(name): + lowered = name.lower() + for pattern, cwe in CWE_BY_NAME: + if re.search(pattern, lowered): + return cwe + return None + + +def strip_placeholders(node): + """Drop SARIF placeholder values (-1 indexes, empty arrays/objects) the exporter emits.""" + if isinstance(node, dict): + cleaned = {} + for key, value in node.items(): + value = strip_placeholders(value) + if value == -1 or value in ([], {}): + continue + cleaned[key] = value + return cleaned + if isinstance(node, list): + return [strip_placeholders(item) for item in node] + return node + + +def location_uri(result): + locations = result.get("locations") or [{}] + return locations[0].get("physicalLocation", {}).get("artifactLocation", {}).get("uri") + + +def is_traced(result): + uri = location_uri(result) + return bool(uri) and uri != "/" and os.path.isfile(uri) + + +def enrich(sarif, min_risk, fallback_uri): + kept_findings = [] + dropped = 0 + for run in sarif.get("runs", []): + driver = run["tool"]["driver"] + rules = {rule["id"]: rule for rule in driver.get("rules", [])} + traced_rules = {r["ruleId"] for r in run.get("results", []) if is_traced(r)} + rule_names_by_file = {} + for r in run.get("results", []): + rule_names_by_file.setdefault(location_uri(r), []).append(rules.get(r["ruleId"], {}).get("name", "")) + untraced_seen = set() + + results = [] + for result in run.get("results", []): + risk = risk_of(result) + rule = rules.get(result["ruleId"], {"id": result["ruleId"], "name": result["ruleId"]}) + rule_name = rule.get("name") or rule.get("shortDescription", {}).get("text", result["ruleId"]) + parsed = parse_message(result.get("message", {}).get("text", "")) + name = display_name(rule_name, parsed["description"]) + + if RISK_ORDER.index(risk) < RISK_ORDER.index(min_risk): + dropped += 1 + continue + if not is_traced(result): + # Untraced duplicates: the same rule is already mapped to source, or already + # reported once at the fallback location. + if result["ruleId"] in traced_rules or result["ruleId"] in untraced_seen: + dropped += 1 + continue + untraced_seen.add(result["ruleId"]) + if name != rule_name: + # A generic Nuclei bucket that repeats a named header check at the same place. + header = name.split(" ")[1] + if any(header in other for other in rule_names_by_file.get(location_uri(result), []) if other != rule_name): + dropped += 1 + continue + where = f"{parsed['method']} {parsed['path']}" if parsed["path"] else "the application" + + lines = [f"{name} on {where}."] + if parsed["description"]: + lines += ["", to_plain(parsed["description"])[:1800]] + if parsed["link"]: + lines += ["", f"Proof (request, response, payload) in NightVision: {parsed['link']}"] + result["message"] = {"text": "\n".join(lines)} + result["level"] = RISK_LEVEL[risk] + result.setdefault("properties", {})["security-severity"] = RISK_SCORE[risk] + + if not is_traced(result): + result["locations"] = [{ + "physicalLocation": { + "artifactLocation": {"uri": fallback_uri}, + "region": {"startLine": 1}, + }, + "message": {"text": "Application-wide finding; NightVision did not trace it to a single handler."}, + }] + else: + region = result["locations"][0]["physicalLocation"].setdefault("region", {}) + region["message"] = {"text": f"Handler for {where}"} + + # The issue id is regenerated per scan; matching on it would reopen alerts every run. + result.get("partialFingerprints", {}).pop("nightvisionIssueID/v1", None) + + update_rule(rule, rule_name, parsed, risk, result.get("properties", {})) + results.append(result) + kept_findings.append({ + "risk": risk, + "name": name, + "where": where, + "file": location_uri(result), + "line": result["locations"][0]["physicalLocation"].get("region", {}).get("startLine"), + "link": parsed["link"], + }) + + run["results"] = results + used = {r["ruleId"] for r in results} + driver["rules"] = [rule for rule in driver.get("rules", []) if rule["id"] in used] + driver.update({ + "fullName": "NightVision DAST", + "organization": "NightVision", + "informationUri": "https://docs.nightviz.ai", + }) + # The exporter lists the NightVision web UI as an "artifact"; it is not a file in the repo. + run.pop("artifacts", None) + + return strip_placeholders(sarif), kept_findings, dropped + + +def update_rule(rule, rule_name, parsed, risk, result_properties): + props = rule.setdefault("properties", {}) + worst = props.get("_nv_risk") + if worst is None or RISK_ORDER.index(risk) > RISK_ORDER.index(worst): + props["_nv_risk"] = risk + props["security-severity"] = RISK_SCORE[risk] + props["problem.severity"] = {"error": "error", "warning": "warning", "note": "recommendation"}[RISK_LEVEL[risk]] + + precision = CONFIDENCE_PRECISION.get(result_properties.get("nightvision-confidence"), "medium") + if PRECISION_ORDER.index(precision) > PRECISION_ORDER.index(props.get("precision", "low")): + props["precision"] = precision + + tags = ["security", "dast", "nightvision"] + cwe = cwe_for(rule_name) + if cwe: + tags.append(f"external/cwe/cwe-{cwe}") + props["tags"] = tags + + # Rule help is shared by every result of the rule; build it once from the first description. + body = parsed["description"] or FALLBACK_HELP.get(cwe) + if "_nv_help" not in props and body: + props["_nv_help"] = True + footer = ( + "\n\n---\n" + "Found by **NightVision DAST**: the check ran against the running application, " + "and each alert links to the request and response that prove it." + ) + if cwe: + footer += f" Classified as [CWE-{cwe}](https://cwe.mitre.org/data/definitions/{cwe}.html)." + rule["help"] = {"text": to_plain(body), "markdown": body + footer} + summary = re.sub(r"^Summary\s*", "", to_plain(body)) + rule["fullDescription"] = {"text": summary.split("\n\n")[0][:1000]} + rule["shortDescription"] = {"text": rule_name} + rule["helpUri"] = f"https://cwe.mitre.org/data/definitions/{cwe}.html" if cwe else "https://docs.nightviz.ai" + + +def finalize_rules(sarif): + for run in sarif.get("runs", []): + for rule in run["tool"]["driver"].get("rules", []): + for key in ("_nv_risk", "_nv_help"): + rule.get("properties", {}).pop(key, None) + + +def write_summary(path, findings, dropped, scan_url): + counts = {risk: sum(1 for f in findings if f["risk"] == risk) for risk in reversed(RISK_ORDER)} + server = os.environ.get("GITHUB_SERVER_URL", "https://github.com") + repo = os.environ.get("GITHUB_REPOSITORY", "") + sha = os.environ.get("GITHUB_SHA", "") + alerts = f"{server}/{repo}/security/code-scanning?query=is%3Aopen+tool%3ANightVision" + pr = os.environ.get("NIGHTVISION_PR_NUMBER") + if pr: + alerts += f"+pr%3A{pr}" + + out = ["## NightVision DAST results", ""] + out.append(" · ".join(f"{RISK_ICON[r]} **{counts[r]}** {r.title()}" for r in counts if r != "INFO")) + out.append("") + links = [f"[Code Scanning alerts]({alerts})"] + if scan_url: + links.insert(0, f"[Full scan in NightVision]({scan_url})") + out += [" · ".join(links), ""] + + if findings: + out += ["| Severity | Finding | Endpoint | Code | Proof |", "|---|---|---|---|---|"] + for f in sorted(findings, key=lambda f: -RISK_ORDER.index(f["risk"])): + code = f"`{f['file']}`" + if f["file"] and repo and sha: + code = f"[`{os.path.basename(f['file'])}:{f['line']}`]({server}/{repo}/blob/{sha}/{f['file']}#L{f['line']})" + proof = f"[view]({f['link']})" if f["link"] else "" + out.append(f"| {RISK_ICON[f['risk']]} {f['risk'].title()} | {f['name']} | `{f['where']}` | {code} | {proof} |") + else: + out.append("No findings at or above the reporting threshold.") + if dropped: + out += ["", f"{dropped} informational or duplicate results left out of Code Scanning; " + "they are in the raw SARIF artifact and in NightVision."] + + with open(path, "a", encoding="utf-8") as handle: + handle.write("\n".join(out) + "\n") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("input", help="SARIF file from `nightvision export sarif`") + parser.add_argument("-o", "--output", help="output path (default: overwrite input)") + parser.add_argument("--min-risk", default="LOW", type=str.upper, choices=RISK_ORDER, + help="drop results below this NightVision risk (default: LOW, which drops INFO)") + parser.add_argument("--fallback-file", default="src/main/java/hawk/Application.java", + help="repo file to anchor findings NightVision could not trace to source") + parser.add_argument("--summary", default=os.environ.get("GITHUB_STEP_SUMMARY"), + help="append a Markdown summary here (default: $GITHUB_STEP_SUMMARY)") + args = parser.parse_args() + + with open(args.input, encoding="utf-8") as handle: + sarif = json.load(handle) + scan_url = next((run["tool"]["driver"].get("informationUri") for run in sarif.get("runs", [])), None) + + sarif, findings, dropped = enrich(sarif, args.min_risk, args.fallback_file) + finalize_rules(sarif) + + with open(args.output or args.input, "w", encoding="utf-8") as handle: + json.dump(sarif, handle, indent=2) + if args.summary: + write_summary(args.summary, findings, dropped, scan_url) + + print(f"NightVision SARIF: {len(findings)} results kept, {dropped} informational/duplicate dropped") + return 0 + + +if __name__ == "__main__": + sys.exit(main())