From d8bae1ad9ee09a37475f7467fbcaa88776176d86 Mon Sep 17 00:00:00 2001 From: ryan1640 Date: Wed, 23 Sep 2026 11:40:27 -0400 Subject: [PATCH 1/4] Make NightVision findings read well in GitHub Code Scanning The export's rule help is boilerplate, result messages are Markdown that GitHub shows as raw text, every finding is warning or note, and untraced findings point at a file named "/". Add a stdlib-only enrichment step that fixes all four, adds CWE tags and a run summary, and drops informational and duplicate results. Workflow: add PR scans, a readiness check instead of sleep 10, a manual scan-duration input, scan-ID capture from stdout only, export of findings from failed scans, and exclusion of findings triaged in NightVision. Bump actions to Node 24 majors. The job id stays "test" so the stale alerts from the old per-scan rule IDs close on the next run. Repair the Slack, Teams and email workflows (retired upload-sarif v2, docker-compose v1, removed -a flag, --lang spring, invalid Slack YAML), make Slack manual-only, and point all four at one Code Scanning category. Remove the never-run "improved workflow" file and write a real README. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/improved workflow | 119 --------- .github/workflows/nightvision-email.yml | 25 +- .github/workflows/nightvision-slack.yml | 27 +- .github/workflows/nightvision-teams.yml | 25 +- .github/workflows/nightvision.yml | 165 ++++++++---- .gitignore | 7 + README.md | 77 +++++- scripts/nightvision-sarif-enrich.py | 339 ++++++++++++++++++++++++ 8 files changed, 576 insertions(+), 208 deletions(-) delete mode 100644 .github/workflows/improved workflow create mode 100644 scripts/nightvision-sarif-enrich.py 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..ae119ce 100644 --- a/.github/workflows/nightvision-email.yml +++ b/.github/workflows/nightvision-email.yml @@ -7,7 +7,6 @@ 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 }} @@ -20,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 -fsS -o /dev/null http://127.0.0.1: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..5a68968 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: @@ -20,12 +18,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 +31,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 -fsS -o /dev/null http://127.0.0.1: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..eb28721 100644 --- a/.github/workflows/nightvision-teams.yml +++ b/.github/workflows/nightvision-teams.yml @@ -7,7 +7,6 @@ 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 }} @@ -18,36 +17,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 -fsS -o /dev/null http://127.0.0.1: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..13c8c68 100644 --- a/.github/workflows/nightvision.yml +++ b/.github/workflows/nightvision.yml @@ -1,80 +1,139 @@ -## SETP +## SETUP (one time, before enabling the pipeline) +# nightvision project create 1-demo +# 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 +# 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: http://127.0.0.1:9000 + 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 -fsS -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; report them, then fail the job below. + if [ "$status" -ne 0 ]; then + echo "::warning::nightvision scan exited $status; exporting whatever findings it recorded" + 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() + - name: Upload to GitHub Code Scanning + if: always() && hashFiles('results.sarif') != '' + 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/scripts/nightvision-sarif-enrich.py b/scripts/nightvision-sarif-enrich.py new file mode 100644 index 0000000..e7c1a72 --- /dev/null +++ b/scripts/nightvision-sarif-enrich.py @@ -0,0 +1,339 @@ +#!/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"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()) From 3310c34c9db46f673f4beb4547527e8dde7e39b2 Mon Sep 17 00:00:00 2001 From: ryan1640 Date: Wed, 23 Sep 2026 12:06:25 -0400 Subject: [PATCH 2/4] Serve HTTPS in CI to match the NightVision target The CI target is https://localhost:9000, but the July demo-baseline commit turned SSL off and renamed the keystore password to one the keystore does not use, so every scan since then fails the connectivity check. Add docker-compose.ci.yml, which turns SSL on with the keystore's real password for CI only; local demos keep plain HTTP. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/nightvision-email.yml | 4 +++- .github/workflows/nightvision-slack.yml | 4 +++- .github/workflows/nightvision-teams.yml | 4 +++- .github/workflows/nightvision.yml | 10 ++++++---- docker-compose.ci.yml | 9 +++++++++ 5 files changed, 24 insertions(+), 7 deletions(-) create mode 100644 docker-compose.ci.yml diff --git a/.github/workflows/nightvision-email.yml b/.github/workflows/nightvision-email.yml index ae119ce..22ba105 100644 --- a/.github/workflows/nightvision-email.yml +++ b/.github/workflows/nightvision-email.yml @@ -12,6 +12,8 @@ env: SENDER_PASSWORD: ${{ secrets.SENDER_PASSWORD }} RECEIVER_EMAIL: ${{ secrets.RECEIVER_EMAIL }} + COMPOSE_FILE: docker-compose.yml:docker-compose.ci.yml + jobs: test: permissions: @@ -36,7 +38,7 @@ jobs: - name: (4) Start the app run: | docker compose up -d --build - for i in $(seq 1 36); do curl -fsS -o /dev/null http://127.0.0.1:9000/ && exit 0; sleep 5; done + 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: | diff --git a/.github/workflows/nightvision-slack.yml b/.github/workflows/nightvision-slack.yml index 5a68968..4885553 100644 --- a/.github/workflows/nightvision-slack.yml +++ b/.github/workflows/nightvision-slack.yml @@ -11,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: @@ -33,7 +35,7 @@ jobs: - name: (4) Start the app run: | docker compose up -d --build - for i in $(seq 1 36); do curl -fsS -o /dev/null http://127.0.0.1:9000/ && exit 0; sleep 5; done + 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: | diff --git a/.github/workflows/nightvision-teams.yml b/.github/workflows/nightvision-teams.yml index eb28721..4d276a4 100644 --- a/.github/workflows/nightvision-teams.yml +++ b/.github/workflows/nightvision-teams.yml @@ -10,6 +10,8 @@ env: NIGHTVISION_AUTH: javaspringvulny-api TEAMS_WEBHOOK_URL: ${{ secrets.TEAMS_WEBHOOK_URL }} + COMPOSE_FILE: docker-compose.yml:docker-compose.ci.yml + jobs: test: permissions: @@ -34,7 +36,7 @@ jobs: - name: (4) Start the app run: | docker compose up -d --build - for i in $(seq 1 36); do curl -fsS -o /dev/null http://127.0.0.1:9000/ && exit 0; sleep 5; done + 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: | diff --git a/.github/workflows/nightvision.yml b/.github/workflows/nightvision.yml index 13c8c68..8296cb4 100644 --- a/.github/workflows/nightvision.yml +++ b/.github/workflows/nightvision.yml @@ -1,7 +1,8 @@ ## SETUP (one time, before enabling the pipeline) # nightvision project create 1-demo -# 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 +# 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. name: NightVision DAST - Java Spring App @@ -27,7 +28,8 @@ env: NIGHTVISION_TOKEN: ${{ secrets.NIGHTVISION_TOKEN }} NIGHTVISION_TARGET: javaspringvulny-api NIGHTVISION_AUTH: javaspringvulny-api - APP_URL: http://127.0.0.1:9000 + 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, @@ -64,7 +66,7 @@ jobs: run: | docker compose up -d --build for i in $(seq 1 36); do - if curl -fsS -o /dev/null "$APP_URL/"; then + if curl -kfsS -o /dev/null "$APP_URL/"; then echo "App is up after $((i * 5))s" exit 0 fi 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 From 7b0da6c2565afdcabf7bb8cf7ee633772cf67d1f Mon Sep 17 00:00:00 2001 From: ryan1640 Date: Wed, 23 Sep 2026 12:26:32 -0400 Subject: [PATCH 3/4] Tag SSRF and remote file inclusion findings with their CWEs Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/nightvision-sarif-enrich.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/nightvision-sarif-enrich.py b/scripts/nightvision-sarif-enrich.py index e7c1a72..cb264e6 100644 --- a/scripts/nightvision-sarif-enrich.py +++ b/scripts/nightvision-sarif-enrich.py @@ -38,6 +38,8 @@ (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), From b4bc88b1d494d87ab1c2a60b52d5679d034047a6 Mon Sep 17 00:00:00 2001 From: ryan1640 Date: Wed, 23 Sep 2026 12:42:15 -0400 Subject: [PATCH 4/4] Upload to Code Scanning only after a clean scan A scan that dies partway (a relay timeout did this on the last PR run) exports a subset of findings, and uploading it marks everything it missed as fixed. Keep partial findings in the artifacts instead. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/nightvision.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/nightvision.yml b/.github/workflows/nightvision.yml index 8296cb4..5e38a9a 100644 --- a/.github/workflows/nightvision.yml +++ b/.github/workflows/nightvision.yml @@ -94,9 +94,9 @@ jobs: echo "::error::No scan ID in the NightVision output" exit 1 fi - # A scan that ends FAILED can still hold valid findings; report them, then fail the job below. + # 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; exporting whatever findings it recorded" + echo "::warning::nightvision scan exited $status; saving its partial findings as artifacts only" fi - name: Export and enrich SARIF @@ -111,8 +111,10 @@ jobs: -o nightvision-raw.sarif python3 scripts/nightvision-sarif-enrich.py nightvision-raw.sarif -o results.sarif + # 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') != '' + if: always() && hashFiles('results.sarif') != '' && steps.scan.outputs.status == '0' uses: github/codeql-action/upload-sarif@v4 with: sarif_file: results.sarif