From 5198c9b6a74be44e69169c19c08abbeb283f8ec7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Apr 2026 18:48:12 +0000 Subject: [PATCH 1/3] Add integration tests, security/QAT workflows, and SDK framework README - tests/unit/: 55 unit tests for email validation, schedule helpers, CSV parsing - tests/integration/: 71 integration tests covering DAST request script, blacklist script, bash syntax validation, shellcheck, XML API scripts, and live API connectivity (credential-gated) - tests/fixtures/: allowlist, blacklist, glblacklist CSV fixtures for test runs - pytest.ini, requirements-test.txt: test runner configuration - .github/workflows/integration-tests.yml: unit + integration + shell + API tests, split into jobs with artifact uploads and optional live API job on main - .github/workflows/security-scan.yml: Bandit, ShellCheck, Gitleaks, pip-audit, Semgrep, and credentials-file checker; scheduled weekly - .github/workflows/qat.yml: flake8, ShellCheck lint, JSON/YAML validation, PSScriptAnalyzer on Windows, full test suite with result publishing - README.md: rewritten with badge table, SDK framework overview, API reference, quick-start, test docs, and secrets guide - .gitignore: excludes __pycache__, credentials, test artifacts, coverage files https://claude.ai/code/session_015pBhzcxzBhLcAujgXrwsaz --- .github/workflows/integration-tests.yml | 184 ++++++++++++ .github/workflows/qat.yml | 263 ++++++++++++++++++ .github/workflows/security-scan.yml | 207 ++++++++++++++ .gitignore | 40 +++ README.md | 202 +++++++++++++- pytest.ini | 11 + requirements-test.txt | 3 + tests/conftest.py | 49 ++++ tests/fixtures/allowlist.csv | 5 + tests/fixtures/blacklist.csv | 4 + tests/fixtures/glblacklist.csv | 3 + tests/integration/__init__.py | 0 tests/integration/test_api_connectivity.py | 143 ++++++++++ tests/integration/test_blacklist_script.py | 128 +++++++++ .../test_dast_web_request_script.py | 190 +++++++++++++ tests/integration/test_shell_scripts.py | 223 +++++++++++++++ tests/unit/__init__.py | 0 tests/unit/test_csv_parsing.py | 177 ++++++++++++ tests/unit/test_email_validation.py | 69 +++++ tests/unit/test_schedule_helpers.py | 137 +++++++++ 20 files changed, 2028 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/integration-tests.yml create mode 100644 .github/workflows/qat.yml create mode 100644 .github/workflows/security-scan.yml create mode 100644 .gitignore create mode 100644 pytest.ini create mode 100644 requirements-test.txt create mode 100644 tests/conftest.py create mode 100644 tests/fixtures/allowlist.csv create mode 100644 tests/fixtures/blacklist.csv create mode 100644 tests/fixtures/glblacklist.csv create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_api_connectivity.py create mode 100644 tests/integration/test_blacklist_script.py create mode 100644 tests/integration/test_dast_web_request_script.py create mode 100644 tests/integration/test_shell_scripts.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_csv_parsing.py create mode 100644 tests/unit/test_email_validation.py create mode 100644 tests/unit/test_schedule_helpers.py diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 0000000..d8b0552 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,184 @@ +name: Integration Tests + +on: + push: + branches: + - main + - "claude/**" + pull_request: + branches: + - main + +jobs: + # ── Unit tests: pure logic, no external dependencies ─────────────────────── + unit-tests: + name: Unit Tests (Python) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Install test dependencies + run: pip install -r requirements-test.txt + + - name: Run unit tests + run: pytest tests/unit/ -v --tb=short --junit-xml=unit-test-results.xml + + - name: Upload unit test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: unit-test-results + path: unit-test-results.xml + + # ── Integration tests: scripts invoked end-to-end (no API creds needed) ─── + python-integration-tests: + name: Python Script Integration Tests + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Install test dependencies + run: pip install -r requirements-test.txt + + - name: Run Python integration tests + run: | + pytest tests/integration/ \ + --ignore=tests/integration/test_api_connectivity.py \ + --ignore=tests/integration/test_shell_scripts.py \ + -v --tb=short \ + --junit-xml=integration-test-results.xml + + - name: Upload integration test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: integration-test-results + path: integration-test-results.xml + + # ── Shell script integration tests (bash syntax + shellcheck) ───────────── + shell-integration-tests: + name: Shell Script Tests + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install shellcheck + run: sudo apt-get update -q && sudo apt-get install -y shellcheck + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install test dependencies + run: pip install -r requirements-test.txt + + - name: Run shell script tests + run: | + pytest tests/integration/test_shell_scripts.py \ + -v --tb=short \ + --junit-xml=shell-test-results.xml + + - name: Upload shell test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: shell-test-results + path: shell-test-results.xml + + # ── Combined coverage report ─────────────────────────────────────────────── + coverage: + name: Test Coverage + runs-on: ubuntu-latest + needs: [unit-tests, python-integration-tests] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: pip install -r requirements-test.txt + + - name: Run tests with coverage + run: | + pytest tests/ \ + --ignore=tests/integration/test_api_connectivity.py \ + --cov=Scripts \ + --cov-report=xml \ + --cov-report=term-missing \ + -q + continue-on-error: true + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage.xml + + # ── Live API connectivity tests (main branch + secrets only) ─────────────── + api-connectivity-tests: + name: Veracode API Connectivity + runs-on: ubuntu-latest + if: > + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + secrets.VERACODE_API_ID != '' + environment: veracode-integration + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + pip install -r requirements-test.txt + pip install veracode-api-signing 2>/dev/null || true + + - name: Configure Veracode credentials + run: | + mkdir -p ~/.veracode + printf '[default]\nveracode_api_key_id = %s\nveracode_api_key_secret = %s\n' \ + "$VERACODE_API_ID" "$VERACODE_API_KEY" > ~/.veracode/credentials + env: + VERACODE_API_ID: ${{ secrets.VERACODE_API_ID }} + VERACODE_API_KEY: ${{ secrets.VERACODE_API_KEY }} + + - name: Run API connectivity tests + env: + VERACODE_API_ID: ${{ secrets.VERACODE_API_ID }} + VERACODE_API_KEY: ${{ secrets.VERACODE_API_KEY }} + run: | + pytest tests/integration/test_api_connectivity.py \ + -m api -v --tb=short \ + --junit-xml=api-test-results.xml + continue-on-error: true + + - name: Upload API test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: api-test-results + path: api-test-results.xml diff --git a/.github/workflows/qat.yml b/.github/workflows/qat.yml new file mode 100644 index 0000000..f1c28e0 --- /dev/null +++ b/.github/workflows/qat.yml @@ -0,0 +1,263 @@ +name: QAT (Quality Assurance Testing) + +on: + push: + branches: + - main + - "claude/**" + pull_request: + branches: + - main + +jobs: + # ── Python linting (flake8) ──────────────────────────────────────────────── + python-lint: + name: Python Lint (flake8) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install flake8 + run: pip install flake8 + + - name: Run flake8 on Release scripts + run: | + flake8 Scripts/Release/ \ + --max-line-length=120 \ + --extend-ignore=E501,W503,E302,E303 \ + --statistics \ + --count \ + --format=default + continue-on-error: true + + - name: Run flake8 on Dev Python scripts + run: | + flake8 Scripts/Dev/ \ + --max-line-length=120 \ + --extend-ignore=E501,W503,E302,E303,F401,F811 \ + --exclude=Scripts/Dev/Reference,Scripts/Dev/archive \ + --statistics \ + --count + continue-on-error: true + + - name: Run flake8 on tests + run: | + flake8 tests/ \ + --max-line-length=120 \ + --extend-ignore=E501,W503 \ + --statistics \ + --count + + # ── Shell script linting (ShellCheck) ───────────────────────────────────── + shell-lint: + name: Shell Lint (ShellCheck) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install ShellCheck + run: sudo apt-get update -q && sudo apt-get install -y shellcheck + + - name: Lint Release shell scripts + run: | + echo "=== Linting Release scripts ===" + find Scripts/Release/ -name "*.sh" -print0 | \ + xargs -0 shellcheck --severity=warning --format=tty + continue-on-error: true + + - name: Lint Dev bash scripts + run: | + echo "=== Linting Dev bash scripts ===" + find Scripts/Dev/bash_scripts/ -name "*.sh" -print0 | \ + xargs -0 shellcheck --severity=warning --format=tty + continue-on-error: true + + - name: Lint XML API scripts + run: | + echo "=== Linting XML API scripts ===" + find xml_api_calls/ -name "*.sh" -print0 | \ + xargs -0 shellcheck --severity=warning --format=tty + continue-on-error: true + + # ── JSON validation ──────────────────────────────────────────────────────── + json-validation: + name: JSON File Validation + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Validate all JSON files + run: | + FAILED=0 + while IFS= read -r -d '' file; do + if python3 -c "import json; json.load(open('$file'))" 2>/dev/null; then + echo "PASS: $file" + else + echo "FAIL: $file" + python3 -c "import json; json.load(open('$file'))" 2>&1 || true + FAILED=1 + fi + done < <(find . \ + -name "*.json" \ + -not -path "./.git/*" \ + -not -path "./Scripts/Dev/Test/results.json" \ + -not -path "./Scripts/Dev/Test/filtered_results.json" \ + -not -path "./Scripts/results.json" \ + -not -path "./Scripts/Dev/Reference/*" \ + -print0) + + if [ "$FAILED" -eq 1 ]; then + echo "One or more JSON files failed validation" + exit 1 + fi + echo "All JSON files passed validation" + + # ── YAML validation ──────────────────────────────────────────────────────── + yaml-validation: + name: YAML Validation + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install yamllint + run: pip install yamllint + + - name: Validate GitHub workflow YAML files + run: | + yamllint .github/workflows/ \ + -d "{extends: relaxed, rules: {line-length: {max: 120}}}" + + # ── Full test suite execution ────────────────────────────────────────────── + full-test-suite: + name: Full Test Suite + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install ShellCheck (for shell tests) + run: sudo apt-get update -q && sudo apt-get install -y shellcheck + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Install test dependencies + run: pip install -r requirements-test.txt + + - name: Run full test suite (excluding API tests) + run: | + pytest tests/ \ + --ignore=tests/integration/test_api_connectivity.py \ + -v \ + --tb=long \ + --junit-xml=qat-test-results.xml \ + -q + + - name: Upload QAT test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: qat-test-results + path: qat-test-results.xml + + - name: Publish test results summary + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() + with: + files: qat-test-results.xml + check_name: "QAT Test Results" + continue-on-error: true + + # ── PowerShell script analysis ───────────────────────────────────────────── + powershell-analysis: + name: PowerShell Script Analysis + runs-on: windows-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install PSScriptAnalyzer + shell: pwsh + run: Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser + + - name: Analyze PowerShell scripts + shell: pwsh + run: | + $scripts = Get-ChildItem -Path "Scripts" -Filter "*.ps1" -Recurse + $hasErrors = $false + foreach ($script in $scripts) { + Write-Host "Analyzing: $($script.FullName)" + $results = Invoke-ScriptAnalyzer -Path $script.FullName -Severity Warning,Error + if ($results) { + $results | Format-Table -AutoSize + $hasErrors = $true + } else { + Write-Host "PASS: $($script.Name)" + } + } + if ($hasErrors) { + Write-Warning "PSScriptAnalyzer found issues. Review output above." + } + continue-on-error: true + + # ── Script structure and metadata checks ────────────────────────────────── + structure-checks: + name: Script Structure Checks + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Check Python scripts have shebangs or module docstrings + run: | + echo "Checking Python script headers..." + MISSING=0 + for f in Scripts/Release/*.py; do + if ! head -5 "$f" | grep -qE '^#!|^#|^"""'; then + echo "WARNING: $f may be missing a shebang or docstring header" + MISSING=$((MISSING + 1)) + fi + done + echo "Files checked. $MISSING potential header issues found." + continue-on-error: true + + - name: Check shell scripts have shebangs + run: | + echo "Checking shell script shebangs..." + MISSING=0 + for f in Scripts/Release/*.sh; do + if ! head -1 "$f" | grep -q '^#!'; then + echo "WARNING: $f is missing a shebang line" + MISSING=$((MISSING + 1)) + fi + done + echo "Files checked. $MISSING shebangs missing." + continue-on-error: true + + - name: Verify test fixtures exist and are non-empty + run: | + echo "Verifying test fixtures..." + for f in tests/fixtures/allowlist.csv tests/fixtures/blacklist.csv tests/fixtures/glblacklist.csv; do + if [ ! -s "$f" ]; then + echo "FAIL: $f is missing or empty" + exit 1 + fi + echo "PASS: $f" + done diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml new file mode 100644 index 0000000..3ee742e --- /dev/null +++ b/.github/workflows/security-scan.yml @@ -0,0 +1,207 @@ +name: Security Scan + +on: + push: + branches: + - main + - "claude/**" + pull_request: + branches: + - main + schedule: + # Run weekly on Monday at 08:00 UTC + - cron: "0 8 * * 1" + +permissions: + contents: read + security-events: write + actions: read + +jobs: + # ── Python static security analysis (Bandit) ────────────────────────────── + python-bandit: + name: Python Security Analysis (Bandit) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install Bandit + run: pip install bandit[toml] + + - name: Run Bandit (high + medium severity) + run: | + bandit -r Scripts/ \ + -f json -o bandit-report.json \ + -ll \ + --exclude Scripts/Dev/Reference,Scripts/src/bin \ + || true + + - name: Print Bandit summary + run: | + bandit -r Scripts/ \ + -f txt \ + -ll \ + --exclude Scripts/Dev/Reference,Scripts/src/bin \ + || true + + - name: Upload Bandit report + uses: actions/upload-artifact@v4 + if: always() + with: + name: bandit-security-report + path: bandit-report.json + + # ── Shell script security analysis (ShellCheck) ─────────────────────────── + shell-shellcheck: + name: Shell Security Analysis (ShellCheck) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run ShellCheck on Release scripts + uses: ludeeus/action-shellcheck@master + with: + scandir: "./Scripts/Release" + severity: warning + format: tty + continue-on-error: true + + - name: Run ShellCheck on Dev bash scripts + uses: ludeeus/action-shellcheck@master + with: + scandir: "./Scripts/Dev/bash_scripts" + severity: warning + format: tty + continue-on-error: true + + - name: Run ShellCheck on XML API scripts + uses: ludeeus/action-shellcheck@master + with: + scandir: "./xml_api_calls" + severity: warning + format: tty + continue-on-error: true + + # ── Secret scanning (Gitleaks) ──────────────────────────────────────────── + secret-scanning: + name: Secret Scanning (Gitleaks) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Run Gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + continue-on-error: true + + # ── Python dependency audit (pip-audit) ─────────────────────────────────── + dependency-audit: + name: Dependency Vulnerability Audit (pip-audit) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install pip-audit + run: pip install pip-audit + + - name: Audit test dependencies + run: | + pip install -r requirements-test.txt + pip-audit --desc on 2>/dev/null || true + + - name: Audit all Python files for unsafe imports + run: | + echo "Scanning Python scripts for known vulnerable patterns..." + grep -rn "import pickle\|eval(\|exec(\|subprocess.call.*shell=True" \ + Scripts/ --include="*.py" || echo "No flagged patterns found." + + # ── Semgrep SAST ────────────────────────────────────────────────────────── + semgrep: + name: Semgrep SAST + runs-on: ubuntu-latest + if: github.actor != 'dependabot[bot]' + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python for Semgrep + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install Semgrep + run: pip install semgrep + + - name: Run Semgrep on Python scripts + run: | + semgrep --config p/python \ + --config p/security-audit \ + Scripts/ \ + --json -o semgrep-report.json \ + || true + semgrep --config p/python \ + --config p/security-audit \ + Scripts/ \ + --text \ + || true + + - name: Upload Semgrep report + uses: actions/upload-artifact@v4 + if: always() + with: + name: semgrep-report + path: semgrep-report.json + + # ── Credentials file check ───────────────────────────────────────────────── + credentials-check: + name: Credentials File Check + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Check for committed credential files + run: | + echo "Checking for credential files that should not be committed..." + FOUND=0 + + # Check for .veracode/credentials files (except in test fixtures) + if find . -path "./.git" -prune -o \ + -name "credentials" -path "*/.veracode/*" \ + -not -path "*/Dev/Test/*" -print | grep -q .; then + echo "WARNING: .veracode/credentials file found outside of test fixtures" + find . -path "./.git" -prune -o \ + -name "credentials" -path "*/.veracode/*" \ + -not -path "*/Dev/Test/*" -print + FOUND=1 + fi + + # Check for files with hardcoded API key patterns + if grep -rn "veracode_api_key_id\s*=\s*[a-z0-9]\{8\}" \ + --include="*.sh" --include="*.py" --include="*.ps1" \ + Scripts/ 2>/dev/null; then + echo "WARNING: Possible hardcoded API key ID found" + FOUND=1 + fi + + if [ "$FOUND" -eq 0 ]; then + echo "No credential issues detected." + fi + continue-on-error: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d54dac --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +*.egg-info/ +dist/ +build/ +.eggs/ + +# Test outputs +.pytest_cache/ +.coverage +coverage.xml +htmlcov/ +*-test-results.xml +bandit-report.json +semgrep-report.json + +# Virtual environments +.venv/ +venv/ +env/ + +# IDE +.vscode/settings.json +.idea/ + +# OS +.DS_Store +Thumbs.db + +# Credentials (never commit) +.veracode/credentials + +# Generated scan outputs +input.json +pipeline-scan-LATEST.zip +pipeline-scan.jar diff --git a/README.md b/README.md index 4b9e8ba..5f1d441 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,205 @@ -# Veracode-scripts # +# Veracode Security Testing SDK Framework -## General ## -This script is to be used as a interactive installer and agent for making differn't types of calls to Veracode's APIs and an interactive installer for setting up and configuring your Veracode SAST Products and SCA products. +[![Integration Tests](https://github.com/bnreplah/veracode-scripts/actions/workflows/integration-tests.yml/badge.svg)](https://github.com/bnreplah/veracode-scripts/actions/workflows/integration-tests.yml) +[![Security Scan](https://github.com/bnreplah/veracode-scripts/actions/workflows/security-scan.yml/badge.svg)](https://github.com/bnreplah/veracode-scripts/actions/workflows/security-scan.yml) +[![QAT](https://github.com/bnreplah/veracode-scripts/actions/workflows/qat.yml/badge.svg)](https://github.com/bnreplah/veracode-scripts/actions/workflows/qat.yml) +--- -## Includes ## -- Veracode REST and XML API calls -- Veracode REST conversion to XML API output -- SRM (Scriptable Request Modification) API Specification Configuration https://docs.veracode.com/r/Example_Script_for_Scriptable_Request_Modification_Authentication?tocId=GxBzVtHR5GnF~kPAmh0MNw +## Overview -## Use ## +A comprehensive SDK and tooling framework for Veracode application security testing. +It wraps the Veracode REST and XML APIs with helper scripts, automated analysis utilities, and an installation framework — targeting SAST, DAST, SCA, container security, and misconfiguration detection from a single, unified toolset. +### Goals +- **Correlate findings** across DAST, SAST, SCA, container findings, and security misconfigurations +- **Directed analysis** — surface overlapping data paths, common flaw sources in static analysis, and cross-scan vulnerability patterns +- **Automated recommendations** — link findings to security training modules and remediation guidance +- **Cross-platform installation** — thin installer in Bash (`.sh`), PowerShell (`.ps1`), and Go (`.exe`) to bootstrap the full toolset +--- -## Commands ## +## Workflows & Badges +| Workflow | Description | Badge | +|---|---|---| +| **Integration Tests** | Unit tests + Python script integration tests + shell script syntax/shellcheck | [![Integration Tests](https://github.com/bnreplah/veracode-scripts/actions/workflows/integration-tests.yml/badge.svg)](https://github.com/bnreplah/veracode-scripts/actions/workflows/integration-tests.yml) | +| **Security Scan** | Bandit (Python), ShellCheck, Gitleaks secret scanning, pip-audit, Semgrep SAST | [![Security Scan](https://github.com/bnreplah/veracode-scripts/actions/workflows/security-scan.yml/badge.svg)](https://github.com/bnreplah/veracode-scripts/actions/workflows/security-scan.yml) | +| **QAT** | flake8 linting, ShellCheck lint, JSON/YAML validation, PSScriptAnalyzer, full test suite | [![QAT](https://github.com/bnreplah/veracode-scripts/actions/workflows/qat.yml/badge.svg)](https://github.com/bnreplah/veracode-scripts/actions/workflows/qat.yml) | +--- +## What's Included +### Scripts/Release — Production-Ready +| Script | Type | Description | +|---|---|---| +| `DASTWebAppRequest-std.py` | Python | Format and submit Dynamic Web App scan requests from CLI or piped JSON | +| `BlackList-std.py` | Python | Build DAST blocklist/scan settings from CSV files | +| `DAST-ls-v2.sh` | Bash | List DAST scans and results with pagination and verbose reporting | +| `DAST-rescan.sh` | Bash | Trigger rescans for Dynamic Analysis | +| `SearchBuildByName.sh` | Bash | Search Veracode application builds by name across apps | +| `vdb-purl-lte.sh` | Bash | Veracode vulnerability DB PURL lookup (lite) | +| `veracode-installer.sh` | Bash | Install and configure Veracode CLI tooling | -A working repository of custom script integrations for veracode +### Scripts/Dev — In Development + +| Directory | Contents | +|---|---| +| `DASTFramework/` | Modular DAST configuration framework (request builder, status polling, hooks, API scan) | +| `DBlookup/` | CPE/PURL vulnerability database lookup utilities | +| `bash_scripts/` | SCA library search, pipeline scan, sandbox promotion, upload scripts | +| `ps_scripts/` | PowerShell scripts: Java API wrapper management, scan status monitoring | +| `Test/` | Test scripts, sample data, and debugging utilities | + +### xml_api_calls — Legacy XML API + +Sequential workflow scripts for the Veracode XML API: +`0_getapplist` → `1_getapplist` → `2_getbuildlist` → `3_getsandboxlist` → `4_detailedreport` + +--- + +## Veracode APIs Used + +| API | Purpose | +|---|---| +| **Dynamic Analysis REST API** | DAST scan creation, configuration, scheduling, status | +| **Upload/Results API (XML)** | SAST scan submission, build management, detailed reports | +| **SCA REST API** | Workspace/project scanning, library/dependency findings | +| **Identity API** | Team and user management (replacing deprecated XML Admin API) | +| **Pipeline Scan API** | CI/CD integrated scanning with pre-scan file size checks | +| **Veracode CLI** | Modern CLI wrapper for SAST, SCA, and SBOM generation | + +--- + +## Authentication + +Credentials can be supplied via: + +1. **Credentials file** — `~/.veracode/credentials`: + ```ini + [default] + veracode_api_key_id = YOUR_API_ID + veracode_api_key_secret = YOUR_API_KEY + ``` + +2. **Environment variables**: + ```bash + export VERACODE_API_ID="your-api-id" + export VERACODE_API_KEY="your-api-key" + ``` + +3. **SCA Agent token** (for `srcclr`): + ```bash + export SRCCLR_API_TOKEN="your-token" + ``` + +--- + +## Quick Start + +### Install Veracode Tooling +```bash +# Install Veracode CLI +bash Scripts/Release/veracode-installer.sh --force-install-vccli + +# Install SCA CLI agent +bash Scripts/Release/veracode-installer.sh --install-sca-cli + +# Install Java API Wrapper +bash Scripts/Release/veracode-installer.sh --install-java-api-wrapper + +# Install Pipeline Scanner +bash Scripts/Release/veracode-installer.sh --install-pipeline-scanner +``` + +### Create a DAST Analysis Request +```bash +# Interactive mode +python Scripts/Release/DASTWebAppRequest-std.py + +# Non-interactive / pipe mode (stdout JSON for use with http or curl) +python Scripts/Release/DASTWebAppRequest-std.py \ + "My-App-Scan" \ + "https://target.example.com/" \ + "owner@company.com" \ + "Security Team" \ + | http POST "https://api.veracode.com/was/configservice/v1/analyses" \ + --auth-type=veracode_hmac +``` + +### List DAST Scans +```bash +bash Scripts/Release/DAST-ls-v2.sh +``` + +### SCA Library Search +```bash +bash Scripts/Dev/bash_scripts/SCA-Library-ProjectSearch.sh "log4j" +``` + +--- + +## Running Tests + +```bash +# Install test dependencies +pip install -r requirements-test.txt + +# Run all unit tests (no credentials needed) +pytest tests/unit/ -v + +# Run all integration tests (no credentials needed) +pytest tests/integration/ --ignore=tests/integration/test_api_connectivity.py -v + +# Run full suite +pytest tests/ --ignore=tests/integration/test_api_connectivity.py -v + +# Run with coverage +pytest tests/ --ignore=tests/integration/test_api_connectivity.py \ + --cov=Scripts --cov-report=term-missing + +# Run live API connectivity tests (requires credentials) +pytest tests/integration/test_api_connectivity.py -m api -v +``` + +### Test Structure + +``` +tests/ +├── conftest.py # Shared fixtures (tmp_work_dir, paths) +├── fixtures/ +│ ├── allowlist.csv # DAST allowlist test fixture +│ ├── blacklist.csv # DAST blocklist test fixture +│ └── glblacklist.csv # Global blocklist test fixture +├── unit/ +│ ├── test_email_validation.py # Email regex validation logic +│ ├── test_schedule_helpers.py # Scan schedule helper functions +│ └── test_csv_parsing.py # CSV → JSON parsing logic +└── integration/ + ├── test_dast_web_request_script.py # DASTWebAppRequest-std.py end-to-end + ├── test_blacklist_script.py # BlackList-std.py end-to-end + ├── test_shell_scripts.py # Bash syntax + shellcheck for all .sh + └── test_api_connectivity.py # Live Veracode API calls (needs creds) +``` + +### GitHub Secrets Required (for API tests) + +| Secret | Description | +|---|---| +| `VERACODE_API_ID` | Veracode API Key ID | +| `VERACODE_API_KEY` | Veracode API Key Secret | + +--- + +## SRM (Scriptable Request Modification) + +Supports Veracode SRM API specification configuration for authenticated dynamic scans. +Reference: [Veracode SRM Documentation](https://docs.veracode.com/r/Example_Script_for_Scriptable_Request_Modification_Authentication?tocId=GxBzVtHR5GnF~kPAmh0MNw) + +--- + +## License + +See [LICENSE](LICENSE) for details. diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..97294a5 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,11 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = -v --tb=short +markers = + unit: Pure unit tests with no external dependencies + integration: Integration tests invoking scripts end-to-end + api: Tests requiring live Veracode API credentials + slow: Tests that may take significant time to run diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..a090932 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,3 @@ +# Test dependencies for the Veracode SDK framework +pytest>=7.4.0 +pytest-cov>=4.1.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9b163c0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,49 @@ +""" +Shared pytest fixtures and configuration for the Veracode SDK test suite. +""" + +import shutil +import pytest +from pathlib import Path + +REPO_ROOT = Path(__file__).parent.parent +SCRIPTS_DIR = REPO_ROOT / "Scripts" +RELEASE_DIR = SCRIPTS_DIR / "Release" +DEV_DIR = SCRIPTS_DIR / "Dev" +FIXTURES_DIR = Path(__file__).parent / "fixtures" + + +@pytest.fixture(scope="session") +def repo_root(): + """Return the repository root path.""" + return REPO_ROOT + + +@pytest.fixture(scope="session") +def release_dir(): + """Return the path to the Release scripts directory.""" + return RELEASE_DIR + + +@pytest.fixture(scope="session") +def dev_dir(): + """Return the path to the Dev scripts directory.""" + return DEV_DIR + + +@pytest.fixture(scope="session") +def fixtures_dir(): + """Return the path to the test fixtures directory.""" + return FIXTURES_DIR + + +@pytest.fixture +def tmp_work_dir(tmp_path): + """ + Create a temp working directory pre-populated with all test CSV fixtures. + Use this for running scripts that need allowlist.csv, blacklist.csv, glblacklist.csv + in the current working directory. + """ + for csv_file in FIXTURES_DIR.glob("*.csv"): + shutil.copy(csv_file, tmp_path / csv_file.name) + return tmp_path diff --git a/tests/fixtures/allowlist.csv b/tests/fixtures/allowlist.csv new file mode 100644 index 0000000..ce42d36 --- /dev/null +++ b/tests/fixtures/allowlist.csv @@ -0,0 +1,5 @@ +directory_restriction_type,http_and_https,url +NONE,TRUE,https://example.com +DIRECTORY_AND_SUBDIRECTORY,TRUE,https://api.example.com +FOLDER_ONLY,TRUE,https://www.example.com +FILE,TRUE,https://docs.example.com diff --git a/tests/fixtures/blacklist.csv b/tests/fixtures/blacklist.csv new file mode 100644 index 0000000..65abf29 --- /dev/null +++ b/tests/fixtures/blacklist.csv @@ -0,0 +1,4 @@ +directory_restriction_type,http_and_https,url +NONE,TRUE,https://blocked.example.com +FILE,FALSE,https://private.example.com +DIRECTORY_AND_SUBDIRECTORY,TRUE,https://secret.example.com diff --git a/tests/fixtures/glblacklist.csv b/tests/fixtures/glblacklist.csv new file mode 100644 index 0000000..3ad9522 --- /dev/null +++ b/tests/fixtures/glblacklist.csv @@ -0,0 +1,3 @@ +directory_restriction_type,http_and_https,url +NONE,TRUE,https://global-blocked.example.com +DIRECTORY_AND_SUBDIRECTORY,FALSE,https://internal.example.com diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_api_connectivity.py b/tests/integration/test_api_connectivity.py new file mode 100644 index 0000000..88ab519 --- /dev/null +++ b/tests/integration/test_api_connectivity.py @@ -0,0 +1,143 @@ +""" +Veracode API connectivity integration tests. + +These tests make LIVE calls to the Veracode REST/XML APIs and require +valid credentials to be configured via one of: + - Environment variables: VERACODE_API_ID and VERACODE_API_KEY + - Credentials file: ~/.veracode/credentials + +Mark: @pytest.mark.api +Skip: Automatically skipped if no credentials are found. + +Run selectively with: pytest tests/integration/test_api_connectivity.py -m api -v +""" + +import os +import subprocess +import sys +import shutil +import json +import pytest +from pathlib import Path + +pytestmark = pytest.mark.api + +RELEASE_DIR = Path(__file__).parent.parent.parent / "Scripts" / "Release" +FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" + +CREDS_FILE = Path.home() / ".veracode" / "credentials" + + +def credentials_available() -> bool: + """Return True if Veracode API credentials can be found.""" + has_env = bool( + os.environ.get("VERACODE_API_ID") and os.environ.get("VERACODE_API_KEY") + ) + return has_env or CREDS_FILE.exists() + + +skip_no_creds = pytest.mark.skipif( + not credentials_available(), + reason=( + "Veracode API credentials not found. " + "Set VERACODE_API_ID + VERACODE_API_KEY env vars " + "or configure ~/.veracode/credentials" + ), +) + + +@skip_no_creds +class TestDASTAPIConnectivity: + """Tests that verify connectivity to the Veracode Dynamic Analysis API.""" + + def test_dast_ls_returns_without_auth_error(self): + """DAST-ls-v2.sh should connect and not return a 401 auth error.""" + script = RELEASE_DIR / "DAST-ls-v2.sh" + if not script.exists(): + pytest.skip("DAST-ls-v2.sh not found") + + result = subprocess.run( + ["bash", str(script)], + capture_output=True, + text=True, + timeout=60, + env={**os.environ}, + ) + combined = result.stdout + result.stderr + assert "401" not in combined, f"Authentication error returned:\n{combined}" + assert "Unauthorized" not in combined + + def test_dast_ls_returns_json_or_status(self): + """DAST-ls-v2.sh should return JSON data or an API status message.""" + script = RELEASE_DIR / "DAST-ls-v2.sh" + if not script.exists(): + pytest.skip("DAST-ls-v2.sh not found") + + result = subprocess.run( + ["bash", str(script)], + capture_output=True, + text=True, + timeout=60, + ) + # Either valid JSON response or a recognized API response structure + combined = result.stdout + result.stderr + assert len(combined.strip()) > 0, "No output from DAST ls script" + + +@skip_no_creds +class TestSASTAPIConnectivity: + """Tests that verify connectivity to the Veracode Static Analysis API.""" + + def test_search_build_no_auth_error(self): + """SearchBuildByName.sh should connect without auth errors.""" + script = RELEASE_DIR / "SearchBuildByName.sh" + if not script.exists(): + pytest.skip("SearchBuildByName.sh not found") + + result = subprocess.run( + ["bash", str(script), "connectivity-test-app"], + capture_output=True, + text=True, + timeout=60, + input="", + ) + combined = result.stdout + result.stderr + assert "401" not in combined, f"Auth error:\n{combined}" + + +@skip_no_creds +class TestDASTRequestSubmission: + """ + Tests that attempt to format and validate a DAST analysis request + against the Veracode API schema. + + NOTE: These tests format a request JSON but do NOT submit/create scans + to avoid affecting production data. + """ + + def test_formatted_request_valid_json(self, tmp_path): + """A formatted DAST request should produce valid JSON output.""" + shutil.copy(FIXTURES_DIR / "allowlist.csv", tmp_path / "allowlist.csv") + shutil.copy(FIXTURES_DIR / "blacklist.csv", tmp_path / "blacklist.csv") + + script = RELEASE_DIR / "DASTWebAppRequest-std.py" + result = subprocess.run( + [ + sys.executable, + str(script), + "api-connectivity-test", + "https://target.example.com/", + os.environ.get("VERACODE_ORG_EMAIL", "test@example.com"), + "API Test", + ], + capture_output=True, + text=True, + cwd=str(tmp_path), + timeout=30, + ) + non_empty = [l for l in result.stdout.strip().splitlines() if l.strip()] + assert non_empty, f"No output. stderr: {result.stderr}" + + parsed = json.loads(non_empty[-1]) + assert "name" in parsed + assert "scans" in parsed diff --git a/tests/integration/test_blacklist_script.py b/tests/integration/test_blacklist_script.py new file mode 100644 index 0000000..926ccbd --- /dev/null +++ b/tests/integration/test_blacklist_script.py @@ -0,0 +1,128 @@ +""" +Integration tests for Scripts/Release/BlackList-std.py. + +Tests the script end-to-end via subprocess, verifying: + - Script exits successfully when CSV files are present + - Output contains expected scan configuration fragments + - Blacklist entries from the CSV appear in the output + - input.json is written to the working directory + +These tests do NOT require Veracode API credentials. +The script runs its built-in test() function when DEBUG=True (the default). +""" + +import csv +import sys +import shutil +import subprocess +import pytest +from pathlib import Path + +RELEASE_DIR = Path(__file__).parent.parent.parent / "Scripts" / "Release" +FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" +SCRIPT = RELEASE_DIR / "BlackList-std.py" + + +@pytest.fixture +def work_dir(tmp_path): + """Temp dir with CSV fixtures so the script can find blacklist files.""" + for f in FIXTURES_DIR.glob("*.csv"): + shutil.copy(f, tmp_path / f.name) + return tmp_path + + +def run_script(work_dir): + return subprocess.run( + [sys.executable, str(SCRIPT)], + capture_output=True, + text=True, + cwd=str(work_dir), + ) + + +# --- Execution health --- + +class TestBlacklistScriptExecution: + def test_exits_zero(self, work_dir): + result = run_script(work_dir) + assert result.returncode == 0, f"Non-zero exit:\nstdout: {result.stdout}\nstderr: {result.stderr}" + + def test_produces_output(self, work_dir): + result = run_script(work_dir) + assert result.stdout.strip() != "" + + def test_writes_input_json(self, work_dir): + run_script(work_dir) + assert (work_dir / "input.json").exists() + + def test_input_json_is_nonempty(self, work_dir): + run_script(work_dir) + content = (work_dir / "input.json").read_text() + assert content.strip() != "" + + +# --- Output content --- + +class TestBlacklistScriptOutput: + def test_output_contains_analysis_name_prefix(self, work_dir): + """The test() function prefixes the name with 'veracode-api-test-'.""" + result = run_script(work_dir) + assert "veracode-api-test-" in result.stdout + + def test_output_contains_scan_config(self, work_dir): + result = run_script(work_dir) + assert "scan_config_request" in result.stdout + + def test_output_contains_target_url(self, work_dir): + result = run_script(work_dir) + assert "target_url" in result.stdout + + def test_output_contains_veracode_test_url(self, work_dir): + """The test() function uses http://veracode.com as the target URL.""" + result = run_script(work_dir) + assert "veracode.com" in result.stdout + + def test_output_contains_org_email(self, work_dir): + """The test() function uses example@example.com as the org email.""" + result = run_script(work_dir) + assert "example@example.com" in result.stdout + + def test_output_contains_blacklist_config(self, work_dir): + result = run_script(work_dir) + assert "blacklist_configuration" in result.stdout or "black_list" in result.stdout + + def test_blacklist_urls_appear_in_output(self, work_dir): + """URLs from blacklist.csv should be present in the output.""" + with open(FIXTURES_DIR / "blacklist.csv") as f: + reader = csv.DictReader(f) + urls = [row["url"].strip() for row in reader] + + result = run_script(work_dir) + assert any(url in result.stdout for url in urls), ( + f"None of the blacklist URLs {urls} found in output:\n{result.stdout[:500]}" + ) + + +# --- CSV not found handling --- + +class TestBlacklistMissingCSV: + def test_exits_zero_without_csv_files(self, tmp_path): + """Script should not crash when CSV files are missing (graceful error handling).""" + result = subprocess.run( + [sys.executable, str(SCRIPT)], + capture_output=True, + text=True, + cwd=str(tmp_path), + ) + assert result.returncode == 0 + + def test_reports_csv_load_error_without_csv(self, tmp_path): + """When CSV files are missing the script should report load failures.""" + result = subprocess.run( + [sys.executable, str(SCRIPT)], + capture_output=True, + text=True, + cwd=str(tmp_path), + ) + combined = result.stdout + result.stderr + assert "failed to load" in combined or "veracode-api-test-" in combined diff --git a/tests/integration/test_dast_web_request_script.py b/tests/integration/test_dast_web_request_script.py new file mode 100644 index 0000000..35c1428 --- /dev/null +++ b/tests/integration/test_dast_web_request_script.py @@ -0,0 +1,190 @@ +""" +Integration tests for Scripts/Release/DASTWebAppRequest-std.py. + +Tests the script end-to-end via subprocess, verifying: + - stdout mode with CLI args produces valid JSON + - Request structure matches Veracode Dynamic Analysis API schema + - Input file (input.json) is written correctly + - Org info, scan config, and schedule are included + +These tests do NOT require Veracode API credentials. +The script is invoked with pre-set CLI args so it runs non-interactively. +""" + +import json +import sys +import shutil +import subprocess +import pytest +from pathlib import Path + +RELEASE_DIR = Path(__file__).parent.parent.parent / "Scripts" / "Release" +FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" +SCRIPT = RELEASE_DIR / "DASTWebAppRequest-std.py" + +TEST_ARGS = [ + "integration-test-analysis", + "https://target.example.com/", + "owner@example.com", + "Test Owner", +] + + +@pytest.fixture +def work_dir(tmp_path): + """Temp dir with CSV fixtures so the script can find allowlist/blocklist files.""" + for f in FIXTURES_DIR.glob("*.csv"): + shutil.copy(f, tmp_path / f.name) + return tmp_path + + +def run_script(work_dir, args=None): + """Run DASTWebAppRequest-std.py from work_dir with the given args.""" + cmd = [sys.executable, str(SCRIPT)] + (args or TEST_ARGS) + return subprocess.run(cmd, capture_output=True, text=True, cwd=str(work_dir)) + + +def extract_json(result) -> dict: + """Extract the last JSON object from the script's stdout.""" + non_empty = [l for l in result.stdout.strip().splitlines() if l.strip()] + assert non_empty, f"No output from script. stderr: {result.stderr}" + return json.loads(non_empty[-1]) + + +# --- Basic execution --- + +class TestScriptExecution: + def test_exits_zero(self, work_dir): + result = run_script(work_dir) + assert result.returncode == 0, f"Non-zero exit: {result.stderr}" + + def test_produces_stdout(self, work_dir): + result = run_script(work_dir) + assert result.stdout.strip() != "" + + def test_stdout_ends_with_valid_json(self, work_dir): + result = run_script(work_dir) + parsed = extract_json(result) + assert isinstance(parsed, dict) + + def test_writes_input_json_file(self, work_dir): + run_script(work_dir) + assert (work_dir / "input.json").exists() + + def test_input_json_matches_stdout_json(self, work_dir): + result = run_script(work_dir) + stdout_parsed = extract_json(result) + with open(work_dir / "input.json") as f: + file_parsed = json.load(f) + assert stdout_parsed == file_parsed + + +# --- Request structure --- + +class TestRequestStructure: + def test_name_field_present(self, work_dir): + result = run_script(work_dir) + parsed = extract_json(result) + assert "name" in parsed + + def test_name_matches_arg(self, work_dir): + result = run_script(work_dir) + parsed = extract_json(result) + assert parsed["name"] == TEST_ARGS[0] + + def test_scans_field_present(self, work_dir): + result = run_script(work_dir) + parsed = extract_json(result) + assert "scans" in parsed + + def test_scans_is_nonempty_list(self, work_dir): + result = run_script(work_dir) + parsed = extract_json(result) + assert isinstance(parsed["scans"], list) + assert len(parsed["scans"]) >= 1 + + def test_schedule_field_present(self, work_dir): + result = run_script(work_dir) + parsed = extract_json(result) + assert "schedule" in parsed + + def test_schedule_has_duration(self, work_dir): + result = run_script(work_dir) + parsed = extract_json(result) + assert "duration" in parsed["schedule"] + + def test_org_info_present(self, work_dir): + result = run_script(work_dir) + parsed = extract_json(result) + assert "org_info" in parsed + + def test_org_info_has_email(self, work_dir): + result = run_script(work_dir) + parsed = extract_json(result) + assert "email" in parsed["org_info"] + + def test_org_email_matches_arg(self, work_dir): + result = run_script(work_dir) + parsed = extract_json(result) + assert parsed["org_info"]["email"] == TEST_ARGS[2] + + +# --- Scan configuration --- + +class TestScanConfiguration: + def test_scan_has_scan_config_request(self, work_dir): + result = run_script(work_dir) + parsed = extract_json(result) + scan = parsed["scans"][0] + assert "scan_config_request" in scan + + def test_scan_config_has_target_url(self, work_dir): + result = run_script(work_dir) + parsed = extract_json(result) + scan_config = parsed["scans"][0]["scan_config_request"] + assert "target_url" in scan_config + + def test_target_url_matches_arg(self, work_dir): + result = run_script(work_dir) + parsed = extract_json(result) + target = parsed["scans"][0]["scan_config_request"]["target_url"] + assert target["url"] == TEST_ARGS[1] + + def test_target_url_has_http_and_https(self, work_dir): + result = run_script(work_dir) + parsed = extract_json(result) + target = parsed["scans"][0]["scan_config_request"]["target_url"] + assert "http_and_https" in target + + def test_http_and_https_is_true(self, work_dir): + result = run_script(work_dir) + parsed = extract_json(result) + target = parsed["scans"][0]["scan_config_request"]["target_url"] + # The script sets this to "true" (string) or boolean true + assert str(target["http_and_https"]).lower() == "true" + + +# --- Different analysis names --- + +class TestAnalysisNameVariants: + @pytest.mark.parametrize("name", [ + "my-app-dast-scan", + "prod-api-scan-v2", + "veracode-weekly-analysis", + ]) + def test_custom_analysis_name(self, work_dir, name): + args = [name, "https://example.com/", "scan@company.com", "Owner"] + result = run_script(work_dir, args) + parsed = extract_json(result) + assert parsed["name"] == name + + @pytest.mark.parametrize("url", [ + "https://app.example.com/", + "https://api.example.com/v1/", + "https://staging.example.com/", + ]) + def test_various_target_urls(self, work_dir, url): + args = ["test-scan", url, "test@example.com", "Owner"] + result = run_script(work_dir, args) + parsed = extract_json(result) + assert parsed["scans"][0]["scan_config_request"]["target_url"]["url"] == url diff --git a/tests/integration/test_shell_scripts.py b/tests/integration/test_shell_scripts.py new file mode 100644 index 0000000..a035287 --- /dev/null +++ b/tests/integration/test_shell_scripts.py @@ -0,0 +1,223 @@ +""" +Integration tests for shell scripts under Scripts/Release/ and Scripts/Dev/bash_scripts/. + +Tests: + - bash -n syntax validation for all .sh files + - shellcheck linting (skipped if shellcheck not installed) + - Functional smoke tests: help output, basic invocation + +These tests do NOT require Veracode API credentials. +""" + +import shutil +import subprocess +import pytest +from pathlib import Path + +RELEASE_DIR = Path(__file__).parent.parent.parent / "Scripts" / "Release" +DEV_BASH_DIR = Path(__file__).parent.parent.parent / "Scripts" / "Dev" / "bash_scripts" +XML_API_DIR = Path(__file__).parent.parent.parent / "xml_api_calls" + +_RELEASE_SCRIPTS_CANDIDATES = [ + RELEASE_DIR / "veracode-installer.sh", + RELEASE_DIR / "DAST-ls-v2.sh", + RELEASE_DIR / "DAST-ls.sh", + RELEASE_DIR / "DAST-rescan.sh", + RELEASE_DIR / "SearchBuildByName.sh", + RELEASE_DIR / "vdb-purl-lte.sh", +] + +_DEV_BASH_SCRIPTS_CANDIDATES = [ + DEV_BASH_DIR / "veracode-installer.sh", + DEV_BASH_DIR / "UploadExtended.sh", + DEV_BASH_DIR / "SAST-promoteSandbox.sh", + DEV_BASH_DIR / "SCA-Library-ProjectSearch.sh", + DEV_BASH_DIR / "pipelinescan-sandboxscan-filesizecheck.sh", +] + +# Only include scripts that actually exist on disk so ids always match values +RELEASE_SCRIPTS = [s for s in _RELEASE_SCRIPTS_CANDIDATES if s.exists()] +DEV_BASH_SCRIPTS = [s for s in _DEV_BASH_SCRIPTS_CANDIDATES if s.exists()] +ALL_SCRIPTS = RELEASE_SCRIPTS + DEV_BASH_SCRIPTS + + +# --- Bash syntax validation --- + +class TestBashSyntax: + @pytest.mark.parametrize( + "script", + [s for s in RELEASE_SCRIPTS if s.exists()], + ids=[s.name for s in RELEASE_SCRIPTS], + ) + def test_release_script_syntax(self, script): + """All Release scripts should pass bash -n syntax check.""" + result = subprocess.run( + ["bash", "-n", str(script)], + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + f"Syntax error in {script.name}:\n{result.stderr}" + ) + + @pytest.mark.parametrize( + "script", + [s for s in DEV_BASH_SCRIPTS if s.exists()], + ids=[s.name for s in DEV_BASH_SCRIPTS], + ) + def test_dev_bash_script_syntax(self, script): + """All Dev bash scripts should pass bash -n syntax check.""" + result = subprocess.run( + ["bash", "-n", str(script)], + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + f"Syntax error in {script.name}:\n{result.stderr}" + ) + + +# --- ShellCheck linting --- + +@pytest.mark.skipif( + not shutil.which("shellcheck"), + reason="shellcheck not installed — install with: sudo apt-get install shellcheck", +) +class TestShellCheck: + @pytest.mark.parametrize( + "script", + [s for s in RELEASE_SCRIPTS if s.exists()], + ids=[s.name for s in RELEASE_SCRIPTS], + ) + def test_shellcheck_release_script(self, script): + """Release scripts should pass shellcheck at warning severity.""" + result = subprocess.run( + ["shellcheck", "--severity=warning", str(script)], + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + f"shellcheck issues in {script.name}:\n{result.stdout}" + ) + + @pytest.mark.parametrize( + "script", + [s for s in DEV_BASH_SCRIPTS if s.exists()], + ids=[s.name for s in DEV_BASH_SCRIPTS], + ) + def test_shellcheck_dev_script(self, script): + """Dev bash scripts should pass shellcheck at warning severity.""" + result = subprocess.run( + ["shellcheck", "--severity=warning", str(script)], + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + f"shellcheck issues in {script.name}:\n{result.stdout}" + ) + + +# --- Functional smoke tests --- + +class TestInstallerScript: + def test_installer_help_prints_usage(self): + """Installer script should print help text when invoked with unknown args.""" + script = RELEASE_DIR / "veracode-installer.sh" + if not script.exists(): + pytest.skip("veracode-installer.sh not found in Release") + result = subprocess.run( + ["bash", str(script), "--help"], + capture_output=True, + text=True, + ) + # --help hits the *) case which calls help() and exits 1 + combined = result.stdout + result.stderr + assert "Veracode" in combined or "install" in combined.lower(), ( + f"Expected help output, got:\n{combined}" + ) + + def test_installer_help_lists_options(self): + """Help output should list known installer flags.""" + script = RELEASE_DIR / "veracode-installer.sh" + if not script.exists(): + pytest.skip("veracode-installer.sh not found in Release") + result = subprocess.run( + ["bash", str(script), "--help"], + capture_output=True, + text=True, + ) + combined = result.stdout + result.stderr + assert "--install-sca-ci" in combined or "--install-sca-cli" in combined + + +class TestSearchBuildByName: + def test_script_has_valid_syntax(self): + script = RELEASE_DIR / "SearchBuildByName.sh" + if not script.exists(): + pytest.skip("SearchBuildByName.sh not found") + result = subprocess.run( + ["bash", "-n", str(script)], + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"Syntax error:\n{result.stderr}" + + def test_script_exists_and_is_readable(self): + script = RELEASE_DIR / "SearchBuildByName.sh" + if not script.exists(): + pytest.skip("SearchBuildByName.sh not found") + assert script.stat().st_size > 0 + + +class TestDASTRescan: + def test_script_has_valid_syntax(self): + script = RELEASE_DIR / "DAST-rescan.sh" + if not script.exists(): + pytest.skip("DAST-rescan.sh not found") + result = subprocess.run( + ["bash", "-n", str(script)], + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"Syntax error:\n{result.stderr}" + + +class TestDASTLsV2: + def test_script_has_valid_syntax(self): + script = RELEASE_DIR / "DAST-ls-v2.sh" + if not script.exists(): + pytest.skip("DAST-ls-v2.sh not found") + result = subprocess.run( + ["bash", "-n", str(script)], + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"Syntax error:\n{result.stderr}" + + def test_script_is_nonempty(self): + script = RELEASE_DIR / "DAST-ls-v2.sh" + if not script.exists(): + pytest.skip("DAST-ls-v2.sh not found") + assert script.stat().st_size > 100 + + +# --- XML API scripts --- + +class TestXMLAPIScripts: + XML_SCRIPTS = list(XML_API_DIR.glob("*.sh")) if XML_API_DIR.exists() else [] + + @pytest.mark.parametrize( + "script", + XML_SCRIPTS, + ids=[s.name for s in XML_SCRIPTS], + ) + def test_xml_api_script_syntax(self, script): + """XML API helper scripts should have valid bash syntax.""" + result = subprocess.run( + ["bash", "-n", str(script)], + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + f"Syntax error in {script.name}:\n{result.stderr}" + ) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_csv_parsing.py b/tests/unit/test_csv_parsing.py new file mode 100644 index 0000000..1434a25 --- /dev/null +++ b/tests/unit/test_csv_parsing.py @@ -0,0 +1,177 @@ +""" +Unit tests for CSV-to-JSON parsing logic. + +Tests the allowlist and blacklist CSV parsing behavior that feeds into +DASTWebAppRequest-std.py and BlackList-std.py, verified against fixture files +and controlled temporary CSVs. +""" + +import csv +import json +import pytest +from pathlib import Path + +FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" + +VALID_RESTRICTION_TYPES = { + "NONE", + "FILE", + "FOLDER_ONLY", + "DIRECTORY_AND_SUBDIRECTORY", +} + + +# --- Helpers mirroring script CSV parsing logic --- + +def parse_allowlist_csv(csv_path: str) -> dict: + """ + Mirror of allowlistConfigCSVtoJSON() from DASTWebAppRequest-std.py. + Returns {"allowed_hosts": [...]} or raises on error. + """ + allowed_hosts = [] + with open(csv_path, "r") as f: + reader = csv.DictReader(f) + for row in reader: + allowed_hosts.append({ + "directory_restriction_type": row["directory_restriction_type"], + "http_and_https": str(row["http_and_https"]).strip().lower(), + "url": row["url"].strip(), + }) + return {"allowed_hosts": allowed_hosts} + + +def parse_blacklist_csv(csv_path: str) -> list: + """ + Mirror of blacklistConfigCSVtoJSON() from BlackList-std.py. + Returns a list of blacklist entry dicts. + """ + entries = [] + with open(csv_path, "r") as f: + reader = csv.DictReader(f) + for row in reader: + entries.append({ + "directory_restriction_type": row["directory_restriction_type"], + "http_and_https": str(row["http_and_https"]).strip().lower(), + "url": row["url"].strip(), + }) + return entries + + +# --- Allowlist fixture tests --- + +class TestAllowlistCSVFixture: + def test_fixture_loads_without_error(self): + result = parse_allowlist_csv(FIXTURES_DIR / "allowlist.csv") + assert result is not None + + def test_result_has_allowed_hosts_key(self): + result = parse_allowlist_csv(FIXTURES_DIR / "allowlist.csv") + assert "allowed_hosts" in result + + def test_fixture_has_multiple_entries(self): + result = parse_allowlist_csv(FIXTURES_DIR / "allowlist.csv") + assert len(result["allowed_hosts"]) >= 2 + + def test_each_entry_has_required_fields(self): + result = parse_allowlist_csv(FIXTURES_DIR / "allowlist.csv") + for host in result["allowed_hosts"]: + assert "directory_restriction_type" in host + assert "http_and_https" in host + assert "url" in host + + def test_urls_are_nonempty(self): + result = parse_allowlist_csv(FIXTURES_DIR / "allowlist.csv") + for host in result["allowed_hosts"]: + assert host["url"] != "" + + def test_http_and_https_is_boolean_string(self): + result = parse_allowlist_csv(FIXTURES_DIR / "allowlist.csv") + for host in result["allowed_hosts"]: + assert host["http_and_https"].lower() in ("true", "false") + + def test_restriction_types_are_valid(self): + result = parse_allowlist_csv(FIXTURES_DIR / "allowlist.csv") + for host in result["allowed_hosts"]: + assert host["directory_restriction_type"] in VALID_RESTRICTION_TYPES + + +# --- Blacklist fixture tests --- + +class TestBlacklistCSVFixture: + def test_fixture_loads_without_error(self): + result = parse_blacklist_csv(FIXTURES_DIR / "blacklist.csv") + assert result is not None + + def test_fixture_has_entries(self): + result = parse_blacklist_csv(FIXTURES_DIR / "blacklist.csv") + assert len(result) >= 1 + + def test_each_entry_has_required_fields(self): + result = parse_blacklist_csv(FIXTURES_DIR / "blacklist.csv") + for entry in result: + assert "directory_restriction_type" in entry + assert "http_and_https" in entry + assert "url" in entry + + def test_restriction_types_are_valid(self): + result = parse_blacklist_csv(FIXTURES_DIR / "blacklist.csv") + for entry in result: + assert entry["directory_restriction_type"] in VALID_RESTRICTION_TYPES + + def test_http_and_https_is_boolean_string(self): + result = parse_blacklist_csv(FIXTURES_DIR / "blacklist.csv") + for entry in result: + assert entry["http_and_https"].lower() in ("true", "false") + + +# --- Edge case tests with tmp files --- + +class TestCSVEdgeCases: + def test_empty_allowlist_returns_empty_list(self, tmp_path): + csv_file = tmp_path / "allowlist.csv" + csv_file.write_text("directory_restriction_type,http_and_https,url\n") + result = parse_allowlist_csv(csv_file) + assert result["allowed_hosts"] == [] + + def test_empty_blacklist_returns_empty_list(self, tmp_path): + csv_file = tmp_path / "blacklist.csv" + csv_file.write_text("directory_restriction_type,http_and_https,url\n") + result = parse_blacklist_csv(csv_file) + assert result == [] + + def test_single_allowlist_entry(self, tmp_path): + csv_file = tmp_path / "allowlist.csv" + csv_file.write_text( + "directory_restriction_type,http_and_https,url\n" + "NONE,TRUE,https://single.example.com\n" + ) + result = parse_allowlist_csv(csv_file) + assert len(result["allowed_hosts"]) == 1 + assert result["allowed_hosts"][0]["url"] == "https://single.example.com" + + def test_multiple_blacklist_entries(self, tmp_path): + csv_file = tmp_path / "blacklist.csv" + csv_file.write_text( + "directory_restriction_type,http_and_https,url\n" + "NONE,TRUE,https://blocked1.example.com\n" + "FILE,FALSE,https://blocked2.example.com\n" + "DIRECTORY_AND_SUBDIRECTORY,TRUE,https://blocked3.example.com\n" + ) + result = parse_blacklist_csv(csv_file) + assert len(result) == 3 + + def test_missing_file_raises_error(self, tmp_path): + with pytest.raises((FileNotFoundError, OSError)): + parse_allowlist_csv(tmp_path / "nonexistent.csv") + + def test_result_serializes_to_json(self): + result = parse_allowlist_csv(FIXTURES_DIR / "allowlist.csv") + serialized = json.dumps(result) + assert isinstance(serialized, str) + reparsed = json.loads(serialized) + assert reparsed["allowed_hosts"] == result["allowed_hosts"] + + def test_glblacklist_fixture_loads(self): + result = parse_blacklist_csv(FIXTURES_DIR / "glblacklist.csv") + assert isinstance(result, list) + assert len(result) >= 1 diff --git a/tests/unit/test_email_validation.py b/tests/unit/test_email_validation.py new file mode 100644 index 0000000..75114a2 --- /dev/null +++ b/tests/unit/test_email_validation.py @@ -0,0 +1,69 @@ +""" +Unit tests for email validation logic used in DASTWebAppRequest-std.py. + +The is_valid_email() function uses the pattern r'^[\\w\\.-]+@[\\w\\.-]+\\.\\w+$'. +These tests verify that behavior without importing the script (which has +module-level side effects including stdin prompts and file I/O). +""" + +import re +import pytest + +# Pattern mirrored from Scripts/Release/DASTWebAppRequest-std.py +EMAIL_PATTERN = r'^[\w\.-]+@[\w\.-]+\.\w+$' + + +def is_valid_email(email: str) -> bool: + """Mirror of is_valid_email() from DASTWebAppRequest-std.py.""" + return bool(re.match(EMAIL_PATTERN, email)) + + +class TestValidEmails: + def test_simple_email(self): + assert is_valid_email("user@example.com") + + def test_subdomain_email(self): + assert is_valid_email("user@mail.example.com") + + def test_dot_in_local_part(self): + assert is_valid_email("first.last@example.com") + + def test_hyphen_in_domain(self): + assert is_valid_email("user@my-company.com") + + def test_numeric_local_part(self): + assert is_valid_email("user123@example.com") + + def test_underscore_in_local(self): + assert is_valid_email("user_name@example.com") + + def test_multi_level_tld(self): + assert is_valid_email("user@example.co.uk") + + +class TestInvalidEmails: + def test_no_at_sign(self): + assert not is_valid_email("userexample.com") + + def test_no_domain_after_at(self): + assert not is_valid_email("user@") + + def test_no_tld(self): + assert not is_valid_email("user@example") + + def test_empty_string(self): + assert not is_valid_email("") + + def test_only_at_sign(self): + assert not is_valid_email("@") + + def test_plus_sign_not_supported(self): + # The script's pattern uses [\w\.-] which does NOT include + + # This documents that the pattern rejects plus-addressed emails + assert not is_valid_email("user+tag@example.com") + + def test_spaces_rejected(self): + assert not is_valid_email("user @example.com") + + def test_double_at(self): + assert not is_valid_email("user@@example.com") diff --git a/tests/unit/test_schedule_helpers.py b/tests/unit/test_schedule_helpers.py new file mode 100644 index 0000000..ee6bb46 --- /dev/null +++ b/tests/unit/test_schedule_helpers.py @@ -0,0 +1,137 @@ +""" +Unit tests for scan scheduling helper functions. + +These mirror the schedule logic from Scripts/Release/DASTWebAppRequest-std.py, +tested in isolation to avoid module-level side effects. +""" + +import pytest + + +# --- Mirrors of schedule helpers from DASTWebAppRequest-std.py --- + +def _is_true(value, var_true: bool = False): + if str(value).casefold() == "true": + return True if var_true else "true" + return False if var_true else "false" + + +def schedule_now(now_b: str = _is_true(False), days: int = 1) -> dict: + """Mirror of scheduleNow() from DASTWebAppRequest-std.py.""" + schedule = {"schedule": { + "now": now_b, + "duration": { + "length": str(days), + "unit": "DAY" + } + }} + if now_b == _is_true(True): + schedule["schedule"]["scheduled"] = True + return schedule + + +def schedule_scan(start_now: bool = False, length: int = 1, unit: str = "DAY", + recurring: bool = False, recurrence_type: str = "WEEKLY", + schedule_end_after: int = 2, recurrence_interval: int = 1, + day_of_week: str = "FRIDAY") -> dict: + """Mirror of scheduleScan() from DASTWebAppRequest-std.py.""" + schedule = {"schedule": { + "duration": {"length": length, "unit": unit} + }} + if recurring: + schedule["schedule"]["scan_recurrence_schedule"] = { + "recurrence_type": recurrence_type, + "schedule_end_after": schedule_end_after, + "recurrence_interval": recurrence_interval, + "day_of_week": day_of_week + } + if start_now: + schedule["schedule"].update({"scheduled": True, "now": True}) + return schedule + + +# --- Tests --- + +class TestIsTrue: + def test_true_string_returns_true_string(self): + assert _is_true("true") == "true" + + def test_false_string_returns_false_string(self): + assert _is_true("false") == "false" + + def test_true_bool_with_var_true_returns_python_true(self): + assert _is_true(True, var_true=True) is True + + def test_false_bool_with_var_true_returns_python_false(self): + assert _is_true(False, var_true=True) is False + + def test_case_insensitive_TRUE(self): + assert _is_true("TRUE") == "true" + + def test_case_insensitive_True(self): + assert _is_true("True") == "true" + + +class TestScheduleNow: + def test_returns_dict(self): + result = schedule_now() + assert isinstance(result, dict) + + def test_has_schedule_key(self): + result = schedule_now() + assert "schedule" in result + + def test_default_now_is_false_string(self): + result = schedule_now() + assert result["schedule"]["now"] == "false" + + def test_now_true_sets_scheduled_flag(self): + result = schedule_now(now_b="true") + assert result["schedule"].get("scheduled") is True + + def test_duration_unit_is_day(self): + result = schedule_now() + assert result["schedule"]["duration"]["unit"] == "DAY" + + def test_custom_days_reflected_as_string(self): + result = schedule_now(days=5) + assert result["schedule"]["duration"]["length"] == "5" + + def test_one_day_default(self): + result = schedule_now() + assert result["schedule"]["duration"]["length"] == "1" + + +class TestScheduleScan: + def test_returns_dict(self): + result = schedule_scan() + assert isinstance(result, dict) + + def test_has_schedule_key(self): + result = schedule_scan() + assert "schedule" in result + + def test_default_unit_is_day(self): + result = schedule_scan() + assert result["schedule"]["duration"]["unit"] == "DAY" + + def test_start_now_sets_flags(self): + result = schedule_scan(start_now=True) + assert result["schedule"]["now"] is True + assert result["schedule"]["scheduled"] is True + + def test_recurring_adds_recurrence_block(self): + result = schedule_scan(recurring=True) + assert "scan_recurrence_schedule" in result["schedule"] + + def test_recurring_day_of_week(self): + result = schedule_scan(recurring=True, day_of_week="MONDAY") + assert result["schedule"]["scan_recurrence_schedule"]["day_of_week"] == "MONDAY" + + def test_non_recurring_has_no_recurrence_block(self): + result = schedule_scan(recurring=False) + assert "scan_recurrence_schedule" not in result["schedule"] + + def test_custom_length(self): + result = schedule_scan(length=7) + assert result["schedule"]["duration"]["length"] == 7 From e1296a8c94f5c932bbf861ec688863da72760b86 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 17:48:07 +0000 Subject: [PATCH 2/3] Add CI/CD onboarding templates and reusable Veracode workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit templates/workflows/ — copy any file to .github/workflows/ in a target repo: - pipeline-scan.yml fast SAST inline scan, every PR - policy-scan-sast.yml full policy scan, main/release branches - sandbox-scan-promote.yml feature branch sandbox + promote on merge - sca-agent-scan.yml SCA dependency scan via srcclr agent - dast-web-scan.yml DAST dynamic scan via DASTWebAppRequest-std.py - container-scan.yml Docker image scan via Veracode CLI or action - all-scans-devops.yml full DevSecOps pipeline (build → all scans → summary) - by-language/java-maven.yml - by-language/java-gradle.yml - by-language/nodejs.yml - by-language/python.yml - by-language/dotnet.yml - by-language/go.yml .github/workflows/ — reusable/callable workflows (any repo can call these): - reusable-pipeline-scan.yml workflow_call with inputs, secrets, outputs - reusable-policy-scan.yml workflow_call supporting sandbox or policy mode All templates include CUSTOMIZE/TODO markers, secrets documentation, packaging notes per language, and inline option comments (e.g. Action vs Java wrapper, binary vs source zip for Go). https://claude.ai/code/session_015pBhzcxzBhLcAujgXrwsaz --- .github/workflows/reusable-pipeline-scan.yml | 150 +++++++++++++++ .github/workflows/reusable-policy-scan.yml | 159 ++++++++++++++++ templates/workflows/README.md | 66 +++++++ templates/workflows/all-scans-devops.yml | 180 ++++++++++++++++++ templates/workflows/by-language/dotnet.yml | 134 +++++++++++++ templates/workflows/by-language/go.yml | 137 +++++++++++++ .../workflows/by-language/java-gradle.yml | 120 ++++++++++++ .../workflows/by-language/java-maven.yml | 131 +++++++++++++ templates/workflows/by-language/nodejs.yml | 131 +++++++++++++ templates/workflows/by-language/python.yml | 132 +++++++++++++ templates/workflows/container-scan.yml | 145 ++++++++++++++ templates/workflows/dast-web-scan.yml | 167 ++++++++++++++++ templates/workflows/pipeline-scan.yml | 100 ++++++++++ templates/workflows/policy-scan-sast.yml | 138 ++++++++++++++ templates/workflows/sandbox-scan-promote.yml | 154 +++++++++++++++ templates/workflows/sca-agent-scan.yml | 113 +++++++++++ 16 files changed, 2157 insertions(+) create mode 100644 .github/workflows/reusable-pipeline-scan.yml create mode 100644 .github/workflows/reusable-policy-scan.yml create mode 100644 templates/workflows/README.md create mode 100644 templates/workflows/all-scans-devops.yml create mode 100644 templates/workflows/by-language/dotnet.yml create mode 100644 templates/workflows/by-language/go.yml create mode 100644 templates/workflows/by-language/java-gradle.yml create mode 100644 templates/workflows/by-language/java-maven.yml create mode 100644 templates/workflows/by-language/nodejs.yml create mode 100644 templates/workflows/by-language/python.yml create mode 100644 templates/workflows/container-scan.yml create mode 100644 templates/workflows/dast-web-scan.yml create mode 100644 templates/workflows/pipeline-scan.yml create mode 100644 templates/workflows/policy-scan-sast.yml create mode 100644 templates/workflows/sandbox-scan-promote.yml create mode 100644 templates/workflows/sca-agent-scan.yml diff --git a/.github/workflows/reusable-pipeline-scan.yml b/.github/workflows/reusable-pipeline-scan.yml new file mode 100644 index 0000000..ce5774e --- /dev/null +++ b/.github/workflows/reusable-pipeline-scan.yml @@ -0,0 +1,150 @@ +# ────────────────────────────────────────────────────────────────────────────── +# REUSABLE WORKFLOW: Veracode Pipeline Scan (callable from any repo) +# ────────────────────────────────────────────────────────────────────────────── +# Call this from another repo's workflow: +# +# jobs: +# security: +# uses: bnreplah/veracode-scripts/.github/workflows/reusable-pipeline-scan.yml@main +# secrets: inherit +# with: +# artifact_path: "target/app.jar" +# app_name: "My Application" +# fail_build: false +# +# Required secrets (passed via `secrets: inherit` or explicit mapping): +# VERACODE_API_ID +# VERACODE_API_KEY +# ────────────────────────────────────────────────────────────────────────────── + +name: Reusable — Veracode Pipeline Scan + +on: + workflow_call: + inputs: + # Path to the compiled/packaged artifact to scan + artifact_path: + description: "Path to the artifact file to scan (jar, war, zip, dll, exe)" + required: true + type: string + + # Veracode application name (used for labeling results) + app_name: + description: "Application name for scan labeling" + required: false + type: string + default: "${{ github.repository }}" + + # Whether to fail the calling workflow on findings above severity threshold + fail_build: + description: "Fail the workflow if findings exceed severity threshold" + required: false + type: boolean + default: false + + # Severity threshold when fail_build is true + fail_on_severity: + description: "Comma-separated severities that trigger a build failure" + required: false + type: string + default: "Very High, High" + + # Optional: baseline results file artifact name (from a previous run) + baseline_artifact: + description: "Name of a GitHub artifact containing a baseline results.json" + required: false + type: string + default: "" + + # Optional: Veracode policy to evaluate against + request_policy: + description: "Veracode policy name to evaluate results against" + required: false + type: string + default: "" + + # Runner OS — override if your artifact needs Windows (e.g. .NET) + runner: + description: "GitHub-hosted runner image" + required: false + type: string + default: "ubuntu-latest" + + secrets: + VERACODE_API_ID: + description: "Veracode API Key ID" + required: true + VERACODE_API_KEY: + description: "Veracode API Key Secret" + required: true + + outputs: + results_artifact: + description: "Name of the artifact containing pipeline scan results" + value: ${{ jobs.pipeline-scan.outputs.results_artifact }} + scan_status: + description: "Pipeline scan job result (success/failure/skipped)" + value: ${{ jobs.pipeline-scan.result }} + +jobs: + pipeline-scan: + name: Veracode Pipeline Scan — ${{ inputs.app_name }} + runs-on: ${{ inputs.runner }} + outputs: + results_artifact: pipeline-scan-results-${{ github.run_id }} + + steps: + - name: Checkout calling repository + uses: actions/checkout@v4 + + # Download artifact if uploaded by a preceding build job + - name: Download scan artifact + uses: actions/download-artifact@v4 + with: + name: scan-artifact + path: . + continue-on-error: true # OK if artifact doesn't exist (file may be in workspace) + + # Optionally download a baseline file for delta-only reporting + - name: Download baseline artifact + if: inputs.baseline_artifact != '' + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.baseline_artifact }} + path: . + continue-on-error: true + + - name: Veracode Pipeline Scan + id: scan + uses: veracode/pipeline-scan-action@v1 + with: + vid: ${{ secrets.VERACODE_API_ID }} + vkey: ${{ secrets.VERACODE_API_KEY }} + file: ${{ inputs.artifact_path }} + fail_build: ${{ inputs.fail_build }} + fail_on_severity: ${{ inputs.fail_on_severity }} + request_policy: ${{ inputs.request_policy }} + baseline_file: ${{ inputs.baseline_artifact != '' && 'results.json' || '' }} + + - name: Upload pipeline scan results + uses: actions/upload-artifact@v4 + if: always() + with: + name: pipeline-scan-results-${{ github.run_id }} + path: | + results.json + filtered_results.json + retention-days: 30 + + - name: Write step summary + if: always() + run: | + echo "## Veracode Pipeline Scan — ${{ inputs.app_name }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY + echo "|---|---|" >> $GITHUB_STEP_SUMMARY + echo "| Artifact | \`${{ inputs.artifact_path }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Fail Build | ${{ inputs.fail_build }} |" >> $GITHUB_STEP_SUMMARY + echo "| Repository | ${{ github.repository }} |" >> $GITHUB_STEP_SUMMARY + echo "| Ref | ${{ github.ref_name }} |" >> $GITHUB_STEP_SUMMARY + echo "| Run | ${{ github.run_id }} |" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/reusable-policy-scan.yml b/.github/workflows/reusable-policy-scan.yml new file mode 100644 index 0000000..cd0fd0c --- /dev/null +++ b/.github/workflows/reusable-policy-scan.yml @@ -0,0 +1,159 @@ +# ────────────────────────────────────────────────────────────────────────────── +# REUSABLE WORKFLOW: Veracode Policy Scan (callable from any repo) +# ────────────────────────────────────────────────────────────────────────────── +# Call this from another repo's workflow: +# +# jobs: +# security: +# uses: bnreplah/veracode-scripts/.github/workflows/reusable-policy-scan.yml@main +# secrets: inherit +# with: +# artifact_path: "target/app.jar" +# app_name: "My Application" +# sandbox_name: "" # leave blank for policy scan +# +# Required secrets (passed via `secrets: inherit` or explicit mapping): +# VERACODE_API_ID +# VERACODE_API_KEY +# ────────────────────────────────────────────────────────────────────────────── + +name: Reusable — Veracode Policy Scan + +on: + workflow_call: + inputs: + artifact_path: + description: "Path to the artifact to upload and scan" + required: true + type: string + + app_name: + description: "Veracode application profile name" + required: true + type: string + + # Leave blank to run a policy (main) scan; provide a name to scan in sandbox + sandbox_name: + description: "Sandbox name (blank = policy scan, non-blank = sandbox scan)" + required: false + type: string + default: "" + + # Build version label shown in the Veracode platform + build_version: + description: "Build/version label in the Veracode platform" + required: false + type: string + default: "${{ github.ref_name }}-${{ github.run_number }}" + + # Create sandbox if it doesn't exist (only relevant when sandbox_name set) + create_sandbox: + description: "Create the sandbox if it doesn't already exist" + required: false + type: boolean + default: true + + # When true, block the calling workflow until scan results are ready + wait_for_scan: + description: "Wait for scan to complete before returning" + required: false + type: boolean + default: false + + # When true, fail the workflow if the policy is not passed + fail_build: + description: "Fail the workflow when the policy result is not passed" + required: false + type: boolean + default: false + + # Behaviour when an incomplete scan exists: 0=cancel, 1=delete, 2=ignore + delete_incomplete: + description: "How to handle an existing incomplete scan (0/1/2)" + required: false + type: string + default: "1" + + runner: + description: "GitHub-hosted runner image" + required: false + type: string + default: "ubuntu-latest" + + secrets: + VERACODE_API_ID: + required: true + VERACODE_API_KEY: + required: true + + outputs: + scan_status: + description: "Policy scan job result (success/failure/skipped)" + value: ${{ jobs.policy-scan.result }} + scan_type: + description: "Whether this ran as a sandbox or policy scan" + value: ${{ jobs.policy-scan.outputs.scan_type }} + +jobs: + policy-scan: + name: > + Veracode ${{ inputs.sandbox_name != '' && 'Sandbox' || 'Policy' }} Scan + — ${{ inputs.app_name }} + runs-on: ${{ inputs.runner }} + outputs: + scan_type: ${{ steps.scan-type.outputs.type }} + + steps: + - name: Checkout calling repository + uses: actions/checkout@v4 + + - name: Download scan artifact + uses: actions/download-artifact@v4 + with: + name: scan-artifact + path: . + continue-on-error: true + + - name: Determine scan type + id: scan-type + run: | + if [ -n "${{ inputs.sandbox_name }}" ]; then + echo "type=sandbox" >> $GITHUB_OUTPUT + echo "Running SANDBOX scan in: ${{ inputs.sandbox_name }}" + else + echo "type=policy" >> $GITHUB_OUTPUT + echo "Running POLICY scan for: ${{ inputs.app_name }}" + fi + + - name: Veracode Upload and Scan + uses: veracode/veracode-uploadandscan-action@master + with: + appname: ${{ inputs.app_name }} + createprofile: true + filepath: ${{ inputs.artifact_path }} + version: ${{ inputs.build_version }} + vid: ${{ secrets.VERACODE_API_ID }} + vkey: ${{ secrets.VERACODE_API_KEY }} + sandboxname: ${{ inputs.sandbox_name }} + createsandbox: ${{ inputs.create_sandbox }} + deleteincompletescan: ${{ inputs.delete_incomplete }} + waitForScan: ${{ inputs.wait_for_scan }} + failbuild: ${{ inputs.fail_build }} + + - name: Write step summary + if: always() + run: | + SCAN_TYPE="${{ steps.scan-type.outputs.type }}" + echo "## Veracode ${SCAN_TYPE^} Scan — ${{ inputs.app_name }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY + echo "|---|---|" >> $GITHUB_STEP_SUMMARY + echo "| Scan Type | ${SCAN_TYPE} |" >> $GITHUB_STEP_SUMMARY + echo "| Application | ${{ inputs.app_name }} |" >> $GITHUB_STEP_SUMMARY + if [ -n "${{ inputs.sandbox_name }}" ]; then + echo "| Sandbox | ${{ inputs.sandbox_name }} |" >> $GITHUB_STEP_SUMMARY + fi + echo "| Version | ${{ inputs.build_version }} |" >> $GITHUB_STEP_SUMMARY + echo "| Wait For Results | ${{ inputs.wait_for_scan }} |" >> $GITHUB_STEP_SUMMARY + echo "| Fail Build | ${{ inputs.fail_build }} |" >> $GITHUB_STEP_SUMMARY + echo "| Repository | ${{ github.repository }} |" >> $GITHUB_STEP_SUMMARY diff --git a/templates/workflows/README.md b/templates/workflows/README.md new file mode 100644 index 0000000..ea30aed --- /dev/null +++ b/templates/workflows/README.md @@ -0,0 +1,66 @@ +# Veracode CI/CD Workflow Templates + +Copy any template from this directory into your repo's `.github/workflows/` folder, then follow the `# CUSTOMIZE:` and `# TODO:` markers to tailor it. + +## Templates + +### By Scan Type + +| File | Scan Type | When to Use | +|---|---|---| +| [`pipeline-scan.yml`](pipeline-scan.yml) | SAST (fast, inline) | Every PR and commit — fast feedback, no policy gate | +| [`policy-scan-sast.yml`](policy-scan-sast.yml) | SAST (full policy) | Main/release branches — authoritative policy result | +| [`sandbox-scan-promote.yml`](sandbox-scan-promote.yml) | SAST Sandbox | Feature branches — scan in sandbox, promote to policy on merge | +| [`sca-agent-scan.yml`](sca-agent-scan.yml) | SCA (dependencies) | Every PR — catch vulnerable OSS libraries | +| [`dast-web-scan.yml`](dast-web-scan.yml) | DAST (dynamic) | Scheduled or manual — test running web application | +| [`container-scan.yml`](container-scan.yml) | Container / IaC | On image build — scan Docker image layers | +| [`all-scans-devops.yml`](all-scans-devops.yml) | All scans | Full DevOps pipeline with build → all security gates | + +### By Language (Build + Scan) + +| File | Language | Package Manager | +|---|---|---| +| [`by-language/java-maven.yml`](by-language/java-maven.yml) | Java | Maven | +| [`by-language/java-gradle.yml`](by-language/java-gradle.yml) | Java | Gradle | +| [`by-language/nodejs.yml`](by-language/nodejs.yml) | JavaScript / TypeScript | npm / yarn | +| [`by-language/python.yml`](by-language/python.yml) | Python | pip | +| [`by-language/dotnet.yml`](by-language/dotnet.yml) | C# / .NET | dotnet CLI | +| [`by-language/go.yml`](by-language/go.yml) | Go | go build | + +### Reusable / Callable + +The two reusable workflows live in `.github/workflows/` so any repo can call them: + +```yaml +jobs: + security: + uses: bnreplah/veracode-scripts/.github/workflows/reusable-pipeline-scan.yml@main + secrets: inherit + with: + artifact_path: "target/app.jar" + app_name: "My Application" +``` + +See `.github/workflows/reusable-pipeline-scan.yml` and `.github/workflows/reusable-policy-scan.yml`. + +--- + +## Required GitHub Secrets + +Set these in your repo under **Settings → Secrets and variables → Actions**: + +| Secret | Required By | Description | +|---|---|---| +| `VERACODE_API_ID` | All scan types | Veracode API Key ID | +| `VERACODE_API_KEY` | All scan types | Veracode API Key Secret | +| `SRCCLR_API_TOKEN` | SCA scans | SourceClear / SCA agent token | + +--- + +## Quick Onboarding Checklist + +1. Copy the template(s) matching your stack to `.github/workflows/` +2. Add `VERACODE_API_ID`, `VERACODE_API_KEY` (and `SRCCLR_API_TOKEN` for SCA) to repo secrets +3. Search for `# CUSTOMIZE:` in the template and update every instance +4. Search for `# TODO:` and complete every action item +5. Push to trigger your first scan diff --git a/templates/workflows/all-scans-devops.yml b/templates/workflows/all-scans-devops.yml new file mode 100644 index 0000000..81c24f1 --- /dev/null +++ b/templates/workflows/all-scans-devops.yml @@ -0,0 +1,180 @@ +# ────────────────────────────────────────────────────────────────────────────── +# TEMPLATE: Veracode Full DevSecOps Pipeline — All Scans +# Combines: Pipeline Scan (PR) + SCA (PR) + Policy SAST (main) + Container (main) +# +# JOB FLOW: +# PR: build → pipeline-scan (SAST fast) + sca-scan (parallel) +# main: build → pipeline-scan + sca-scan → policy-scan → container-scan +# +# RUNTIME: PR ≈ 5–10 min | main ≈ 30 min – several hours +# ────────────────────────────────────────────────────────────────────────────── +# REQUIRED SECRETS: +# VERACODE_API_ID - Veracode API Key ID +# VERACODE_API_KEY - Veracode API Key Secret +# SRCCLR_API_TOKEN - SCA agent token +# ────────────────────────────────────────────────────────────────────────────── + +name: Veracode DevSecOps Pipeline + +on: + push: + branches: + - main + - master + - "release/**" + pull_request: + types: [opened, synchronize, reopened] + +env: + # CUSTOMIZE: Veracode application profile name + VERACODE_APP_NAME: "YOUR_APP_NAME" + # CUSTOMIZE: path to your compiled/packaged artifact + ARTIFACT_PATH: "app.zip" + BUILD_VERSION: "${{ github.ref_name }}-${{ github.run_number }}" + # CUSTOMIZE: Docker image name (for container scan on main) + IMAGE_NAME: "your-app-image" + +jobs: + # ── 1. BUILD ──────────────────────────────────────────────────────────────── + build: + name: Build + runs-on: ubuntu-latest + outputs: + artifact_name: ${{ steps.artifact.outputs.name }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # TODO: Replace with your actual build steps + # See by-language/ templates for language-specific examples + - name: TODO - Add build steps + run: echo "Replace with your build command" + + - name: Upload artifact + id: artifact + uses: actions/upload-artifact@v4 + with: + name: scan-artifact-${{ github.run_id }} + path: ${{ env.ARTIFACT_PATH }} + retention-days: 1 + + # ── 2. PIPELINE SCAN (every branch / PR) ─────────────────────────────────── + pipeline-scan: + name: SAST Pipeline Scan + runs-on: ubuntu-latest + needs: build + + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: scan-artifact-${{ github.run_id }} + + - name: Veracode Pipeline Scan + uses: veracode/pipeline-scan-action@v1 + with: + vid: ${{ secrets.VERACODE_API_ID }} + vkey: ${{ secrets.VERACODE_API_KEY }} + file: ${{ env.ARTIFACT_PATH }} + fail_build: false # CUSTOMIZE: true to gate PRs on findings + # fail_on_severity: "Very High, High" + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: pipeline-scan-results + path: results.json + + # ── 3. SCA SCAN (every branch / PR, parallel with pipeline scan) ─────────── + sca-scan: + name: SCA Dependency Scan + runs-on: ubuntu-latest + needs: build + + steps: + - uses: actions/checkout@v4 + + # TODO: Add language-specific dependency install steps here + + - name: SCA Agent Scan + env: + SRCCLR_API_TOKEN: ${{ secrets.SRCCLR_API_TOKEN }} + run: | + curl -sSL https://download.sourceclear.com/ci.sh | sh -s scan \ + --allow-dirty \ + --recursive + continue-on-error: true + + # ── 4. POLICY SCAN (main branch only, after fast scans pass) ─────────────── + policy-scan: + name: SAST Policy Scan + runs-on: ubuntu-latest + needs: [pipeline-scan, sca-scan] + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' + + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: scan-artifact-${{ github.run_id }} + + - name: Veracode Policy Scan + uses: veracode/veracode-uploadandscan-action@master + with: + appname: ${{ env.VERACODE_APP_NAME }} + createprofile: true + filepath: ${{ env.ARTIFACT_PATH }} + version: ${{ env.BUILD_VERSION }} + vid: ${{ secrets.VERACODE_API_ID }} + vkey: ${{ secrets.VERACODE_API_KEY }} + deleteincompletescan: "1" + waitForScan: false # CUSTOMIZE: true to block on results + + # ── 5. CONTAINER SCAN (main branch only, if Dockerfile exists) ───────────── + container-scan: + name: Container Scan + runs-on: ubuntu-latest + needs: [pipeline-scan, sca-scan] + if: > + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') && + hashFiles('Dockerfile') != '' + + steps: + - uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -t ${{ env.IMAGE_NAME }}:${{ github.sha }} . + + - name: Install Veracode CLI + run: curl -fsS https://tools.veracode.com/veracode-cli/install | sh + + - name: Veracode container scan + env: + VERACODE_API_KEY_ID: ${{ secrets.VERACODE_API_ID }} + VERACODE_API_KEY_SECRET: ${{ secrets.VERACODE_API_KEY }} + run: | + ./veracode scan \ + --type image \ + --source ${{ env.IMAGE_NAME }}:${{ github.sha }} \ + --format table + continue-on-error: true + + # ── 6. SUMMARY ────────────────────────────────────────────────────────────── + security-summary: + name: Security Summary + runs-on: ubuntu-latest + needs: [pipeline-scan, sca-scan] + if: always() + + steps: + - name: Write job summary + run: | + echo "## Veracode DevSecOps Security Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Scan | Result |" >> $GITHUB_STEP_SUMMARY + echo "|------|--------|" >> $GITHUB_STEP_SUMMARY + echo "| SAST Pipeline Scan | ${{ needs.pipeline-scan.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| SCA Dependency Scan | ${{ needs.sca-scan.result }} |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Branch: \`${{ github.ref_name }}\` | Commit: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY diff --git a/templates/workflows/by-language/dotnet.yml b/templates/workflows/by-language/dotnet.yml new file mode 100644 index 0000000..8901ac6 --- /dev/null +++ b/templates/workflows/by-language/dotnet.yml @@ -0,0 +1,134 @@ +# ────────────────────────────────────────────────────────────────────────────── +# TEMPLATE: .NET (C#) + Veracode +# BUILD: dotnet publish → zip binaries + PDB files +# SCANS: Pipeline Scan (PR) + Policy Scan (main) + SCA +# +# PACKAGING NOTE: +# Veracode requires compiled DLLs AND PDB files (for source mapping). +# Include both in the zip. See: https://docs.veracode.com/r/compilation_net +# ────────────────────────────────────────────────────────────────────────────── +# REQUIRED SECRETS: VERACODE_API_ID, VERACODE_API_KEY, SRCCLR_API_TOKEN +# CUSTOMIZE: VERACODE_APP_NAME, DOTNET_VERSION, project path, configuration +# ────────────────────────────────────────────────────────────────────────────── + +name: .NET + Veracode + +on: + push: + branches: [main, master, "release/**"] + pull_request: + types: [opened, synchronize, reopened] + +env: + VERACODE_APP_NAME: "YOUR_APP_NAME" # CUSTOMIZE + DOTNET_VERSION: "8.0.x" # CUSTOMIZE: 6.0.x, 7.0.x, 8.0.x + BUILD_CONFIGURATION: Release + # CUSTOMIZE: path to your .csproj / .sln file + PROJECT_PATH: "." + BUILD_VERSION: "${{ github.ref_name }}-${{ github.run_number }}" + +jobs: + build: + name: .NET Build & Package + runs-on: ubuntu-latest # CUSTOMIZE: windows-latest if Windows-specific dependencies + + steps: + - uses: actions/checkout@v4 + + - name: Set up .NET ${{ env.DOTNET_VERSION }} + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Restore dependencies + run: dotnet restore ${{ env.PROJECT_PATH }} + + - name: Build + run: | + dotnet build ${{ env.PROJECT_PATH }} \ + -c ${{ env.BUILD_CONFIGURATION }} \ + --no-restore + + - name: Publish + run: | + dotnet publish ${{ env.PROJECT_PATH }} \ + -c ${{ env.BUILD_CONFIGURATION }} \ + --no-build \ + -o publish/ + # CUSTOMIZE: add --self-contained, -r , /p:PublishSingleFile=true + + # Package compiled DLLs + PDBs (both required by Veracode) + - name: Package binaries for Veracode + run: | + zip -r app.zip publish/ \ + -i "*.dll" "*.pdb" "*.exe" "*.json" "*.config" + echo "Artifact size: $(du -sh app.zip)" + + - uses: actions/upload-artifact@v4 + with: + name: dotnet-artifact + path: app.zip + retention-days: 1 + + pipeline-scan: + name: Veracode Pipeline Scan + runs-on: ubuntu-latest + needs: build + + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: { name: dotnet-artifact } + + - uses: veracode/pipeline-scan-action@v1 + with: + vid: ${{ secrets.VERACODE_API_ID }} + vkey: ${{ secrets.VERACODE_API_KEY }} + file: app.zip + fail_build: false + + - uses: actions/upload-artifact@v4 + if: always() + with: { name: pipeline-scan-results, path: results.json } + + policy-scan: + name: Veracode Policy Scan + runs-on: ubuntu-latest + needs: build + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' + + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: { name: dotnet-artifact } + + - uses: veracode/veracode-uploadandscan-action@master + with: + appname: ${{ env.VERACODE_APP_NAME }} + createprofile: true + filepath: app.zip + version: ${{ env.BUILD_VERSION }} + vid: ${{ secrets.VERACODE_API_ID }} + vkey: ${{ secrets.VERACODE_API_KEY }} + deleteincompletescan: "1" + waitForScan: false + + sca-scan: + name: Veracode SCA (NuGet) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Restore (for SCA) + run: dotnet restore ${{ env.PROJECT_PATH }} + + - name: SCA Agent Scan + env: + SRCCLR_API_TOKEN: ${{ secrets.SRCCLR_API_TOKEN }} + run: curl -sSL https://download.sourceclear.com/ci.sh | sh -s scan --recursive + continue-on-error: true diff --git a/templates/workflows/by-language/go.yml b/templates/workflows/by-language/go.yml new file mode 100644 index 0000000..e19b31d --- /dev/null +++ b/templates/workflows/by-language/go.yml @@ -0,0 +1,137 @@ +# ────────────────────────────────────────────────────────────────────────────── +# TEMPLATE: Go + Veracode +# BUILD: go build → compiled binary, zipped with source +# SCANS: Pipeline Scan (PR) + Policy Scan (main) + SCA +# +# PACKAGING NOTE: +# Veracode can scan Go binaries OR zipped Go source. +# Compiled binary scan: faster, no source visibility. +# Source zip: recommended for better flaw attribution. +# See: https://docs.veracode.com/r/compilation_go +# ────────────────────────────────────────────────────────────────────────────── +# REQUIRED SECRETS: VERACODE_API_ID, VERACODE_API_KEY, SRCCLR_API_TOKEN +# CUSTOMIZE: VERACODE_APP_NAME, GO_VERSION, binary name +# ────────────────────────────────────────────────────────────────────────────── + +name: Go + Veracode + +on: + push: + branches: [main, master, "release/**"] + pull_request: + types: [opened, synchronize, reopened] + +env: + VERACODE_APP_NAME: "YOUR_APP_NAME" # CUSTOMIZE + GO_VERSION: "1.22" # CUSTOMIZE: 1.21, 1.22, 1.23 + BINARY_NAME: "app" # CUSTOMIZE: your binary output name + BUILD_VERSION: "${{ github.ref_name }}-${{ github.run_number }}" + +jobs: + build: + name: Go Build & Package + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go ${{ env.GO_VERSION }} + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + - name: Download dependencies + run: go mod download + + - name: Build binary + run: | + go build \ + -v \ + -o ${{ env.BINARY_NAME }} \ + ./... + # CUSTOMIZE: ./cmd/myapp/ or specific package path + + # Option A: Scan compiled binary (faster) + - name: Package binary for scan + run: | + zip app.zip ${{ env.BINARY_NAME }} + echo "Binary artifact: $(du -sh app.zip)" + + # Option B: Scan source (better flaw attribution) — uncomment to use instead + # - name: Package source for scan + # run: | + # zip -r app.zip . \ + # -x "*.git*" \ + # -x "vendor/*" \ + # -x "*_test.go" \ + # -x ".github/*" + + - uses: actions/upload-artifact@v4 + with: + name: go-artifact + path: app.zip + retention-days: 1 + + pipeline-scan: + name: Veracode Pipeline Scan + runs-on: ubuntu-latest + needs: build + + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: { name: go-artifact } + + - uses: veracode/pipeline-scan-action@v1 + with: + vid: ${{ secrets.VERACODE_API_ID }} + vkey: ${{ secrets.VERACODE_API_KEY }} + file: app.zip + fail_build: false + + - uses: actions/upload-artifact@v4 + if: always() + with: { name: pipeline-scan-results, path: results.json } + + policy-scan: + name: Veracode Policy Scan + runs-on: ubuntu-latest + needs: build + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' + + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: { name: go-artifact } + + - uses: veracode/veracode-uploadandscan-action@master + with: + appname: ${{ env.VERACODE_APP_NAME }} + createprofile: true + filepath: app.zip + version: ${{ env.BUILD_VERSION }} + vid: ${{ secrets.VERACODE_API_ID }} + vkey: ${{ secrets.VERACODE_API_KEY }} + deleteincompletescan: "1" + waitForScan: false + + sca-scan: + name: Veracode SCA (Go modules) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Download modules (for SCA) + run: go mod download + + - name: SCA Agent Scan + env: + SRCCLR_API_TOKEN: ${{ secrets.SRCCLR_API_TOKEN }} + run: curl -sSL https://download.sourceclear.com/ci.sh | sh -s scan --recursive + continue-on-error: true diff --git a/templates/workflows/by-language/java-gradle.yml b/templates/workflows/by-language/java-gradle.yml new file mode 100644 index 0000000..ee875ee --- /dev/null +++ b/templates/workflows/by-language/java-gradle.yml @@ -0,0 +1,120 @@ +# ────────────────────────────────────────────────────────────────────────────── +# TEMPLATE: Java (Gradle) + Veracode +# BUILD: ./gradlew build → build/libs/*.jar or *.war +# SCANS: Pipeline Scan (PR) + Policy Scan (main) +# ────────────────────────────────────────────────────────────────────────────── +# REQUIRED SECRETS: VERACODE_API_ID, VERACODE_API_KEY +# CUSTOMIZE: VERACODE_APP_NAME, JAVA_VERSION, gradle task +# ────────────────────────────────────────────────────────────────────────────── + +name: Java Gradle + Veracode + +on: + push: + branches: [main, master, "release/**"] + pull_request: + types: [opened, synchronize, reopened] + +env: + VERACODE_APP_NAME: "YOUR_APP_NAME" # CUSTOMIZE + JAVA_VERSION: "17" # CUSTOMIZE: 8, 11, 17, 21 + BUILD_VERSION: "${{ github.ref_name }}-${{ github.run_number }}" + +jobs: + build: + name: Gradle Build + runs-on: ubuntu-latest + outputs: + artifact_path: ${{ steps.find-artifact.outputs.path }} + + steps: + - uses: actions/checkout@v4 + + - name: Set up Java ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: temurin + cache: gradle + + - name: Grant execute permission to gradlew + run: chmod +x gradlew + + - name: Build with Gradle + run: | + ./gradlew build -x test + # CUSTOMIZE: use 'bootJar' for Spring Boot, 'war' for web apps, etc. + + - name: Find built artifact + id: find-artifact + run: | + ARTIFACT=$(find build/libs/ -name "*.war" -o -name "*.jar" \ + | grep -v "sources\|plain\|javadoc" \ + | head -1) + echo "Found: $ARTIFACT" + echo "path=$ARTIFACT" >> $GITHUB_OUTPUT + + - uses: actions/upload-artifact@v4 + with: + name: gradle-artifact + path: ${{ steps.find-artifact.outputs.path }} + retention-days: 1 + + pipeline-scan: + name: Veracode Pipeline Scan + runs-on: ubuntu-latest + needs: build + + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: { name: gradle-artifact } + + - uses: veracode/pipeline-scan-action@v1 + with: + vid: ${{ secrets.VERACODE_API_ID }} + vkey: ${{ secrets.VERACODE_API_KEY }} + file: ${{ needs.build.outputs.artifact_path }} + fail_build: false + + - uses: actions/upload-artifact@v4 + if: always() + with: { name: pipeline-scan-results, path: results.json } + + policy-scan: + name: Veracode Policy Scan + runs-on: ubuntu-latest + needs: build + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' + + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: { name: gradle-artifact } + + - uses: veracode/veracode-uploadandscan-action@master + with: + appname: ${{ env.VERACODE_APP_NAME }} + createprofile: true + filepath: ${{ needs.build.outputs.artifact_path }} + version: ${{ env.BUILD_VERSION }} + vid: ${{ secrets.VERACODE_API_ID }} + vkey: ${{ secrets.VERACODE_API_KEY }} + deleteincompletescan: "1" + waitForScan: false + + sca-scan: + name: Veracode SCA + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: temurin + - name: SCA Scan + env: + SRCCLR_API_TOKEN: ${{ secrets.SRCCLR_API_TOKEN }} + run: curl -sSL https://download.sourceclear.com/ci.sh | sh -s scan --recursive + continue-on-error: true diff --git a/templates/workflows/by-language/java-maven.yml b/templates/workflows/by-language/java-maven.yml new file mode 100644 index 0000000..f14aae1 --- /dev/null +++ b/templates/workflows/by-language/java-maven.yml @@ -0,0 +1,131 @@ +# ────────────────────────────────────────────────────────────────────────────── +# TEMPLATE: Java (Maven) + Veracode +# BUILD: mvn package → target/*.jar or *.war +# SCANS: Pipeline Scan (PR) + Policy Scan (main) +# ────────────────────────────────────────────────────────────────────────────── +# REQUIRED SECRETS: VERACODE_API_ID, VERACODE_API_KEY +# CUSTOMIZE: VERACODE_APP_NAME, JAVA_VERSION, maven goals +# ────────────────────────────────────────────────────────────────────────────── + +name: Java Maven + Veracode + +on: + push: + branches: [main, master, "release/**"] + pull_request: + types: [opened, synchronize, reopened] + +env: + VERACODE_APP_NAME: "YOUR_APP_NAME" # CUSTOMIZE + JAVA_VERSION: "17" # CUSTOMIZE: 8, 11, 17, 21 + BUILD_VERSION: "${{ github.ref_name }}-${{ github.run_number }}" + +jobs: + build: + name: Maven Build + runs-on: ubuntu-latest + outputs: + artifact_path: ${{ steps.find-artifact.outputs.path }} + + steps: + - uses: actions/checkout@v4 + + - name: Set up Java ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: temurin + cache: maven + + - name: Build with Maven + run: | + mvn -B package \ + -DskipTests \ + --file pom.xml + # CUSTOMIZE: add -P , -D=, etc. + + # For a WAR (web app), the artifact is typically target/*.war + # For a JAR (standalone), it's target/*.jar + # If multi-module: CUSTOMIZE the glob below + - name: Find built artifact + id: find-artifact + run: | + ARTIFACT=$(find target/ -name "*.war" -o -name "*.jar" \ + | grep -v "sources\|javadoc\|original" \ + | head -1) + echo "Found artifact: $ARTIFACT" + echo "path=$ARTIFACT" >> $GITHUB_OUTPUT + + - uses: actions/upload-artifact@v4 + with: + name: java-artifact + path: ${{ steps.find-artifact.outputs.path }} + retention-days: 1 + + pipeline-scan: + name: Veracode Pipeline Scan (SAST) + runs-on: ubuntu-latest + needs: build + + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: { name: java-artifact } + + - name: Pipeline Scan + uses: veracode/pipeline-scan-action@v1 + with: + vid: ${{ secrets.VERACODE_API_ID }} + vkey: ${{ secrets.VERACODE_API_KEY }} + file: ${{ needs.build.outputs.artifact_path }} + fail_build: false + # fail_on_severity: "Very High, High" + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: pipeline-scan-results + path: results.json + + policy-scan: + name: Veracode Policy Scan (SAST) + runs-on: ubuntu-latest + needs: build + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' + + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: { name: java-artifact } + + - name: Policy Scan + uses: veracode/veracode-uploadandscan-action@master + with: + appname: ${{ env.VERACODE_APP_NAME }} + createprofile: true + filepath: ${{ needs.build.outputs.artifact_path }} + version: ${{ env.BUILD_VERSION }} + vid: ${{ secrets.VERACODE_API_ID }} + vkey: ${{ secrets.VERACODE_API_KEY }} + deleteincompletescan: "1" + waitForScan: false + + sca-scan: + name: Veracode SCA (Dependencies) + runs-on: ubuntu-latest + needs: build + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: temurin + cache: maven + + - name: SCA Scan + env: + SRCCLR_API_TOKEN: ${{ secrets.SRCCLR_API_TOKEN }} + run: curl -sSL https://download.sourceclear.com/ci.sh | sh -s scan --recursive + continue-on-error: true diff --git a/templates/workflows/by-language/nodejs.yml b/templates/workflows/by-language/nodejs.yml new file mode 100644 index 0000000..0d1846f --- /dev/null +++ b/templates/workflows/by-language/nodejs.yml @@ -0,0 +1,131 @@ +# ────────────────────────────────────────────────────────────────────────────── +# TEMPLATE: Node.js / TypeScript + Veracode +# BUILD: npm ci + npm run build → zip source + dist +# SCANS: Pipeline Scan (PR) + Policy Scan (main) + SCA +# +# PACKAGING NOTE: +# Veracode scans Node.js source. Package all .js files (not node_modules) +# into a zip. See: https://docs.veracode.com/r/compilation_nodejs +# ────────────────────────────────────────────────────────────────────────────── +# REQUIRED SECRETS: VERACODE_API_ID, VERACODE_API_KEY, SRCCLR_API_TOKEN +# ────────────────────────────────────────────────────────────────────────────── + +name: Node.js + Veracode + +on: + push: + branches: [main, master, "release/**"] + pull_request: + types: [opened, synchronize, reopened] + +env: + VERACODE_APP_NAME: "YOUR_APP_NAME" # CUSTOMIZE + NODE_VERSION: "20" # CUSTOMIZE: 18, 20, 22 + BUILD_VERSION: "${{ github.ref_name }}-${{ github.run_number }}" + +jobs: + build: + name: Node.js Build & Package + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm # CUSTOMIZE: yarn or pnpm if applicable + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build 2>/dev/null || true # CUSTOMIZE: skip if no build step + + # Package source files for Veracode scanning + # CUSTOMIZE: adjust paths to include/exclude based on your project layout + - name: Package source for scan + run: | + zip -r app.zip . \ + -x "*.git*" \ + -x "node_modules/*" \ + -x "test/*" \ + -x "tests/*" \ + -x "spec/*" \ + -x "*.test.*" \ + -x "*.spec.*" \ + -x ".github/*" \ + -x "coverage/*" \ + -x ".nyc_output/*" + echo "Artifact size: $(du -sh app.zip)" + + - uses: actions/upload-artifact@v4 + with: + name: node-artifact + path: app.zip + retention-days: 1 + + pipeline-scan: + name: Veracode Pipeline Scan + runs-on: ubuntu-latest + needs: build + + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: { name: node-artifact } + + - uses: veracode/pipeline-scan-action@v1 + with: + vid: ${{ secrets.VERACODE_API_ID }} + vkey: ${{ secrets.VERACODE_API_KEY }} + file: app.zip + fail_build: false + + - uses: actions/upload-artifact@v4 + if: always() + with: { name: pipeline-scan-results, path: results.json } + + policy-scan: + name: Veracode Policy Scan + runs-on: ubuntu-latest + needs: build + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' + + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: { name: node-artifact } + + - uses: veracode/veracode-uploadandscan-action@master + with: + appname: ${{ env.VERACODE_APP_NAME }} + createprofile: true + filepath: app.zip + version: ${{ env.BUILD_VERSION }} + vid: ${{ secrets.VERACODE_API_ID }} + vkey: ${{ secrets.VERACODE_API_KEY }} + deleteincompletescan: "1" + waitForScan: false + + sca-scan: + name: Veracode SCA (npm dependencies) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: SCA Agent Scan + env: + SRCCLR_API_TOKEN: ${{ secrets.SRCCLR_API_TOKEN }} + run: curl -sSL https://download.sourceclear.com/ci.sh | sh -s scan --recursive + continue-on-error: true diff --git a/templates/workflows/by-language/python.yml b/templates/workflows/by-language/python.yml new file mode 100644 index 0000000..7c715f8 --- /dev/null +++ b/templates/workflows/by-language/python.yml @@ -0,0 +1,132 @@ +# ────────────────────────────────────────────────────────────────────────────── +# TEMPLATE: Python + Veracode +# BUILD: zip source .py files +# SCANS: Pipeline Scan (PR) + Policy Scan (main) + SCA +# +# PACKAGING NOTE: +# Veracode scans Python source. Zip all .py files excluding tests/venv. +# See: https://docs.veracode.com/r/compilation_python +# ────────────────────────────────────────────────────────────────────────────── +# REQUIRED SECRETS: VERACODE_API_ID, VERACODE_API_KEY, SRCCLR_API_TOKEN +# ────────────────────────────────────────────────────────────────────────────── + +name: Python + Veracode + +on: + push: + branches: [main, master, "release/**"] + pull_request: + types: [opened, synchronize, reopened] + +env: + VERACODE_APP_NAME: "YOUR_APP_NAME" # CUSTOMIZE + PYTHON_VERSION: "3.11" # CUSTOMIZE: 3.9, 3.10, 3.11, 3.12 + BUILD_VERSION: "${{ github.ref_name }}-${{ github.run_number }}" + +jobs: + build: + name: Python Package for Scan + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: pip + + - name: Install dependencies (for SCA and completeness) + run: | + pip install -r requirements.txt 2>/dev/null || \ + pip install -r requirements/base.txt 2>/dev/null || true + + # Package source for Veracode — exclude tests, venvs, cache + # CUSTOMIZE: adjust excludes based on your project structure + - name: Package Python source for scan + run: | + zip -r app.zip . \ + -x "*.git*" \ + -x "__pycache__/*" \ + -x "*.pyc" \ + -x ".venv/*" \ + -x "venv/*" \ + -x "env/*" \ + -x "tests/*" \ + -x "test/*" \ + -x "*.egg-info/*" \ + -x "dist/*" \ + -x "build/*" \ + -x ".github/*" \ + -x "htmlcov/*" \ + -x ".pytest_cache/*" + echo "Artifact size: $(du -sh app.zip)" + + - uses: actions/upload-artifact@v4 + with: + name: python-artifact + path: app.zip + retention-days: 1 + + pipeline-scan: + name: Veracode Pipeline Scan + runs-on: ubuntu-latest + needs: build + + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: { name: python-artifact } + + - uses: veracode/pipeline-scan-action@v1 + with: + vid: ${{ secrets.VERACODE_API_ID }} + vkey: ${{ secrets.VERACODE_API_KEY }} + file: app.zip + fail_build: false + + - uses: actions/upload-artifact@v4 + if: always() + with: { name: pipeline-scan-results, path: results.json } + + policy-scan: + name: Veracode Policy Scan + runs-on: ubuntu-latest + needs: build + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' + + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: { name: python-artifact } + + - uses: veracode/veracode-uploadandscan-action@master + with: + appname: ${{ env.VERACODE_APP_NAME }} + createprofile: true + filepath: app.zip + version: ${{ env.BUILD_VERSION }} + vid: ${{ secrets.VERACODE_API_ID }} + vkey: ${{ secrets.VERACODE_API_KEY }} + deleteincompletescan: "1" + waitForScan: false + + sca-scan: + name: Veracode SCA (pip dependencies) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install dependencies + run: pip install -r requirements.txt 2>/dev/null || true + + - name: SCA Agent Scan + env: + SRCCLR_API_TOKEN: ${{ secrets.SRCCLR_API_TOKEN }} + run: curl -sSL https://download.sourceclear.com/ci.sh | sh -s scan --recursive + continue-on-error: true diff --git a/templates/workflows/container-scan.yml b/templates/workflows/container-scan.yml new file mode 100644 index 0000000..6d816ea --- /dev/null +++ b/templates/workflows/container-scan.yml @@ -0,0 +1,145 @@ +# ────────────────────────────────────────────────────────────────────────────── +# TEMPLATE: Veracode Container / Image Security Scan +# SCAN TYPE: Container scanning — analyzes Docker image layers for vulnerabilities +# WHEN: On image build (push to main) or PR that changes Dockerfile +# RUNTIME: ~5–20 minutes +# DOCS: https://docs.veracode.com/r/c_container_scanning +# ────────────────────────────────────────────────────────────────────────────── +# REQUIRED SECRETS: +# VERACODE_API_ID - Veracode API Key ID +# VERACODE_API_KEY - Veracode API Key Secret +# +# OPTIONAL (for pushing to registry): +# REGISTRY_USERNAME - Container registry username +# REGISTRY_TOKEN - Container registry token/password +# ────────────────────────────────────────────────────────────────────────────── + +name: Veracode Container Scan + +on: + push: + branches: + - main + - master + paths: + - "Dockerfile" + - "Dockerfile.*" + - ".dockerignore" + - "docker-compose*.yml" + pull_request: + paths: + - "Dockerfile" + - "Dockerfile.*" + workflow_dispatch: + inputs: + image_ref: + description: "Docker image to scan (e.g. myapp:latest or registry/myapp:sha)" + required: false + type: string + +env: + # CUSTOMIZE: your image name and tag + IMAGE_NAME: "your-app-name" # TODO + IMAGE_TAG: ${{ github.sha }} + # CUSTOMIZE: container registry (ghcr.io, docker.io, etc.) + # REGISTRY: ghcr.io/${{ github.repository_owner }} + +jobs: + build-image: + name: Build Docker Image + runs-on: ubuntu-latest + outputs: + image_ref: ${{ steps.set-ref.outputs.image_ref }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # OPTIONAL: login to container registry + # - name: Log in to registry + # uses: docker/login-action@v3 + # with: + # registry: ${{ env.REGISTRY }} + # username: ${{ secrets.REGISTRY_USERNAME }} + # password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build Docker image + run: | + # CUSTOMIZE: add build args, build context, etc. + docker build -t ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }} . + + - name: Save image to tar for scanning + run: | + docker save ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }} \ + -o ${{ env.IMAGE_NAME }}-image.tar + + - name: Upload image tar + uses: actions/upload-artifact@v4 + with: + name: docker-image + path: ${{ env.IMAGE_NAME }}-image.tar + retention-days: 1 + + - id: set-ref + run: echo "image_ref=${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}" >> $GITHUB_OUTPUT + + container-scan: + name: Veracode Container Scan + runs-on: ubuntu-latest + needs: build-image + + steps: + - name: Download image tar + uses: actions/download-artifact@v4 + with: + name: docker-image + + - name: Load image + run: docker load -i ${{ env.IMAGE_NAME }}-image.tar + + # ── Option A: Veracode CLI container scan ───────────────────────────── + - name: Install Veracode CLI + run: curl -fsS https://tools.veracode.com/veracode-cli/install | sh + + - name: Veracode container scan + env: + VERACODE_API_KEY_ID: ${{ secrets.VERACODE_API_ID }} + VERACODE_API_KEY_SECRET: ${{ secrets.VERACODE_API_KEY }} + run: | + ./veracode scan \ + --type image \ + --source ${{ needs.build-image.outputs.image_ref }} \ + --format table + # CUSTOMIZE: add --project-name, --format json, --output results.json + continue-on-error: true # CUSTOMIZE: set to false to fail on findings + + # ── Option B: Veracode Container Security Action ────────────────────── + # Uncomment below and comment out Option A for the GitHub Action approach + # + # - name: Veracode Container Security Scan + # uses: veracode/container-security-action@v1 + # with: + # vid: ${{ secrets.VERACODE_API_ID }} + # vkey: ${{ secrets.VERACODE_API_KEY }} + # image: ${{ needs.build-image.outputs.image_ref }} + # # CUSTOMIZE: fail_build: true + # # CUSTOMIZE: min_cvss_for_fail: 7.0 + # # CUSTOMIZE: skip_fixable_only: false + + # ── Option C: Trivy + upload to GitHub Security (free alternative) ──── + # - name: Trivy vulnerability scan + # uses: aquasecurity/trivy-action@master + # with: + # image-ref: ${{ needs.build-image.outputs.image_ref }} + # format: sarif + # output: trivy-results.sarif + # - uses: github/codeql-action/upload-sarif@v3 + # with: { sarif_file: trivy-results.sarif } + + - name: Upload scan results + uses: actions/upload-artifact@v4 + if: always() + with: + name: container-scan-results + path: "*.json" + continue-on-error: true diff --git a/templates/workflows/dast-web-scan.yml b/templates/workflows/dast-web-scan.yml new file mode 100644 index 0000000..dae580c --- /dev/null +++ b/templates/workflows/dast-web-scan.yml @@ -0,0 +1,167 @@ +# ────────────────────────────────────────────────────────────────────────────── +# TEMPLATE: Veracode DAST Web Application Scan +# SCAN TYPE: DAST — Dynamic Analysis of a running web application +# WHEN: Manual trigger or scheduled (requires a live target URL) +# RUNTIME: Varies: 30 min – 8+ hours depending on app complexity +# DOCS: https://docs.veracode.com/r/c_was_intro +# ────────────────────────────────────────────────────────────────────────────── +# REQUIRED SECRETS: +# VERACODE_API_ID - Veracode API Key ID +# VERACODE_API_KEY - Veracode API Key Secret +# +# REQUIRED SETUP: +# 1. Your application must be running and reachable at TARGET_URL +# 2. For internal apps, configure an Internal Scan Management (ISM) gateway +# 3. Provide allowlist.csv and blocklist.csv (see Scripts/Release/ for format) +# +# TOOLS USED FROM THIS REPO: +# - Scripts/Release/DASTWebAppRequest-std.py (formats the scan request JSON) +# - Scripts/Release/DAST-ls-v2.sh (list/monitor scan status) +# ────────────────────────────────────────────────────────────────────────────── + +name: Veracode DAST Web Scan + +on: + # Manual trigger with configurable parameters + workflow_dispatch: + inputs: + target_url: + description: "Target URL to scan (e.g. https://app.example.com/)" + required: true + type: string + analysis_name: + description: "Analysis name in Veracode platform" + required: true + type: string + default: "GH-Actions-DAST" + org_email: + description: "Notification email for scan owner" + required: true + type: string + org_owner: + description: "Scan owner name" + required: false + default: "DevSecOps" + start_now: + description: "Start scan immediately?" + type: boolean + default: true + + # OPTIONAL: Scheduled scan (uncomment and customize cron) + # schedule: + # - cron: "0 2 * * 1" # Every Monday at 02:00 UTC + +env: + # CUSTOMIZE: base URL of this repo (to pull scripts without cloning) + SCRIPTS_REPO: "bnreplah/veracode-scripts" + SCRIPTS_BRANCH: "main" + +jobs: + dast-scan: + name: Configure & Submit DAST Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout this repo (or the target repo) + uses: actions/checkout@v4 + with: + # CUSTOMIZE: if running from your own repo, remove repository/ref below + # and ensure allowlist.csv / blacklist.csv are in the workspace root + repository: ${{ env.SCRIPTS_REPO }} + ref: ${{ env.SCRIPTS_BRANCH }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install httpie + Veracode HMAC signing + run: pip install httpie veracode-api-signing + + # ── Prepare allowlist / blocklist CSVs ────────────────────────────── + # CUSTOMIZE: replace these files with your application-specific lists. + # Format: directory_restriction_type,http_and_https,url + # Valid restriction types: NONE, FILE, FOLDER_ONLY, DIRECTORY_AND_SUBDIRECTORY + - name: Prepare scan configuration CSVs + run: | + # Create a minimal allowlist (CUSTOMIZE: add your app's URLs) + cat > allowlist.csv << 'EOF' + directory_restriction_type,http_and_https,url + DIRECTORY_AND_SUBDIRECTORY,TRUE,${{ github.event.inputs.target_url }} + EOF + + # Create a minimal blocklist (CUSTOMIZE: add URLs to exclude) + cat > blacklist.csv << 'EOF' + directory_restriction_type,http_and_https,url + NONE,TRUE,https://logout.example.com + EOF + + # ── Format the DAST analysis request JSON ────────────────────────── + - name: Format DAST request JSON + run: | + python Scripts/Release/DASTWebAppRequest-std.py \ + "${{ github.event.inputs.analysis_name }}" \ + "${{ github.event.inputs.target_url }}" \ + "${{ github.event.inputs.org_email }}" \ + "${{ github.event.inputs.org_owner }}" \ + > input.json + echo "Generated request:" + cat input.json | python3 -m json.tool + + # ── Submit the DAST scan to the Veracode platform ───────────────── + - name: Submit DAST analysis request + env: + VERACODE_API_KEY_ID: ${{ secrets.VERACODE_API_ID }} + VERACODE_API_KEY_SECRET: ${{ secrets.VERACODE_API_KEY }} + run: | + RESPONSE=$(http --auth-type veracode_hmac \ + POST "https://api.veracode.com/was/configservice/v1/analyses" \ + Content-Type:application/json \ + @input.json) + echo "API Response: $RESPONSE" + echo "$RESPONSE" > dast-submission-response.json + + # Extract the analysis ID from the response + ANALYSIS_ID=$(echo "$RESPONSE" | python3 -c \ + "import sys,json; d=json.load(sys.stdin); print(d.get('analysis_id','unknown'))" 2>/dev/null || echo "unknown") + echo "Analysis ID: $ANALYSIS_ID" + echo "ANALYSIS_ID=$ANALYSIS_ID" >> $GITHUB_ENV + + # ── Upload submission details ─────────────────────────────────────── + - name: Upload DAST submission artifacts + uses: actions/upload-artifact@v4 + if: always() + with: + name: dast-scan-config + path: | + input.json + dast-submission-response.json + + - name: Summary + run: | + echo "## DAST Scan Submitted" >> $GITHUB_STEP_SUMMARY + echo "- **Analysis Name:** ${{ github.event.inputs.analysis_name }}" >> $GITHUB_STEP_SUMMARY + echo "- **Target URL:** ${{ github.event.inputs.target_url }}" >> $GITHUB_STEP_SUMMARY + echo "- **Analysis ID:** ${{ env.ANALYSIS_ID }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Monitor progress in the Veracode platform or run \`DAST-ls-v2.sh\`." >> $GITHUB_STEP_SUMMARY + + # ── OPTIONAL: Monitor DAST scan status ────────────────────────────────── + # Uncomment to poll for scan completion (may run for hours) + # + # monitor-dast-scan: + # name: Monitor DAST Scan + # runs-on: ubuntu-latest + # needs: dast-scan + # if: github.event.inputs.start_now == 'true' + # timeout-minutes: 480 # 8 hours max + # steps: + # - uses: actions/checkout@v4 + # with: { repository: "${{ env.SCRIPTS_REPO }}", ref: "${{ env.SCRIPTS_BRANCH }}" } + # - name: Install httpie + # run: pip install httpie veracode-api-signing + # - name: Poll scan status + # env: + # VERACODE_API_KEY_ID: ${{ secrets.VERACODE_API_ID }} + # VERACODE_API_KEY_SECRET: ${{ secrets.VERACODE_API_KEY }} + # run: bash Scripts/Release/DAST-ls-v2.sh diff --git a/templates/workflows/pipeline-scan.yml b/templates/workflows/pipeline-scan.yml new file mode 100644 index 0000000..8892bb7 --- /dev/null +++ b/templates/workflows/pipeline-scan.yml @@ -0,0 +1,100 @@ +# ────────────────────────────────────────────────────────────────────────────── +# TEMPLATE: Veracode Pipeline Scan +# SCAN TYPE: SAST (Static Analysis) — Fast inline scan, no platform policy gate +# WHEN: Every push and every pull request +# RUNTIME: ~2–5 minutes +# DOCS: https://docs.veracode.com/r/Pipeline_Scan +# ────────────────────────────────────────────────────────────────────────────── +# REQUIRED SECRETS (Settings → Secrets → Actions): +# VERACODE_API_ID - Veracode API Key ID +# VERACODE_API_KEY - Veracode API Key Secret +# +# ONBOARDING STEPS: +# 1. Replace every # CUSTOMIZE: comment with your actual values +# 2. Add the two secrets above to your repo +# 3. Push — the scan runs automatically on next commit/PR +# ────────────────────────────────────────────────────────────────────────────── + +name: Veracode Pipeline Scan + +on: + push: + branches: ["**"] + pull_request: + types: [opened, synchronize, reopened] + +jobs: + build-and-pipeline-scan: + name: Build & Pipeline Scan + runs-on: ubuntu-latest # CUSTOMIZE: windows-latest or macos-latest if needed + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # ── BUILD STEP ────────────────────────────────────────────────────────── + # TODO: Replace this section with your actual build commands. + # The output must be a single uploadable artifact (jar/war/zip/dll/exe). + # See https://docs.veracode.com/r/compilation_packaging for packaging guides. + # + # Example — Java/Maven: + # - uses: actions/setup-java@v4 + # with: { java-version: '17', distribution: 'temurin' } + # - run: mvn -B package -DskipTests + # + # Example — Node.js (zip source): + # - uses: actions/setup-node@v4 + # with: { node-version: '20' } + # - run: npm ci && npm run build + # - run: zip -r app.zip . -x "*.git*" "node_modules/*" "test/*" + # + # Example — Python (zip source): + # - run: zip -r app.zip . -x "*.git*" "__pycache__/*" "*.pyc" "tests/*" + # + # Example — .NET: + # - uses: actions/setup-dotnet@v4 + # with: { dotnet-version: '8.0.x' } + # - run: dotnet publish -c Release -o publish/ + # - run: zip -r app.zip publish/ + + - name: TODO - Add your build step here + run: echo "Replace this step with your actual build command" # TODO + + # ── PIPELINE SCAN ─────────────────────────────────────────────────────── + - name: Veracode Pipeline Scan + uses: veracode/pipeline-scan-action@v1 + with: + vid: ${{ secrets.VERACODE_API_ID }} + vkey: ${{ secrets.VERACODE_API_KEY }} + # CUSTOMIZE: path to your compiled artifact + file: "app.zip" + # CUSTOMIZE: set to true to fail the build when findings exceed threshold + fail_build: false + # CUSTOMIZE: comma-separated severities that fail the build (when fail_build=true) + # fail_on_severity: "Very High, High" + # CUSTOMIZE: only report findings not in a previous baseline + # baseline_file: "baseline.json" + # CUSTOMIZE: policy name to evaluate against (optional) + # request_policy: "Veracode Recommended Medium + SCA" + id: pipeline-scan + + # ── RESULTS ───────────────────────────────────────────────────────────── + - name: Upload pipeline scan results + uses: actions/upload-artifact@v4 + if: always() + with: + name: pipeline-scan-results + path: | + results.json + filtered_results.json + + # OPTIONAL: Import findings as GitHub code scanning alerts (requires SARIF) + # - name: Convert to SARIF and upload + # uses: veracode/veracode-pipeline-scan-results-to-sarif@v2 + # with: + # pipeline-results-json: results.json + # output-results-sarif: veracode-results.sarif + # finding-rule-level: "3:1:0" + # - uses: github/codeql-action/upload-sarif@v3 + # with: + # sarif_file: veracode-results.sarif diff --git a/templates/workflows/policy-scan-sast.yml b/templates/workflows/policy-scan-sast.yml new file mode 100644 index 0000000..25c7c6f --- /dev/null +++ b/templates/workflows/policy-scan-sast.yml @@ -0,0 +1,138 @@ +# ────────────────────────────────────────────────────────────────────────────── +# TEMPLATE: Veracode Policy / Full SAST Scan +# SCAN TYPE: SAST (Static Analysis) — Full scan, evaluated against policy +# WHEN: Pushes to main/release branches; not on every PR (long runtime) +# RUNTIME: 15 min – several hours depending on app size +# DOCS: https://docs.veracode.com/r/c_uploadandscan +# ────────────────────────────────────────────────────────────────────────────── +# REQUIRED SECRETS: +# VERACODE_API_ID - Veracode API Key ID +# VERACODE_API_KEY - Veracode API Key Secret +# +# ONBOARDING STEPS: +# 1. Set VERACODE_APP_NAME to match your application's profile in the platform +# 2. Replace the build step with your actual build commands +# 3. Set ARTIFACT_PATH to point to your compiled artifact +# 4. Add secrets to your repo and push +# ────────────────────────────────────────────────────────────────────────────── + +name: Veracode Policy Scan (SAST) + +on: + push: + branches: + - main + - master + - "release/**" # CUSTOMIZE: add other protected branches + workflow_dispatch: # Allow manual trigger + inputs: + sandbox: + description: "Sandbox name (leave blank for policy scan)" + required: false + default: "" + +env: + # CUSTOMIZE: name of your application profile in the Veracode platform + VERACODE_APP_NAME: "YOUR_APP_NAME" + # CUSTOMIZE: path to the artifact produced by your build step + ARTIFACT_PATH: "app.zip" + # CUSTOMIZE: human-readable build/version label + BUILD_VERSION: "${{ github.ref_name }}-${{ github.run_number }}" + +jobs: + build: + name: Build Application + runs-on: ubuntu-latest # CUSTOMIZE: match your build environment + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # TODO: Replace with your actual build steps (see pipeline-scan.yml for examples) + - name: TODO - Add your build step + run: echo "Replace with your build command" # TODO + + - name: Upload artifact for scan job + uses: actions/upload-artifact@v4 + with: + name: scan-artifact + path: ${{ env.ARTIFACT_PATH }} + retention-days: 1 + + sast-policy-scan: + name: Veracode SAST Policy Scan + runs-on: ubuntu-latest + needs: build + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download artifact + uses: actions/download-artifact@v4 + with: + name: scan-artifact + + # ── Option A: Official GitHub Action (recommended) ──────────────────── + - name: Veracode Upload and Scan + uses: veracode/veracode-uploadandscan-action@master + with: + appname: ${{ env.VERACODE_APP_NAME }} + createprofile: true # Creates app profile in platform if missing + filepath: ${{ env.ARTIFACT_PATH }} + version: ${{ env.BUILD_VERSION }} + vid: ${{ secrets.VERACODE_API_ID }} + vkey: ${{ secrets.VERACODE_API_KEY }} + # CUSTOMIZE: sandbox name from workflow_dispatch input, or hardcode one + sandboxname: ${{ github.event.inputs.sandbox || '' }} + # CUSTOMIZE: delete an incomplete scan to unblock (0=cancel, 1=delete) + deleteincompletescan: "1" + # CUSTOMIZE: set true to wait for scan completion and check policy + waitForScan: true + # CUSTOMIZE: set true to fail the build if policy is not passed + failbuild: false + + # ── Option B: Java Wrapper via this repo's installer (alternative) ───── + # Uncomment below and comment out Option A if you prefer the Java wrapper. + # + # - name: Install Veracode Java API Wrapper + # run: | + # bash <(curl -fsSL https://raw.githubusercontent.com/bnreplah/veracode-scripts/main/Scripts/Release/veracode-installer.sh) \ + # --install-java-api-wrapper + # + # - name: Upload and Scan + # env: + # VERACODE_API_ID: ${{ secrets.VERACODE_API_ID }} + # VERACODE_API_KEY: ${{ secrets.VERACODE_API_KEY }} + # run: | + # java -jar VeracodeJavaAPI.jar \ + # -action uploadandscan \ + # -filepath ${{ env.ARTIFACT_PATH }} \ + # -appname "${{ env.VERACODE_APP_NAME }}" \ + # -createprofile true \ + # -version "${{ env.BUILD_VERSION }}" \ + # -deleteincompletescan 1 + + # ── OPTIONAL: Import findings to GitHub Security tab ───────────────────────── + import-findings: + name: Import Findings to GitHub + runs-on: ubuntu-latest + needs: sast-policy-scan + if: always() + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # OPTIONAL: pull findings into GitHub Security > Code scanning alerts + # - name: Veracode Flaw Importer + # uses: veracode/veracode-flaw-importer-action@main + # with: + # vid: ${{ secrets.VERACODE_API_ID }} + # vkey: ${{ secrets.VERACODE_API_KEY }} + # type: github + # appname: ${{ env.VERACODE_APP_NAME }} + # sandboxname: "" + # scantype: Dynamic,Static,SCA + + - name: Scan complete + run: echo "Policy scan submitted for ${{ env.VERACODE_APP_NAME }} build ${{ env.BUILD_VERSION }}" diff --git a/templates/workflows/sandbox-scan-promote.yml b/templates/workflows/sandbox-scan-promote.yml new file mode 100644 index 0000000..f1c5d86 --- /dev/null +++ b/templates/workflows/sandbox-scan-promote.yml @@ -0,0 +1,154 @@ +# ────────────────────────────────────────────────────────────────────────────── +# TEMPLATE: Veracode Sandbox Scan + Promote to Policy +# SCAN TYPE: SAST — Sandbox (feature branch) → promote to policy on merge +# WHEN: Feature branches scan to sandbox; main branch promotes + policy scan +# RUNTIME: 15 min – several hours +# DOCS: https://docs.veracode.com/r/c_about_sandbox +# ────────────────────────────────────────────────────────────────────────────── +# WORKFLOW: +# feature/* push → scan in sandbox (named after branch) +# PR to main → also scans in sandbox with PR label +# push to main → promote sandbox findings to policy, then policy scan +# +# REQUIRED SECRETS: +# VERACODE_API_ID - Veracode API Key ID +# VERACODE_API_KEY - Veracode API Key Secret +# ────────────────────────────────────────────────────────────────────────────── + +name: Veracode Sandbox Scan & Promote + +on: + push: + branches: + - "feature/**" + - "bugfix/**" + - "hotfix/**" + - main + - master + pull_request: + branches: + - main + - master + +env: + # CUSTOMIZE: your Veracode application profile name + VERACODE_APP_NAME: "YOUR_APP_NAME" + ARTIFACT_PATH: "app.zip" + # Sandbox is named after the branch (sanitized for Veracode) + SANDBOX_NAME: ${{ github.head_ref || github.ref_name }} + BUILD_VERSION: "${{ github.ref_name }}-${{ github.run_number }}" + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # TODO: Replace with your actual build commands + - name: TODO - Add build step + run: echo "Replace with your build command" + + - uses: actions/upload-artifact@v4 + with: + name: scan-artifact + path: ${{ env.ARTIFACT_PATH }} + retention-days: 1 + + # ── Sandbox scan for feature branches and PRs ───────────────────────────── + sandbox-scan: + name: Sandbox SAST Scan + runs-on: ubuntu-latest + needs: build + # Run on feature branches and pull requests (not on main direct push) + if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/master' + + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: scan-artifact + + - name: Veracode Sandbox Scan + uses: veracode/veracode-uploadandscan-action@master + with: + appname: ${{ env.VERACODE_APP_NAME }} + createprofile: true + filepath: ${{ env.ARTIFACT_PATH }} + version: ${{ env.BUILD_VERSION }} + vid: ${{ secrets.VERACODE_API_ID }} + vkey: ${{ secrets.VERACODE_API_KEY }} + sandboxname: ${{ env.SANDBOX_NAME }} + createsandbox: true # Create sandbox if it doesn't exist + deleteincompletescan: "1" + waitForScan: true + failbuild: false # CUSTOMIZE: set true to enforce sandbox policy + + # ── Promote sandbox + full policy scan on merge to main ─────────────────── + promote-and-policy-scan: + name: Promote Sandbox & Policy Scan + runs-on: ubuntu-latest + needs: build + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' + + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: scan-artifact + + # Step 1: promote sandbox to policy (using this repo's promote script) + # CUSTOMIZE: set PR_SANDBOX_NAME to the name of the sandbox to promote + - name: Promote sandbox to policy + env: + VERACODE_API_ID: ${{ secrets.VERACODE_API_ID }} + VERACODE_API_KEY: ${{ secrets.VERACODE_API_KEY }} + run: | + pip install httpie veracode-api-signing 2>/dev/null || true + # CUSTOMIZE: replace "feature/my-branch" with the sandbox to promote + APP_NAME="${{ env.VERACODE_APP_NAME }}" + SANDBOX_NAME="${{ github.event.pull_request.head.ref || 'dev' }}" + + APP_GUID=$(http --auth-type veracode_hmac \ + "https://api.veracode.com/appsec/v1/applications?name=${APP_NAME}" \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['_embedded']['applications'][0]['guid'])" 2>/dev/null || echo "") + + if [ -n "$APP_GUID" ]; then + SANDBOX_GUID=$(http --auth-type veracode_hmac \ + "https://api.veracode.com/appsec/v1/applications/${APP_GUID}/sandboxes" \ + | python3 -c " + import sys,json + d=json.load(sys.stdin) + name='${SANDBOX_NAME}' + sandboxes=d.get('_embedded',{}).get('sandboxes',[]) + match=[s['guid'] for s in sandboxes if s['name']==name] + print(match[0] if match else '') + " 2>/dev/null || echo "") + + if [ -n "$SANDBOX_GUID" ]; then + echo "Promoting sandbox '$SANDBOX_NAME' (${SANDBOX_GUID}) to policy..." + http --auth-type veracode_hmac POST \ + "https://api.veracode.com/appsec/v1/applications/${APP_GUID}/sandboxes/${SANDBOX_GUID}/promote" + else + echo "Sandbox '$SANDBOX_NAME' not found — skipping promotion" + fi + else + echo "Application '$APP_NAME' not found — skipping promotion" + fi + continue-on-error: true + + # Step 2: full policy scan on main + - name: Veracode Policy Scan + uses: veracode/veracode-uploadandscan-action@master + with: + appname: ${{ env.VERACODE_APP_NAME }} + createprofile: true + filepath: ${{ env.ARTIFACT_PATH }} + version: ${{ env.BUILD_VERSION }} + vid: ${{ secrets.VERACODE_API_ID }} + vkey: ${{ secrets.VERACODE_API_KEY }} + deleteincompletescan: "1" + waitForScan: true + failbuild: false # CUSTOMIZE: set true to gate deployment on policy pass diff --git a/templates/workflows/sca-agent-scan.yml b/templates/workflows/sca-agent-scan.yml new file mode 100644 index 0000000..1260b83 --- /dev/null +++ b/templates/workflows/sca-agent-scan.yml @@ -0,0 +1,113 @@ +# ────────────────────────────────────────────────────────────────────────────── +# TEMPLATE: Veracode SCA (Software Composition Analysis) Agent Scan +# SCAN TYPE: SCA — identifies vulnerable open-source libraries +# WHEN: Every PR + pushes to main (catches new vulnerable dependencies early) +# RUNTIME: ~2–10 minutes +# DOCS: https://docs.veracode.com/r/c_sc_agent_scan +# ────────────────────────────────────────────────────────────────────────────── +# REQUIRED SECRETS: +# SRCCLR_API_TOKEN - SCA Agent token (from Veracode platform → Integrations → SCA) +# +# OPTIONAL — for correlating SCA findings with an app profile: +# VERACODE_API_ID +# VERACODE_API_KEY +# +# ONBOARDING STEPS: +# 1. Create an SCA workspace and agent in the Veracode platform +# 2. Copy the agent token to SRCCLR_API_TOKEN secret +# 3. Customize scan flags in the srcclr scan command below +# ────────────────────────────────────────────────────────────────────────────── + +name: Veracode SCA Scan + +on: + push: + branches: + - main + - master + - "release/**" + pull_request: + types: [opened, synchronize, reopened] + +jobs: + sca-scan: + name: SCA Agent Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # ── Language runtime setup ─────────────────────────────────────────── + # TODO: Uncomment the runtime that matches your project + # + # Java: + # - uses: actions/setup-java@v4 + # with: { java-version: '17', distribution: 'temurin' } + # + # Node.js: + # - uses: actions/setup-node@v4 + # with: { node-version: '20', cache: 'npm' } + # - run: npm ci + # + # Python: + # - uses: actions/setup-python@v5 + # with: { python-version: '3.11' } + # - run: pip install -r requirements.txt + # + # .NET: + # - uses: actions/setup-dotnet@v4 + # with: { dotnet-version: '8.0.x' } + # - run: dotnet restore + # + # Go: + # - uses: actions/setup-go@v5 + # with: { go-version: '1.22' } + + # ── SCA agent scan ─────────────────────────────────────────────────── + - name: Veracode SCA Agent Scan + env: + SRCCLR_API_TOKEN: ${{ secrets.SRCCLR_API_TOKEN }} + run: | + curl -sSL https://download.sourceclear.com/ci.sh | sh -s scan \ + --allow-dirty \ + --recursive \ + --skip-collectors none + # CUSTOMIZE additional flags: + # --update-advisor show remediation guidance + # --vuln-methods detect vulnerable methods (Java) + # --no-graphs skip dependency graphs + # --json=results.json write JSON report + + # ── Upload SCA results as artifact ─────────────────────────────────── + - name: Upload SCA results + uses: actions/upload-artifact@v4 + if: always() + with: + name: sca-results + path: | + scaResults.json + results.json + continue-on-error: true + + # ── Optional: SCA scan via Veracode platform (links to app profile) ────── + # Uncomment to also run a platform-linked SCA scan (requires VERACODE_API_ID/KEY) + # + # sca-platform-scan: + # name: SCA Platform Scan + # runs-on: ubuntu-latest + # needs: sca-scan + # if: github.ref == 'refs/heads/main' + # steps: + # - uses: actions/checkout@v4 + # - name: Install Veracode CLI + # run: curl -fsS https://tools.veracode.com/veracode-cli/install | sh + # - name: SCA scan via Veracode CLI + # env: + # VERACODE_API_KEY_ID: ${{ secrets.VERACODE_API_ID }} + # VERACODE_API_KEY_SECRET: ${{ secrets.VERACODE_API_KEY }} + # run: | + # ./veracode scan \ + # --type directory \ + # --source . \ + # --project-name "YOUR_APP_NAME" # CUSTOMIZE From 3ea7ca5cddf1d2624a651ccf2bf06a4be2cad85a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 21:48:45 +0000 Subject: [PATCH 3/3] Add model-agnostic AI analysis layer with frontier-model hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scripts/AIAnalysis/ — a pipeline that correlates findings across Veracode scan types (SAST/DAST/SCA/container), validates them with a panel of Claude models to reduce false positives, and chains related findings into risk paths. Prepared for Mythos and future frontier models: - models.json registry drives all model selection by role (triage/validate/deep-validate/correlate/chain/second-opinion). Enabling Claude Mythos 5 or a later model is a one-line config edit — no code change. - providers.py shapes each request per model family: adaptive thinking for Opus/Sonnet, thinking omitted for Fable/Mythos (always-on), and server-side refusal fallbacks when a fallback_model is set. A not-yet-served model (404) is disabled for the run so the pipeline degrades gracefully. - hooks.py exposes 9 operational hook points (pre_ingest ... report) so external tooling or a frontier model running alongside can observe/transform data at each stage without touching pipeline code. Analysis capabilities: - Deterministic cross-scan correlation (offline, stdlib-only, CI-safe): cross-layer CWE confirmation (SAST+DAST reachable), common flaw sources, shared CVE (SCA+container), dependency-usage links. - Multi-model consensus FP validation (adversarial refute framing + quorum). - Model-assisted risk chaining with combined severity, remediation order, and directed security-training recommendations. - Structured outputs via output_config.format (json_schema) throughout. Tests: 47 new tests (config/findings/correlation/hooks unit + pipeline integration with a MockProvider, no API key needed). Full suite 163 passing. Adds AIAnalysis flake8 job to the QAT workflow and documents the layer in the READMEs. https://claude.ai/code/session_015pBhzcxzBhLcAujgXrwsaz --- .github/workflows/qat.yml | 11 +- README.md | 13 ++ Scripts/AIAnalysis/README.md | 142 +++++++++++++ Scripts/AIAnalysis/analyze.py | 78 +++++++ Scripts/AIAnalysis/models.json | 56 +++++ Scripts/AIAnalysis/requirements.txt | 4 + Scripts/AIAnalysis/veracode_ai/__init__.py | 23 +++ Scripts/AIAnalysis/veracode_ai/config.py | 91 +++++++++ Scripts/AIAnalysis/veracode_ai/correlate.py | 215 ++++++++++++++++++++ Scripts/AIAnalysis/veracode_ai/findings.py | 158 ++++++++++++++ Scripts/AIAnalysis/veracode_ai/hooks.py | 79 +++++++ Scripts/AIAnalysis/veracode_ai/pipeline.py | 85 ++++++++ Scripts/AIAnalysis/veracode_ai/providers.py | 103 ++++++++++ Scripts/AIAnalysis/veracode_ai/validate.py | 120 +++++++++++ tests/fixtures/container_scan.json | 18 ++ tests/fixtures/veracode_findings.json | 60 ++++++ tests/integration/test_ai_pipeline.py | 135 ++++++++++++ tests/unit/test_ai_config.py | 78 +++++++ tests/unit/test_ai_correlate.py | 85 ++++++++ tests/unit/test_ai_findings.py | 79 +++++++ tests/unit/test_ai_hooks.py | 69 +++++++ 21 files changed, 1701 insertions(+), 1 deletion(-) create mode 100644 Scripts/AIAnalysis/README.md create mode 100644 Scripts/AIAnalysis/analyze.py create mode 100644 Scripts/AIAnalysis/models.json create mode 100644 Scripts/AIAnalysis/requirements.txt create mode 100644 Scripts/AIAnalysis/veracode_ai/__init__.py create mode 100644 Scripts/AIAnalysis/veracode_ai/config.py create mode 100644 Scripts/AIAnalysis/veracode_ai/correlate.py create mode 100644 Scripts/AIAnalysis/veracode_ai/findings.py create mode 100644 Scripts/AIAnalysis/veracode_ai/hooks.py create mode 100644 Scripts/AIAnalysis/veracode_ai/pipeline.py create mode 100644 Scripts/AIAnalysis/veracode_ai/providers.py create mode 100644 Scripts/AIAnalysis/veracode_ai/validate.py create mode 100644 tests/fixtures/container_scan.json create mode 100644 tests/fixtures/veracode_findings.json create mode 100644 tests/integration/test_ai_pipeline.py create mode 100644 tests/unit/test_ai_config.py create mode 100644 tests/unit/test_ai_correlate.py create mode 100644 tests/unit/test_ai_findings.py create mode 100644 tests/unit/test_ai_hooks.py diff --git a/.github/workflows/qat.yml b/.github/workflows/qat.yml index f1c28e0..d2c67d1 100644 --- a/.github/workflows/qat.yml +++ b/.github/workflows/qat.yml @@ -46,11 +46,20 @@ jobs: --count continue-on-error: true + - name: Run flake8 on AI Analysis layer + run: | + flake8 Scripts/AIAnalysis/ \ + --max-line-length=120 \ + --extend-ignore=E501,W503,E402 \ + --statistics \ + --count + continue-on-error: true + - name: Run flake8 on tests run: | flake8 tests/ \ --max-line-length=120 \ - --extend-ignore=E501,W503 \ + --extend-ignore=E501,W503,E402 \ --statistics \ --count diff --git a/README.md b/README.md index 5f1d441..c11b8b1 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,19 @@ It wraps the Veracode REST and XML APIs with helper scripts, automated analysis | `vdb-purl-lte.sh` | Bash | Veracode vulnerability DB PURL lookup (lite) | | `veracode-installer.sh` | Bash | Install and configure Veracode CLI tooling | +### Scripts/AIAnalysis — AI Analysis Layer + +Model-agnostic pipeline that correlates findings across scan types, uses Claude +models to reduce false positives, and chains related findings into risk paths. +New frontier models (Claude Mythos 5, future releases) are onboarded by editing +`models.json` — no code changes. See [Scripts/AIAnalysis/README.md](Scripts/AIAnalysis/README.md). + +| File | Purpose | +|---|---| +| `analyze.py` | CLI entry point (offline correlation or full AI pipeline) | +| `models.json` | Model registry — enable/disable models and assign roles | +| `veracode_ai/` | Package: config, findings normalization, correlation, validation, chaining, hooks | + ### Scripts/Dev — In Development | Directory | Contents | diff --git a/Scripts/AIAnalysis/README.md b/Scripts/AIAnalysis/README.md new file mode 100644 index 0000000..6d8b2b6 --- /dev/null +++ b/Scripts/AIAnalysis/README.md @@ -0,0 +1,142 @@ +# Veracode AI Analysis Layer + +A model-agnostic pipeline that sits on top of the Veracode scripts and adds +**cross-scan correlation**, **AI-assisted false-positive reduction**, and +**risk chaining** using one or more Claude models. + +It is built so that **Claude Mythos 5 and future frontier models can be +switched on with a config edit** — no code changes — to validate and feed +into the results, reduce false positives, and surface a deeper breadth of +chained findings. + +--- + +## What it does + +``` + Veracode scans AI Analysis Layer Output +┌──────────────┐ normalize ┌────────────────────────────┐ ┌──────────────┐ +│ SAST (STATIC)│──────────────▶│ 1. Correlate (deterministic)│ │ risk chains │ +│ DAST │ │ 2. Validate (model panel) │──▶│ FP verdicts │ +│ SCA │ │ 3. Chain (model) │ │ correlations │ +│ Container │ └────────────────────────────┘ └──────────────┘ +└──────────────┘ driven entirely by models.json +``` + +1. **Normalize** every scan type into one `UnifiedFinding` shape. +2. **Correlate** (offline, rule-based — runs in CI with no API key): + - **cross-layer confirmation** — same CWE found by SAST *and* DAST (present in code *and* reachable at runtime) + - **common flaw source** — static findings sharing a data-path source (fix once, remediate many) + - **shared CVE** — the same CVE in both SCA and container scans (confirmed in the deployed artifact) + - **dependency usage** — a vulnerable SCA component referenced by first-party flawed code +3. **Validate** — every enabled validator model independently tries to *refute* + each finding; a consensus quorum decides confirmed vs. likely-false-positive. +4. **Chain** — a chain-role model composes correlated findings into ordered + attack paths with combined severity, remediation order, and a recommended + security-training topic. + +--- + +## Onboarding a new frontier model (Mythos / next model) + +Edit [`models.json`](models.json) — set `"enabled": true`. That's the whole change. + +```jsonc +"mythos": { + "id": "claude-mythos-5", + "enabled": true, // ← flip this on + "thinking": "omit", // Fable/Mythos: thinking always on, send no config + "effort": "xhigh", + "max_tokens": 16000, + "fallback_model": "claude-opus-4-8", // refusal fallback via server-side beta + "roles": ["deep-validate", "chain", "second-opinion"] +} +``` + +The new model immediately: +- joins the **validator panel** (a `deep-validate` vote runs alongside the existing votes), +- runs in the **chain stage** (its chains are tagged with its model id so you can compare), +- and requests are shaped correctly for its family (adaptive thinking vs. omit, + effort level, server-side refusal fallback). + +A model that isn't served yet (404) is disabled for that run and the pipeline +degrades gracefully — so you can enable a registry entry ahead of GA without +breaking CI. + +**Roles**: `triage`, `validate`, `deep-validate`, `correlate`, `chain`, `second-opinion`. + +--- + +## Operational hooks + +Every stage fires a hook so external tooling — including a future frontier +model running *alongside* the pipeline — can observe or transform the data +without editing pipeline code: + +```python +from veracode_ai.hooks import hooks + +@hooks.register("post_validate") +def route_low_confidence(payload): + # payload = {"finding": ..., "verdict": ..., "votes": [...]} + # e.g. send split-vote findings to a frontier model for a second opinion + return payload +``` + +Hook points, in order: `pre_ingest`, `post_normalize`, `pre_correlate`, +`post_correlate`, `pre_validate`, `post_validate`, `pre_chain`, `post_chain`, +`report`. + +--- + +## Usage + +```bash +# Offline correlation only — no API key, CI-safe +python analyze.py --rest findings.json --offline -o report.json + +# Full pipeline: correlate + FP-reduce + risk-chain +export ANTHROPIC_API_KEY=... +python analyze.py \ + --rest findings.json \ + --container image-scan.json \ + -o report.json + +# Show the enabled model roster +python analyze.py --print-models +``` + +`--rest` takes a Veracode REST Findings API response +(`GET /appsec/v2/applications/{guid}/findings`, any scan type). `--container` +takes Veracode CLI container scan JSON. + +### As a library + +```python +from veracode_ai import load_registry +from veracode_ai.pipeline import Pipeline +from veracode_ai.providers import Provider + +pipeline = Pipeline(registry=load_registry(), provider=Provider()) +report = pipeline.analyze({"rest": "findings.json", "container": "image.json"}) +``` + +--- + +## Design notes + +- **Model-agnostic**: no model id is hard-coded in the pipeline; everything + comes from `models.json` via the registry. +- **Structured outputs**: every model call uses `output_config.format` + (`json_schema`), so responses are guaranteed-parseable — no fragile text scraping. +- **Family-aware requests**: `providers.py` sends `thinking={"type":"adaptive"}` + for Opus/Sonnet, omits `thinking` entirely for Fable/Mythos (where it's always + on), and attaches server-side refusal fallbacks when a `fallback_model` is set. +- **Adversarial validation**: validators are prompted to *refute* findings, and + a finding only becomes a likely-FP on a quorum — biased toward not silently + dropping real bugs. +- **Offline-first**: correlation needs only the standard library, so it runs in + every CI job; the model stages are additive. + +Requirements: `pip install -r requirements.txt` (only needed for the +validate/chain stages; correlation is stdlib-only). diff --git a/Scripts/AIAnalysis/analyze.py b/Scripts/AIAnalysis/analyze.py new file mode 100644 index 0000000..c712885 --- /dev/null +++ b/Scripts/AIAnalysis/analyze.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Veracode AI Analysis — CLI entry point. + +Correlate and validate findings across Veracode scan types with one or more +Claude models. Model selection is driven entirely by models.json. + +Examples: + # Offline correlation only (no API key needed) — CI-safe + python analyze.py --rest findings.json --offline -o report.json + + # Full pipeline: correlate + FP-reduce + risk-chain + export ANTHROPIC_API_KEY=... + python analyze.py --rest findings.json --container image.json -o report.json + +Onboarding a new frontier model (e.g. Claude Mythos 5): set its +"enabled": true in models.json. No code change, no CLI flag. +""" + +import argparse +import json +import sys + +sys.path.insert(0, __file__.rsplit("/", 1)[0]) + +from veracode_ai.config import load_registry +from veracode_ai.pipeline import Pipeline + + +def main(): + parser = argparse.ArgumentParser(description="Veracode AI cross-scan analysis") + parser.add_argument("--rest", help="Veracode REST Findings API JSON (any scan type)") + parser.add_argument("--container", help="Container scan JSON (Veracode CLI)") + parser.add_argument("--config", help="Path to models.json (default: bundled)") + parser.add_argument("-o", "--output", default="ai-analysis-report.json") + parser.add_argument("--offline", action="store_true", + help="Correlation only; no model calls (no API key needed)") + parser.add_argument("--print-models", action="store_true", + help="Print the enabled model roster and exit") + args = parser.parse_args() + + registry = load_registry(args.config) + + if args.print_models: + for m in registry.enabled_models(): + print(f"{m.key:8} {m.id:20} roles={','.join(m.roles)}") + return 0 + + scan_payloads = {} + if args.rest: + scan_payloads["rest"] = args.rest + if args.container: + scan_payloads["container"] = args.container + if not scan_payloads: + parser.error("provide at least one of --rest / --container") + + provider = None + if not args.offline: + try: + from veracode_ai.providers import Provider + provider = Provider() + except RuntimeError as exc: + print(f"[WARN] {exc}\n[WARN] Falling back to --offline mode.", file=sys.stderr) + + pipeline = Pipeline(registry=registry, provider=provider) + report = pipeline.analyze(scan_payloads, offline=args.offline or provider is None) + + with open(args.output, "w") as f: + json.dump(report, f, indent=2) + + print(f"[INFO] mode={report['mode']} findings={report['total_findings']} " + f"correlations={len(report['correlations'])} " + f"chains={len(report.get('risk_chains', []))}") + print(f"[INFO] wrote {args.output}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Scripts/AIAnalysis/models.json b/Scripts/AIAnalysis/models.json new file mode 100644 index 0000000..0067a78 --- /dev/null +++ b/Scripts/AIAnalysis/models.json @@ -0,0 +1,56 @@ +{ + "_comment": "Model registry for the Veracode AI analysis layer. To onboard a new frontier model (e.g. Claude Mythos 5) set enabled=true — no code changes required. Roles: triage | validate | deep-validate | correlate | chain | second-opinion", + + "models": { + "opus": { + "id": "claude-opus-4-8", + "enabled": true, + "thinking": "adaptive", + "effort": "high", + "max_tokens": 16000, + "roles": ["triage", "validate", "correlate", "chain"] + }, + "sonnet": { + "id": "claude-sonnet-5", + "enabled": true, + "thinking": "adaptive", + "effort": "medium", + "max_tokens": 8000, + "roles": ["validate"] + }, + "fable": { + "id": "claude-fable-5", + "enabled": false, + "thinking": "omit", + "effort": "high", + "max_tokens": 16000, + "fallback_model": "claude-opus-4-8", + "roles": ["deep-validate", "chain", "second-opinion"] + }, + "mythos": { + "id": "claude-mythos-5", + "enabled": false, + "thinking": "omit", + "effort": "xhigh", + "max_tokens": 16000, + "fallback_model": "claude-opus-4-8", + "roles": ["deep-validate", "chain", "second-opinion"] + } + }, + + "strategies": { + "fp_reduction": { + "mode": "consensus", + "validator_roles": ["validate", "deep-validate"], + "quorum_fraction": 0.5, + "batch_size": 10 + }, + "risk_chaining": { + "chain_roles": ["chain"], + "max_findings_per_request": 60 + }, + "second_opinion": { + "roles": ["second-opinion"] + } + } +} diff --git a/Scripts/AIAnalysis/requirements.txt b/Scripts/AIAnalysis/requirements.txt new file mode 100644 index 0000000..85ec95f --- /dev/null +++ b/Scripts/AIAnalysis/requirements.txt @@ -0,0 +1,4 @@ +# Veracode AI analysis layer +# Offline correlation needs no dependencies (stdlib only). +# The validate/chain stages require the Anthropic SDK: +anthropic>=0.69.0 diff --git a/Scripts/AIAnalysis/veracode_ai/__init__.py b/Scripts/AIAnalysis/veracode_ai/__init__.py new file mode 100644 index 0000000..85d6a37 --- /dev/null +++ b/Scripts/AIAnalysis/veracode_ai/__init__.py @@ -0,0 +1,23 @@ +"""Veracode AI analysis layer. + +Model-agnostic pipeline that correlates findings across Veracode scan types +(SAST, DAST, SCA, container), validates them with one or more Claude models +to reduce false positives, and chains related findings into risk paths. + +New frontier models (Claude Mythos 5, future releases) are onboarded by +editing models.json — no code changes required. +""" + +from .config import ModelConfig, Registry, load_registry +from .findings import UnifiedFinding, normalize_findings +from .hooks import HookRegistry, hooks + +__all__ = [ + "ModelConfig", + "Registry", + "load_registry", + "UnifiedFinding", + "normalize_findings", + "HookRegistry", + "hooks", +] diff --git a/Scripts/AIAnalysis/veracode_ai/config.py b/Scripts/AIAnalysis/veracode_ai/config.py new file mode 100644 index 0000000..946a34e --- /dev/null +++ b/Scripts/AIAnalysis/veracode_ai/config.py @@ -0,0 +1,91 @@ +"""Model registry loader. + +The registry is pure data (models.json). Adding or enabling a model — +including Claude Mythos 5 or a future frontier model — is a config edit, +never a code change. Each entry carries the request-shaping knobs that +differ between model families (thinking mode, effort, refusal fallback). +""" + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +DEFAULT_CONFIG_PATH = Path(__file__).parent.parent / "models.json" + +VALID_ROLES = { + "triage", # initial severity/priority assessment + "validate", # standard FP validation vote + "deep-validate", # frontier-model validation (higher-confidence vote) + "correlate", # cross-scan correlation assistance + "chain", # risk/attack-path chaining + "second-opinion", # full-set review for missed findings +} + +# "adaptive" -> send thinking={"type": "adaptive"} (Opus/Sonnet 4.6+) +# "omit" -> send no thinking param at all (Fable 5 / Mythos 5: always on) +VALID_THINKING = {"adaptive", "omit"} + + +@dataclass +class ModelConfig: + key: str + id: str + enabled: bool = True + thinking: str = "adaptive" + effort: str = "high" + max_tokens: int = 8000 + fallback_model: Optional[str] = None + roles: list = field(default_factory=list) + + def __post_init__(self): + if self.thinking not in VALID_THINKING: + raise ValueError( + f"model '{self.key}': thinking must be one of {sorted(VALID_THINKING)}" + ) + unknown = set(self.roles) - VALID_ROLES + if unknown: + raise ValueError(f"model '{self.key}': unknown roles {sorted(unknown)}") + + def has_role(self, role: str) -> bool: + return role in self.roles + + +@dataclass +class Registry: + models: dict + strategies: dict + + def enabled_models(self) -> list: + return [m for m in self.models.values() if m.enabled] + + def models_for_role(self, role: str) -> list: + """All enabled models that can serve a role.""" + return [m for m in self.enabled_models() if m.has_role(role)] + + def models_for_roles(self, roles) -> list: + """Enabled models matching any of the given roles, deduplicated.""" + seen, out = set(), [] + for role in roles: + for m in self.models_for_role(role): + if m.key not in seen: + seen.add(m.key) + out.append(m) + return out + + def strategy(self, name: str) -> dict: + return self.strategies.get(name, {}) + + +def load_registry(path=None) -> Registry: + """Load models.json into a validated Registry.""" + config_path = Path(path) if path else DEFAULT_CONFIG_PATH + with open(config_path) as f: + raw = json.load(f) + + models = {} + for key, entry in raw.get("models", {}).items(): + entry = {k: v for k, v in entry.items() if not k.startswith("_")} + models[key] = ModelConfig(key=key, **entry) + + return Registry(models=models, strategies=raw.get("strategies", {})) diff --git a/Scripts/AIAnalysis/veracode_ai/correlate.py b/Scripts/AIAnalysis/veracode_ai/correlate.py new file mode 100644 index 0000000..0d346ad --- /dev/null +++ b/Scripts/AIAnalysis/veracode_ai/correlate.py @@ -0,0 +1,215 @@ +"""Cross-scan correlation and risk chaining. + +Two layers: + +1. Deterministic correlation (no API): rule-based links across scan types — + cross-layer CWE confirmation (SAST+DAST), shared flaw sources, SCA/container + CVE overlap, dependency-usage links. Runs offline and in CI. + +2. Model-assisted risk chaining: a chain-role model composes correlated + findings into ordered attack paths with a combined severity and rationale. +""" + +from collections import defaultdict + +from .providers import ModelUnavailable, ModelRefused + +# --------------------------------------------------------------------------- +# Deterministic correlation rules +# --------------------------------------------------------------------------- + + +def correlate(findings) -> list: + """Apply rule-based correlations. Mutates findings (appends to + .correlations) and returns the list of correlation dicts.""" + correlations = [] + correlations += _cross_layer_cwe(findings) + correlations += _common_flaw_source(findings) + correlations += _shared_cve(findings) + correlations += _dependency_usage(findings) + + by_id = {f.id: f for f in findings} + for corr in correlations: + for fid in corr["finding_ids"]: + if fid in by_id: + by_id[fid].correlations.append(corr["type"]) + return correlations + + +def _cross_layer_cwe(findings) -> list: + """Same CWE observed by both static and dynamic analysis: the flaw is not + just present in code — it is reachable in the running application.""" + by_cwe = defaultdict(lambda: defaultdict(list)) + for f in findings: + if f.cwe and f.source in ("STATIC", "DYNAMIC"): + by_cwe[f.cwe][f.source].append(f.id) + + out = [] + for cwe, sources in by_cwe.items(): + if "STATIC" in sources and "DYNAMIC" in sources: + out.append({ + "type": "cross_layer_confirmation", + "cwe": cwe, + "finding_ids": sources["STATIC"] + sources["DYNAMIC"], + "note": ( + f"CWE-{cwe} found by both static and dynamic analysis — " + "the weakness is present in code AND reachable at runtime. " + "Treat as likely exploitable; prioritize remediation." + ), + }) + return out + + +def _common_flaw_source(findings) -> list: + """Static findings sharing an attack vector (data-path source): fixing the + shared source remediates the whole group at once.""" + by_vector = defaultdict(list) + for f in findings: + if f.source == "STATIC" and f.attack_vector: + by_vector[f.attack_vector].append(f.id) + + return [ + { + "type": "common_flaw_source", + "attack_vector": vector, + "finding_ids": ids, + "note": ( + f"{len(ids)} static findings share the data-path source " + f"'{vector}'. Sanitizing at this source remediates all of them." + ), + } + for vector, ids in by_vector.items() + if len(ids) >= 2 + ] + + +def _shared_cve(findings) -> list: + """Same CVE reported by SCA and container scanning: the vulnerable library + ships in both the dependency tree and the deployed image.""" + by_cve = defaultdict(lambda: defaultdict(list)) + for f in findings: + if f.cve and f.source in ("SCA", "CONTAINER"): + by_cve[f.cve][f.source].append(f.id) + + out = [] + for cve, sources in by_cve.items(): + if len(sources) >= 2: + ids = [fid for group in sources.values() for fid in group] + out.append({ + "type": "shared_cve", + "cve": cve, + "finding_ids": ids, + "note": ( + f"{cve} appears in both the dependency tree (SCA) and the " + "container image — confirmed in the deployed artifact." + ), + }) + return out + + +def _dependency_usage(findings) -> list: + """SCA component name appearing in static finding module/file paths: + the vulnerable dependency is referenced by first-party flawed code.""" + sca = [f for f in findings if f.source == "SCA" and f.module] + static = [f for f in findings if f.source == "STATIC"] + out = [] + for s in sca: + # component base name without extension/version noise, e.g. "log4j" + base = s.module.rsplit("/", 1)[-1].split("-")[0].split(".")[0].lower() + if len(base) < 4: + continue + hits = [ + st.id for st in static + if base in st.location.lower() or base in st.module.lower() + ] + if hits: + out.append({ + "type": "dependency_usage", + "component": s.module, + "finding_ids": [s.id] + hits, + "note": ( + f"Vulnerable component '{s.module}' is referenced near " + "first-party static findings — elevated exposure." + ), + }) + return out + + +# --------------------------------------------------------------------------- +# Model-assisted risk chaining +# --------------------------------------------------------------------------- + +CHAIN_SCHEMA = { + "type": "object", + "properties": { + "chains": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "finding_ids": {"type": "array", "items": {"type": "string"}}, + "combined_severity": {"type": "integer"}, + "rationale": {"type": "string"}, + "remediation_order": {"type": "array", "items": {"type": "string"}}, + "recommended_training": {"type": "string"}, + }, + "required": [ + "name", "finding_ids", "combined_severity", + "rationale", "remediation_order", "recommended_training", + ], + "additionalProperties": False, + }, + } + }, + "required": ["chains"], + "additionalProperties": False, +} + +CHAIN_SYSTEM = ( + "You are an application security analyst reviewing correlated findings from " + "Veracode static, dynamic, software composition, and container scans of one " + "application. Compose findings into risk chains: ordered sequences an attacker " + "could combine (e.g. exposed endpoint -> injection -> vulnerable library -> " + "container escape surface). Only chain findings with a plausible technical " + "link; do not force unrelated findings together. combined_severity is 0-5. " + "recommended_training names the security training topic that best addresses " + "the chain's root cause (e.g. 'OWASP A03 Injection', 'Secure dependency " + "management')." +) + + +def chain_risks(findings, correlations, registry, provider) -> list: + """Ask each enabled chain-role model to build risk chains. + + Multiple chain models (e.g. Opus today, Mythos when enabled) each produce + chains; results are tagged by model so downstream consumers can compare + or merge. Degrades to [] when no chain model is available. + """ + strategy = registry.strategy("risk_chaining") + limit = strategy.get("max_findings_per_request", 60) + chain_models = registry.models_for_roles(strategy.get("chain_roles", ["chain"])) + + ranked = sorted(findings, key=lambda f: f.severity, reverse=True)[:limit] + finding_block = "\n".join(f.summary_line() for f in ranked) + corr_block = "\n".join( + f"- {c['type']}: {c['note']} (findings: {', '.join(c['finding_ids'])})" + for c in correlations + ) or "(no deterministic correlations found)" + + user = ( + f"FINDINGS ({len(ranked)} highest severity shown):\n{finding_block}\n\n" + f"DETERMINISTIC CORRELATIONS:\n{corr_block}\n\n" + "Build the risk chains." + ) + + chains = [] + for model_cfg in chain_models: + try: + result = provider.structured(model_cfg, CHAIN_SYSTEM, user, CHAIN_SCHEMA) + except (ModelUnavailable, ModelRefused): + continue + for chain in result.get("chains", []): + chain["model"] = model_cfg.id + chains.append(chain) + return chains diff --git a/Scripts/AIAnalysis/veracode_ai/findings.py b/Scripts/AIAnalysis/veracode_ai/findings.py new file mode 100644 index 0000000..b17dfc3 --- /dev/null +++ b/Scripts/AIAnalysis/veracode_ai/findings.py @@ -0,0 +1,158 @@ +"""Unified finding schema and normalizers. + +Every scan type (SAST, DAST, SCA, container) is normalized into +UnifiedFinding so correlation, validation, and chaining operate on one +shape. Normalizers accept the Veracode REST Findings API response +(GET /appsec/v2/applications/{guid}/findings) and best-effort container +scan output from the Veracode CLI. +""" + +import hashlib +import json +from dataclasses import dataclass, field, asdict +from typing import Optional + + +@dataclass +class UnifiedFinding: + id: str # stable id derived from source content + source: str # STATIC | DYNAMIC | SCA | CONTAINER | CONFIG + severity: int # 0 (info) - 5 (very high), Veracode scale + title: str + description: str = "" + cwe: Optional[int] = None + cve: Optional[str] = None + cvss: Optional[float] = None + location: str = "" # file:line, URL, component name, or image layer + module: str = "" # module/component grouping key + attack_vector: str = "" # SAST data-path entry point where available + status: str = "OPEN" + raw: dict = field(default_factory=dict) + + # Enriched by the pipeline + correlations: list = field(default_factory=list) + validation: Optional[dict] = None + + def to_dict(self, include_raw: bool = False) -> dict: + d = asdict(self) + if not include_raw: + d.pop("raw", None) + return d + + def summary_line(self) -> str: + """Compact single-line form used in model prompts.""" + parts = [f"[{self.id}] {self.source} sev={self.severity}"] + if self.cwe: + parts.append(f"CWE-{self.cwe}") + if self.cve: + parts.append(self.cve) + parts.append(self.title[:120]) + if self.location: + parts.append(f"@ {self.location}") + if self.attack_vector: + parts.append(f"vector: {self.attack_vector[:80]}") + return " | ".join(parts) + + +def _stable_id(*parts) -> str: + digest = hashlib.sha256("|".join(str(p) for p in parts).encode()).hexdigest() + return digest[:12] + + +def _normalize_rest_finding(f: dict) -> UnifiedFinding: + """One finding from the Veracode REST Findings API (any scan_type).""" + details = f.get("finding_details", {}) or {} + scan_type = (f.get("scan_type") or "STATIC").upper() + cwe = (details.get("cwe") or {}).get("id") + severity = details.get("severity", 3) + + cve_info = details.get("cve") or {} + cve = cve_info.get("name") + cvss = cve_info.get("cvss") or cve_info.get("cvss3", {}).get("score") \ + if isinstance(cve_info.get("cvss3"), dict) else cve_info.get("cvss") + + if scan_type == "STATIC": + file_name = details.get("file_name", "") + line = details.get("file_line_number", "") + location = f"{file_name}:{line}" if file_name else "" + module = details.get("module", "") or (file_name.rsplit("/", 1)[-1] if file_name else "") + title = ((details.get("finding_category") or {}).get("name") + or (details.get("cwe") or {}).get("name") or "Static finding") + elif scan_type == "DYNAMIC": + location = details.get("url", "") or details.get("path", "") + module = details.get("hostname", "") + title = ((details.get("cwe") or {}).get("name") or "Dynamic finding") + elif scan_type == "SCA": + component = details.get("component_filename", "") or details.get("component_path", "") + location = component + module = component + title = cve or ((details.get("cwe") or {}).get("name") or "SCA finding") + else: + location = details.get("url", "") or details.get("file_name", "") + module = "" + title = (details.get("cwe") or {}).get("name") or f"{scan_type} finding" + + status = ((f.get("finding_status") or {}).get("status") or "OPEN").upper() + issue_id = f.get("issue_id") or _stable_id(scan_type, cwe, location, title) + + return UnifiedFinding( + id=f"{scan_type[:3]}-{issue_id}", + source=scan_type, + severity=int(severity) if severity is not None else 3, + title=title, + description=(f.get("description") or "")[:2000], + cwe=cwe, + cve=cve, + cvss=float(cvss) if cvss else None, + location=location, + module=module, + attack_vector=str(details.get("attack_vector", "") or ""), + status=status, + raw=f, + ) + + +def normalize_rest_findings(payload: dict) -> list: + """Normalize a Veracode REST Findings API response (all scan types).""" + findings = (payload.get("_embedded") or {}).get("findings", []) + if not findings and isinstance(payload.get("findings"), list): + findings = payload["findings"] + return [_normalize_rest_finding(f) for f in findings] + + +def normalize_container_findings(payload: dict) -> list: + """Best-effort normalization of Veracode CLI container scan JSON.""" + out = [] + vulns = payload.get("vulnerabilities") or payload.get("matches") or [] + for v in vulns: + cve = v.get("cve") or v.get("id") or (v.get("vulnerability") or {}).get("id") + sev_name = str(v.get("severity", "medium")).lower() + severity = {"critical": 5, "very high": 5, "high": 4, "medium": 3, + "low": 2, "negligible": 1, "info": 0}.get(sev_name, 3) + component = (v.get("artifact") or {}).get("name") or v.get("package", "") or "" + out.append(UnifiedFinding( + id=f"CON-{_stable_id(cve, component)}", + source="CONTAINER", + severity=severity, + title=cve or "Container vulnerability", + description=(v.get("description") or "")[:2000], + cve=cve if cve and str(cve).upper().startswith("CVE") else None, + location=component, + module=component, + raw=v, + )) + return out + + +def normalize_findings(payload, source_hint: str = "auto") -> list: + """Normalize any supported payload into UnifiedFindings. + + payload may be a dict (parsed JSON) or a path to a JSON file. + """ + if isinstance(payload, str): + with open(payload) as f: + payload = json.load(f) + + if source_hint == "container" or "vulnerabilities" in payload or "matches" in payload: + return normalize_container_findings(payload) + return normalize_rest_findings(payload) diff --git a/Scripts/AIAnalysis/veracode_ai/hooks.py b/Scripts/AIAnalysis/veracode_ai/hooks.py new file mode 100644 index 0000000..f15ca5d --- /dev/null +++ b/Scripts/AIAnalysis/veracode_ai/hooks.py @@ -0,0 +1,79 @@ +"""Operational hooks. + +Hook points let external tooling — including future frontier models running +alongside the pipeline — observe or modify data at each stage without +touching pipeline code. Register with a decorator: + + from veracode_ai.hooks import hooks + + @hooks.register("post_validate") + def my_hook(payload): + # payload is the stage's data; return it (possibly modified) + return payload + +Hook points fired by the pipeline, in order: + + pre_ingest raw scan payloads, before normalization + post_normalize list[UnifiedFinding] + pre_correlate list[UnifiedFinding] + post_correlate {"findings": [...], "correlations": [...]} + pre_validate findings selected for validation + post_validate {"finding": ..., "votes": [...], "verdict": ...} per finding + pre_chain confirmed findings heading into risk chaining + post_chain {"chains": [...]} + report the final report dict, before it is written +""" + +HOOK_POINTS = ( + "pre_ingest", + "post_normalize", + "pre_correlate", + "post_correlate", + "pre_validate", + "post_validate", + "pre_chain", + "post_chain", + "report", +) + + +class HookRegistry: + def __init__(self): + self._hooks = {point: [] for point in HOOK_POINTS} + + def register(self, point: str): + """Decorator: attach a callable to a hook point.""" + if point not in self._hooks: + raise ValueError(f"unknown hook point '{point}'; valid: {HOOK_POINTS}") + + def decorator(fn): + self._hooks[point].append(fn) + return fn + + return decorator + + def emit(self, point: str, payload): + """Run every hook registered at `point`, threading the payload through. + + A hook that returns None leaves the payload unchanged; any other + return value replaces it for the next hook. + """ + for fn in self._hooks.get(point, []): + result = fn(payload) + if result is not None: + payload = result + return payload + + def clear(self, point=None): + if point is None: + for p in self._hooks: + self._hooks[p] = [] + else: + self._hooks[point] = [] + + def count(self, point: str) -> int: + return len(self._hooks.get(point, [])) + + +# Module-level default registry used by the pipeline +hooks = HookRegistry() diff --git a/Scripts/AIAnalysis/veracode_ai/pipeline.py b/Scripts/AIAnalysis/veracode_ai/pipeline.py new file mode 100644 index 0000000..6d728d3 --- /dev/null +++ b/Scripts/AIAnalysis/veracode_ai/pipeline.py @@ -0,0 +1,85 @@ +"""End-to-end analysis pipeline. + +Stages (each fires operational hooks so external tooling / future frontier +models can observe or transform the data): + + ingest -> normalize -> correlate -> validate (FP reduction) -> chain -> report + +The pipeline is model-agnostic: which models run at each stage comes entirely +from the registry (models.json). Enabling Claude Mythos 5 there adds it to the +validator panel and chain stage with no code change. +""" + +from . import findings as findings_mod +from .config import load_registry +from .correlate import correlate, chain_risks +from .hooks import hooks as default_hooks +from .validate import validate_findings + + +class Pipeline: + def __init__(self, registry=None, provider=None, hooks=None): + self.registry = registry or load_registry() + self.provider = provider # None => offline (correlation only) + self.hooks = hooks or default_hooks + + def analyze(self, scan_payloads: dict, offline: bool = None) -> dict: + """Run the full pipeline. + + scan_payloads: {source_hint: payload_or_path}, e.g. + {"rest": findings_json, "container": container_json} + offline: force correlation-only (no model calls). Defaults to True when + no provider is configured. + """ + offline = (self.provider is None) if offline is None else offline + + # --- ingest + normalize ------------------------------------------- + scan_payloads = self.hooks.emit("pre_ingest", scan_payloads) + findings = [] + for hint, payload in scan_payloads.items(): + findings.extend(findings_mod.normalize_findings(payload, source_hint=hint)) + findings = self.hooks.emit("post_normalize", findings) + + # --- correlate (deterministic, always runs) ----------------------- + findings = self.hooks.emit("pre_correlate", findings) + correlations = correlate(findings) + self.hooks.emit("post_correlate", + {"findings": findings, "correlations": correlations}) + + report = { + "counts": _source_counts(findings), + "total_findings": len(findings), + "correlations": correlations, + } + + if offline: + report["mode"] = "offline" + report["findings"] = [f.to_dict() for f in findings] + return self.hooks.emit("report", report) + + # --- validate (FP reduction) -------------------------------------- + to_validate = self.hooks.emit("pre_validate", findings) + report["validation"] = validate_findings( + to_validate, self.registry, self.provider, self.hooks + ) + + # --- chain (confirmed findings only) ------------------------------ + confirmed = [ + f for f in findings + if not f.validation or f.validation["verdict"] != "likely_false_positive" + ] + confirmed = self.hooks.emit("pre_chain", confirmed) + chains = chain_risks(confirmed, correlations, self.registry, self.provider) + self.hooks.emit("post_chain", {"chains": chains}) + + report["mode"] = "full" + report["risk_chains"] = chains + report["findings"] = [f.to_dict() for f in findings] + return self.hooks.emit("report", report) + + +def _source_counts(findings) -> dict: + counts = {} + for f in findings: + counts[f.source] = counts.get(f.source, 0) + 1 + return counts diff --git a/Scripts/AIAnalysis/veracode_ai/providers.py b/Scripts/AIAnalysis/veracode_ai/providers.py new file mode 100644 index 0000000..8abded8 --- /dev/null +++ b/Scripts/AIAnalysis/veracode_ai/providers.py @@ -0,0 +1,103 @@ +"""Anthropic model provider. + +Shapes each request per the model family described in models.json: + + - thinking="adaptive" -> sends thinking={"type": "adaptive"} (Opus 4.8, Sonnet 5) + - thinking="omit" -> sends no thinking parameter at all (Fable 5 / Mythos 5, + where thinking is always on and any explicit config 400s) + - fallback_model set -> uses the beta messages endpoint with server-side + refusal fallbacks so a safety-classifier decline is + transparently re-served by the fallback model + +Structured output uses output_config.format (json_schema), so responses are +guaranteed-parseable JSON. A model that is not yet served (404) is disabled +for the rest of the run instead of failing the pipeline — this is what lets +the registry carry entries for models that ship later. +""" + +import json + +try: + import anthropic +except ImportError: # pragma: no cover - exercised only without the SDK + anthropic = None + +SERVER_SIDE_FALLBACK_BETA = "server-side-fallback-2026-06-01" + + +class ModelUnavailable(Exception): + """Model is not served (yet) or was disabled mid-run.""" + + +class ModelRefused(Exception): + """Safety classifiers declined and no fallback rescued the request.""" + + +class Provider: + """Wraps one Anthropic client; issues structured requests per model config.""" + + def __init__(self, client=None): + if client is None: + if anthropic is None: + raise RuntimeError( + "The 'anthropic' package is required for live analysis: pip install anthropic" + ) + client = anthropic.Anthropic() + self.client = client + self._dead_models = set() + + def available(self, model_cfg) -> bool: + return model_cfg.id not in self._dead_models + + def structured(self, model_cfg, system: str, user: str, schema: dict) -> dict: + """Run one structured-output request; returns the parsed JSON object.""" + if not self.available(model_cfg): + raise ModelUnavailable(model_cfg.id) + + kwargs = { + "model": model_cfg.id, + "max_tokens": model_cfg.max_tokens, + "system": system, + "messages": [{"role": "user", "content": user}], + "output_config": { + "effort": model_cfg.effort, + "format": {"type": "json_schema", "schema": schema}, + }, + } + if model_cfg.thinking == "adaptive": + kwargs["thinking"] = {"type": "adaptive"} + # thinking == "omit": send nothing — Fable/Mythos reject explicit config + + try: + if model_cfg.fallback_model: + response = self.client.beta.messages.create( + betas=[SERVER_SIDE_FALLBACK_BETA], + fallbacks=[{"model": model_cfg.fallback_model}], + **kwargs, + ) + else: + response = self.client.messages.create(**kwargs) + except Exception as exc: + if _is_model_not_found(exc): + # Model not served yet (e.g. registry entry enabled early). + # Disable for this run; callers degrade gracefully. + self._dead_models.add(model_cfg.id) + raise ModelUnavailable(model_cfg.id) from exc + raise + + if getattr(response, "stop_reason", None) == "refusal": + raise ModelRefused(model_cfg.id) + + text = next( + (b.text for b in response.content if getattr(b, "type", "") == "text"), + None, + ) + if text is None: + raise ModelRefused(model_cfg.id) + return json.loads(text) + + +def _is_model_not_found(exc) -> bool: + if anthropic is not None and isinstance(exc, anthropic.NotFoundError): + return True + return "not_found" in str(exc).lower() and "model" in str(exc).lower() diff --git a/Scripts/AIAnalysis/veracode_ai/validate.py b/Scripts/AIAnalysis/veracode_ai/validate.py new file mode 100644 index 0000000..a03cd73 --- /dev/null +++ b/Scripts/AIAnalysis/veracode_ai/validate.py @@ -0,0 +1,120 @@ +"""Multi-model false-positive validation. + +Every enabled model with a validator role independently tries to REFUTE each +finding (adversarial framing — a validator that can't refute it confirms it). +Verdicts are combined by consensus: a finding is confirmed unless a quorum of +validators refute it. Frontier models (deep-validate role) cast the same vote +shape, so enabling Mythos in models.json immediately adds an extra, +higher-confidence voice to the panel — no code changes. +""" + +from .providers import ModelUnavailable, ModelRefused + +VERDICT_SCHEMA = { + "type": "object", + "properties": { + "verdicts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "finding_id": {"type": "string"}, + "is_likely_false_positive": {"type": "boolean"}, + "confidence": {"type": "string", "enum": ["low", "medium", "high"]}, + "rationale": {"type": "string"}, + }, + "required": [ + "finding_id", "is_likely_false_positive", + "confidence", "rationale", + ], + "additionalProperties": False, + }, + } + }, + "required": ["verdicts"], + "additionalProperties": False, +} + +VALIDATE_SYSTEM = ( + "You are an application security reviewer performing false-positive triage " + "on scanner findings. For each finding, actively try to REFUTE it: look for " + "signs it is a false positive (framework-mitigated pattern, non-reachable " + "code path, test fixture, informational-only, scanner heuristic misfire). " + "Mark is_likely_false_positive=true ONLY when you can articulate a concrete " + "refutation in the rationale. When in doubt, the finding stands " + "(is_likely_false_positive=false). Return one verdict per finding, using " + "the exact finding_id given." +) + + +def _batches(items, size): + for i in range(0, len(items), size): + yield items[i:i + size] + + +def consensus(votes, quorum_fraction: float) -> dict: + """Combine per-model votes into a verdict. + + votes: list of {"model", "is_likely_false_positive", "confidence", "rationale"} + A finding is marked a likely FP only when at least quorum_fraction of the + votes refute it. Zero votes -> unvalidated (finding stands). + """ + if not votes: + return {"verdict": "unvalidated", "fp_votes": 0, "total_votes": 0, "votes": []} + fp_votes = sum(1 for v in votes if v["is_likely_false_positive"]) + is_fp = (fp_votes / len(votes)) >= quorum_fraction and fp_votes > 0 + return { + "verdict": "likely_false_positive" if is_fp else "confirmed", + "fp_votes": fp_votes, + "total_votes": len(votes), + "votes": votes, + } + + +def validate_findings(findings, registry, provider, hooks=None) -> dict: + """Run the validator panel over findings. Mutates each finding's + .validation and returns {"validated": n, "confirmed": n, "likely_fp": n, + "unvalidated": n}.""" + strategy = registry.strategy("fp_reduction") + quorum = strategy.get("quorum_fraction", 0.5) + batch_size = strategy.get("batch_size", 10) + validator_models = registry.models_for_roles( + strategy.get("validator_roles", ["validate", "deep-validate"]) + ) + + votes_by_finding = {f.id: [] for f in findings} + + for model_cfg in validator_models: + for batch in _batches(findings, batch_size): + block = "\n\n".join( + f"{f.summary_line()}\n{f.description[:600]}" for f in batch + ) + user = f"FINDINGS TO TRIAGE:\n\n{block}\n\nReturn one verdict per finding." + try: + result = provider.structured( + model_cfg, VALIDATE_SYSTEM, user, VERDICT_SCHEMA + ) + except (ModelUnavailable, ModelRefused): + break # this model is out for the run / batch declined + for v in result.get("verdicts", []): + fid = v.get("finding_id") + if fid in votes_by_finding: + votes_by_finding[fid].append({ + "model": model_cfg.id, + "is_likely_false_positive": bool(v["is_likely_false_positive"]), + "confidence": v.get("confidence", "low"), + "rationale": v.get("rationale", ""), + }) + + stats = {"validated": 0, "confirmed": 0, "likely_fp": 0, "unvalidated": 0} + for f in findings: + f.validation = consensus(votes_by_finding[f.id], quorum) + if hooks is not None: + hooks.emit("post_validate", {"finding": f, **f.validation}) + if f.validation["verdict"] == "unvalidated": + stats["unvalidated"] += 1 + else: + stats["validated"] += 1 + key = "likely_fp" if f.validation["verdict"] == "likely_false_positive" else "confirmed" + stats[key] += 1 + return stats diff --git a/tests/fixtures/container_scan.json b/tests/fixtures/container_scan.json new file mode 100644 index 0000000..5f69e3e --- /dev/null +++ b/tests/fixtures/container_scan.json @@ -0,0 +1,18 @@ +{ + "vulnerabilities": [ + { + "cve": "CVE-2021-44228", + "severity": "critical", + "description": "Log4Shell RCE present in the deployed image layer.", + "artifact": {"name": "log4j-core"}, + "package": "log4j-core" + }, + { + "cve": "CVE-2016-1000027", + "severity": "high", + "description": "Spring deserialization vulnerability.", + "artifact": {"name": "spring-web"}, + "package": "spring-web" + } + ] +} diff --git a/tests/fixtures/veracode_findings.json b/tests/fixtures/veracode_findings.json new file mode 100644 index 0000000..778ec41 --- /dev/null +++ b/tests/fixtures/veracode_findings.json @@ -0,0 +1,60 @@ +{ + "_embedded": { + "findings": [ + { + "issue_id": 1001, + "scan_type": "STATIC", + "description": "SQL query built from unsanitized request parameter.", + "finding_status": {"status": "OPEN"}, + "finding_details": { + "severity": 4, + "cwe": {"id": 89, "name": "SQL Injection"}, + "finding_category": {"name": "SQL Injection"}, + "file_name": "src/main/java/com/verademo/UserController.java", + "file_line_number": 142, + "module": "verademo.war", + "attack_vector": "javax.servlet.http.HttpServletRequest.getParameter" + } + }, + { + "issue_id": 1002, + "scan_type": "STATIC", + "description": "Second query concatenates the same request parameter.", + "finding_status": {"status": "OPEN"}, + "finding_details": { + "severity": 4, + "cwe": {"id": 89, "name": "SQL Injection"}, + "finding_category": {"name": "SQL Injection"}, + "file_name": "src/main/java/com/verademo/SearchController.java", + "file_line_number": 88, + "module": "verademo.war", + "attack_vector": "javax.servlet.http.HttpServletRequest.getParameter" + } + }, + { + "issue_id": 2001, + "scan_type": "DYNAMIC", + "description": "SQL injection confirmed against the live login endpoint.", + "finding_status": {"status": "OPEN"}, + "finding_details": { + "severity": 5, + "cwe": {"id": 89, "name": "SQL Injection"}, + "url": "https://app.example.com/login", + "hostname": "app.example.com" + } + }, + { + "issue_id": 3001, + "scan_type": "SCA", + "description": "log4j-core is vulnerable to remote code execution.", + "finding_status": {"status": "OPEN"}, + "finding_details": { + "severity": 5, + "cve": {"name": "CVE-2021-44228", "cvss": 10.0}, + "component_filename": "log4j-core-2.14.1.jar", + "component_path": "WEB-INF/lib/log4j-core-2.14.1.jar" + } + } + ] + } +} diff --git a/tests/integration/test_ai_pipeline.py b/tests/integration/test_ai_pipeline.py new file mode 100644 index 0000000..3a544df --- /dev/null +++ b/tests/integration/test_ai_pipeline.py @@ -0,0 +1,135 @@ +"""Integration tests for the AI analysis pipeline. + +Offline mode runs with no dependencies. Full mode uses a MockProvider that +returns structured verdicts/chains without calling the API, so the +orchestration (batching, consensus, chaining, hooks) is exercised end-to-end +in CI without an API key. +""" + +import sys +from pathlib import Path + +import pytest + +AI_DIR = Path(__file__).parent.parent.parent / "Scripts" / "AIAnalysis" +sys.path.insert(0, str(AI_DIR)) + +from veracode_ai.config import load_registry # noqa: E402 +from veracode_ai.pipeline import Pipeline # noqa: E402 +from veracode_ai.hooks import HookRegistry # noqa: E402 + +FIXTURES = Path(__file__).parent.parent / "fixtures" + +SCAN_PAYLOADS = { + "rest": str(FIXTURES / "veracode_findings.json"), + "container": str(FIXTURES / "container_scan.json"), +} + + +class MockProvider: + """Stands in for the Anthropic provider. Deterministically confirms every + finding and emits one risk chain, so orchestration can be tested offline.""" + + def __init__(self): + self.calls = [] + + def available(self, model_cfg): + return True + + def structured(self, model_cfg, system, user, schema): + self.calls.append(model_cfg.id) + if "verdicts" in schema["properties"]: + # confirm everything (no false positives) + ids = [tok[1:-1] for tok in user.split() if tok.startswith("[") and tok.endswith("]")] + return {"verdicts": [ + {"finding_id": fid, "is_likely_false_positive": False, + "confidence": "high", "rationale": "reachable"} + for fid in ids + ]} + return {"chains": [{ + "name": "SQLi to data exfiltration", + "finding_ids": [], + "combined_severity": 5, + "rationale": "chained", + "remediation_order": [], + "recommended_training": "OWASP A03 Injection", + }]} + + +class TestOfflinePipeline: + def test_offline_correlation_only(self): + pipeline = Pipeline(registry=load_registry(), provider=None) + report = pipeline.analyze(SCAN_PAYLOADS, offline=True) + assert report["mode"] == "offline" + assert report["total_findings"] == 6 + assert len(report["correlations"]) > 0 + + def test_offline_has_source_counts(self): + pipeline = Pipeline(provider=None) + report = pipeline.analyze(SCAN_PAYLOADS, offline=True) + assert report["counts"]["STATIC"] == 2 + assert report["counts"]["CONTAINER"] == 2 + + def test_offline_needs_no_validation(self): + pipeline = Pipeline(provider=None) + report = pipeline.analyze(SCAN_PAYLOADS, offline=True) + assert "validation" not in report + + +class TestFullPipeline: + @pytest.fixture + def pipeline(self): + return Pipeline(registry=load_registry(), provider=MockProvider()) + + def test_full_run_produces_validation(self, pipeline): + report = pipeline.analyze(SCAN_PAYLOADS, offline=False) + assert report["mode"] == "full" + assert "validation" in report + assert report["validation"]["confirmed"] == 6 + + def test_full_run_produces_chains(self, pipeline): + report = pipeline.analyze(SCAN_PAYLOADS, offline=False) + assert len(report["risk_chains"]) >= 1 + assert report["risk_chains"][0]["recommended_training"] + + def test_chain_tagged_with_model(self, pipeline): + report = pipeline.analyze(SCAN_PAYLOADS, offline=False) + assert "model" in report["risk_chains"][0] + + def test_only_chain_role_models_called_for_chaining(self): + provider = MockProvider() + pipeline = Pipeline(registry=load_registry(), provider=provider) + pipeline.analyze(SCAN_PAYLOADS, offline=False) + # opus has both validate and chain roles; it should appear + assert "claude-opus-4-8" in provider.calls + + +class TestHooksIntegration: + def test_hooks_fire_across_stages(self): + hooks = HookRegistry() + fired = [] + for point in ("pre_ingest", "post_normalize", "post_correlate", + "post_validate", "post_chain", "report"): + hooks.register(point)(lambda p, pt=point: fired.append(pt) or p) + + pipeline = Pipeline(registry=load_registry(), + provider=MockProvider(), hooks=hooks) + pipeline.analyze(SCAN_PAYLOADS, offline=False) + + assert "pre_ingest" in fired + assert "post_normalize" in fired + assert "post_chain" in fired + assert "report" in fired + + def test_pre_validate_hook_can_filter_findings(self): + hooks = HookRegistry() + + @hooks.register("pre_validate") + def _only_high(findings): + return [f for f in findings if f.severity >= 4] + + provider = MockProvider() + pipeline = Pipeline(registry=load_registry(), provider=provider, hooks=hooks) + report = pipeline.analyze(SCAN_PAYLOADS, offline=False) + # low-severity findings were filtered before validation + assert report["validation"]["confirmed"] <= 6 diff --git a/tests/unit/test_ai_config.py b/tests/unit/test_ai_config.py new file mode 100644 index 0000000..5019765 --- /dev/null +++ b/tests/unit/test_ai_config.py @@ -0,0 +1,78 @@ +"""Unit tests for the AI analysis model registry (models.json driven).""" + +import sys +from pathlib import Path + +import pytest + +AI_DIR = Path(__file__).parent.parent.parent / "Scripts" / "AIAnalysis" +sys.path.insert(0, str(AI_DIR)) + +from veracode_ai.config import ModelConfig, load_registry # noqa: E402 + + +class TestModelConfig: + def test_valid_model(self): + m = ModelConfig(key="opus", id="claude-opus-4-8", roles=["validate", "chain"]) + assert m.has_role("validate") + assert not m.has_role("triage") + + def test_invalid_thinking_rejected(self): + with pytest.raises(ValueError): + ModelConfig(key="x", id="y", thinking="enabled") + + def test_unknown_role_rejected(self): + with pytest.raises(ValueError): + ModelConfig(key="x", id="y", roles=["not-a-role"]) + + +class TestRegistry: + @pytest.fixture + def registry(self): + return load_registry() + + def test_bundled_config_loads(self, registry): + assert "opus" in registry.models + + def test_opus_enabled_by_default(self, registry): + assert registry.models["opus"].enabled + + def test_frontier_models_disabled_by_default(self, registry): + # Fable and Mythos ship disabled — enabled only when the org opts in + assert not registry.models["fable"].enabled + assert not registry.models["mythos"].enabled + + def test_enabled_models_excludes_disabled(self, registry): + ids = {m.id for m in registry.enabled_models()} + assert "claude-opus-4-8" in ids + assert "claude-mythos-5" not in ids + + def test_models_for_role(self, registry): + validators = registry.models_for_role("validate") + assert any(m.key == "opus" for m in validators) + + def test_models_for_roles_dedupes(self, registry): + models = registry.models_for_roles(["validate", "chain"]) + keys = [m.key for m in models] + assert len(keys) == len(set(keys)) + + def test_enabling_frontier_model_adds_it(self, tmp_path): + # Simulate onboarding Mythos by flipping the flag in a copied config + import json + cfg = json.loads((AI_DIR / "models.json").read_text()) + cfg["models"]["mythos"]["enabled"] = True + p = tmp_path / "models.json" + p.write_text(json.dumps(cfg)) + + registry = load_registry(p) + deep = registry.models_for_role("deep-validate") + assert any(m.id == "claude-mythos-5" for m in deep) + + def test_fable_has_fallback_and_omits_thinking(self, registry): + fable = registry.models["fable"] + assert fable.fallback_model == "claude-opus-4-8" + assert fable.thinking == "omit" + + def test_strategy_lookup(self, registry): + strat = registry.strategy("fp_reduction") + assert strat["mode"] == "consensus" diff --git a/tests/unit/test_ai_correlate.py b/tests/unit/test_ai_correlate.py new file mode 100644 index 0000000..e335f1a --- /dev/null +++ b/tests/unit/test_ai_correlate.py @@ -0,0 +1,85 @@ +"""Unit tests for deterministic cross-scan correlation and consensus logic.""" + +import sys +from pathlib import Path + +import pytest + +AI_DIR = Path(__file__).parent.parent.parent / "Scripts" / "AIAnalysis" +sys.path.insert(0, str(AI_DIR)) + +from veracode_ai.findings import normalize_findings # noqa: E402 +from veracode_ai.correlate import correlate # noqa: E402 +from veracode_ai.validate import consensus # noqa: E402 + +FIXTURES = Path(__file__).parent.parent / "fixtures" + + +class TestCorrelation: + @pytest.fixture + def findings(self): + rest = normalize_findings(str(FIXTURES / "veracode_findings.json")) + container = normalize_findings(str(FIXTURES / "container_scan.json"), + source_hint="container") + return rest + container + + @pytest.fixture + def correlations(self, findings): + return correlate(findings) + + def test_cross_layer_cwe_detected(self, correlations): + # CWE-89 appears in both STATIC and DYNAMIC -> cross-layer confirmation + types = [c["type"] for c in correlations] + assert "cross_layer_confirmation" in types + + def test_cross_layer_links_static_and_dynamic(self, correlations): + cl = [c for c in correlations if c["type"] == "cross_layer_confirmation"][0] + assert cl["cwe"] == 89 + assert len(cl["finding_ids"]) >= 3 # 2 static + 1 dynamic + + def test_common_flaw_source_detected(self, correlations): + # Two static findings share the same attack_vector + types = [c["type"] for c in correlations] + assert "common_flaw_source" in types + + def test_shared_cve_across_sca_and_container(self, correlations): + # CVE-2021-44228 in both SCA and CONTAINER + shared = [c for c in correlations if c["type"] == "shared_cve"] + assert any(c["cve"] == "CVE-2021-44228" for c in shared) + + def test_correlations_annotate_findings(self, findings, correlations): + annotated = [f for f in findings if f.correlations] + assert len(annotated) > 0 + + def test_no_false_correlation_on_empty(self): + assert correlate([]) == [] + + +class TestConsensus: + def test_no_votes_is_unvalidated(self): + result = consensus([], quorum_fraction=0.5) + assert result["verdict"] == "unvalidated" + + def test_majority_fp_marks_false_positive(self): + votes = [ + {"is_likely_false_positive": True, "model": "a", "confidence": "high", "rationale": ""}, + {"is_likely_false_positive": True, "model": "b", "confidence": "medium", "rationale": ""}, + ] + assert consensus(votes, 0.5)["verdict"] == "likely_false_positive" + + def test_split_below_quorum_confirms(self): + votes = [ + {"is_likely_false_positive": True, "model": "a", "confidence": "low", "rationale": ""}, + {"is_likely_false_positive": False, "model": "b", "confidence": "high", "rationale": ""}, + {"is_likely_false_positive": False, "model": "c", "confidence": "high", "rationale": ""}, + ] + # 1/3 refute, quorum 0.5 -> confirmed + assert consensus(votes, 0.5)["verdict"] == "confirmed" + + def test_all_confirm(self): + votes = [ + {"is_likely_false_positive": False, "model": "a", "confidence": "high", "rationale": ""}, + ] + result = consensus(votes, 0.5) + assert result["verdict"] == "confirmed" + assert result["fp_votes"] == 0 diff --git a/tests/unit/test_ai_findings.py b/tests/unit/test_ai_findings.py new file mode 100644 index 0000000..f1ce110 --- /dev/null +++ b/tests/unit/test_ai_findings.py @@ -0,0 +1,79 @@ +"""Unit tests for finding normalization across scan types.""" + +import sys +from pathlib import Path + +import pytest + +AI_DIR = Path(__file__).parent.parent.parent / "Scripts" / "AIAnalysis" +sys.path.insert(0, str(AI_DIR)) + +from veracode_ai.findings import UnifiedFinding, normalize_findings # noqa: E402 + +FIXTURES = Path(__file__).parent.parent / "fixtures" + + +class TestUnifiedFinding: + def test_summary_line_includes_cwe(self): + f = UnifiedFinding(id="S-1", source="STATIC", severity=4, + title="SQL Injection", cwe=89, location="A.java:10") + line = f.summary_line() + assert "CWE-89" in line + assert "S-1" in line + + def test_to_dict_excludes_raw_by_default(self): + f = UnifiedFinding(id="S-1", source="STATIC", severity=4, + title="x", raw={"big": "payload"}) + assert "raw" not in f.to_dict() + assert "raw" in f.to_dict(include_raw=True) + + +class TestRestNormalization: + @pytest.fixture + def findings(self): + return normalize_findings(str(FIXTURES / "veracode_findings.json")) + + def test_normalizes_all_findings(self, findings): + assert len(findings) == 4 + + def test_static_finding_shape(self, findings): + static = [f for f in findings if f.source == "STATIC"] + assert len(static) == 2 + f = static[0] + assert f.cwe == 89 + assert ":142" in f.location or ":88" in f.location + assert f.attack_vector + + def test_dynamic_finding_shape(self, findings): + dyn = [f for f in findings if f.source == "DYNAMIC"][0] + assert dyn.cwe == 89 + assert dyn.location.startswith("https://") + + def test_sca_finding_has_cve(self, findings): + sca = [f for f in findings if f.source == "SCA"][0] + assert sca.cve == "CVE-2021-44228" + assert sca.cvss == 10.0 + + def test_ids_are_unique(self, findings): + ids = [f.id for f in findings] + assert len(ids) == len(set(ids)) + + +class TestContainerNormalization: + @pytest.fixture + def findings(self): + return normalize_findings(str(FIXTURES / "container_scan.json"), + source_hint="container") + + def test_normalizes_container(self, findings): + assert len(findings) == 2 + assert all(f.source == "CONTAINER" for f in findings) + + def test_critical_maps_to_severity_5(self, findings): + log4shell = [f for f in findings if f.cve == "CVE-2021-44228"][0] + assert log4shell.severity == 5 + + def test_auto_detects_container_payload(self): + # No hint given — presence of "vulnerabilities" triggers container path + findings = normalize_findings(str(FIXTURES / "container_scan.json")) + assert all(f.source == "CONTAINER" for f in findings) diff --git a/tests/unit/test_ai_hooks.py b/tests/unit/test_ai_hooks.py new file mode 100644 index 0000000..9661049 --- /dev/null +++ b/tests/unit/test_ai_hooks.py @@ -0,0 +1,69 @@ +"""Unit tests for the operational hook registry.""" + +import sys +from pathlib import Path + +import pytest + +AI_DIR = Path(__file__).parent.parent.parent / "Scripts" / "AIAnalysis" +sys.path.insert(0, str(AI_DIR)) + +from veracode_ai.hooks import HookRegistry # noqa: E402 + + +class TestHookRegistry: + def test_register_and_emit(self): + reg = HookRegistry() + seen = [] + + @reg.register("post_normalize") + def _hook(payload): + seen.append(payload) + + reg.emit("post_normalize", ["finding"]) + assert seen == [["finding"]] + + def test_hook_can_transform_payload(self): + reg = HookRegistry() + + @reg.register("pre_validate") + def _drop_info(findings): + return [f for f in findings if f != "info"] + + result = reg.emit("pre_validate", ["bug", "info", "flaw"]) + assert result == ["bug", "flaw"] + + def test_returning_none_preserves_payload(self): + reg = HookRegistry() + + @reg.register("report") + def _observe(payload): + pass # returns None + + original = {"k": "v"} + assert reg.emit("report", original) is original + + def test_multiple_hooks_chain_in_order(self): + reg = HookRegistry() + + @reg.register("report") + def _first(p): + return p + ["a"] + + @reg.register("report") + def _second(p): + return p + ["b"] + + assert reg.emit("report", []) == ["a", "b"] + + def test_unknown_hook_point_rejected(self): + reg = HookRegistry() + with pytest.raises(ValueError): + reg.register("not_a_point") + + def test_count_and_clear(self): + reg = HookRegistry() + reg.register("report")(lambda p: p) + assert reg.count("report") == 1 + reg.clear("report") + assert reg.count("report") == 0