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())