diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..01081ab --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +# Git and tooling +.git +.github +.justscripts +.vscode + +# Local environment and secrets +.venv +.env +.env.* + +# Python caches and output +**/__pycache__ +**/*.py[cod] +.pytest_cache +.ruff_cache +*.egg-info +build +dist +.coverage +coverage.xml + +# Not used by this image +docs +prototype +packages/cli diff --git a/.github/scripts/security-summary.js b/.github/scripts/security-summary.js new file mode 100644 index 0000000..fe5b14d --- /dev/null +++ b/.github/scripts/security-summary.js @@ -0,0 +1,220 @@ +const fs = require("fs"); + +/** + * Parse Trivy scan results and count vulnerabilities + */ +function parseScanResults(images) { + let totalCritical = 0; + let totalHigh = 0; + let totalMedium = 0; + let totalLow = 0; + const imageResults = []; + + for (const image of images) { + try { + const results = JSON.parse( + fs.readFileSync(`trivy-${image}-results.json`, "utf8"), + ); + + let imageCritical = 0, + imageHigh = 0, + imageMedium = 0, + imageLow = 0; + + if (results.Results) { + for (const result of results.Results) { + if (result.Vulnerabilities) { + for (const vuln of result.Vulnerabilities) { + switch (vuln.Severity) { + case "CRITICAL": + imageCritical++; + break; + case "HIGH": + imageHigh++; + break; + case "MEDIUM": + imageMedium++; + break; + case "LOW": + imageLow++; + break; + } + } + } + } + } + + totalCritical += imageCritical; + totalHigh += imageHigh; + totalMedium += imageMedium; + totalLow += imageLow; + + imageResults.push({ + name: image, + critical: imageCritical, + high: imageHigh, + medium: imageMedium, + low: imageLow, + total: imageCritical + imageHigh + imageMedium + imageLow, + vulnerabilities: + results.Results?.flatMap((r) => r.Vulnerabilities ?? []) ?? [], + }); + } catch (error) { + console.error(`Error parsing ${image}:`, error); + imageResults.push({ + name: image, + error: true, + errorMessage: error.message, + }); + } + } + + return { + totalCritical, + totalHigh, + totalMedium, + totalLow, + totalVulns: totalCritical + totalHigh + totalMedium + totalLow, + imageResults, + }; +} + +/** + * Format results as GitHub markdown comment + */ +function formatGitHubComment(scanResults, repoOwner, repoName) { + const { + totalCritical, + totalHigh, + totalMedium, + totalLow, + totalVulns, + imageResults, + } = scanResults; + + let message = `## 🔒 Security Scan Results\n\n`; + const errors = imageResults.filter((result) => result.error); + + if (errors.length) { + message += `### ⚠ Scan incomplete: failed to parse ${errors.length} Trivy result(s).\n\n` + } + + // Summary at the top + if (totalVulns > 0) { + message += `### ⚠️ Found ${totalVulns} vulnerabilities\n\n`; + message += `| Severity | Total |\n|----------|-------|\n`; + if (totalCritical > 0) message += `| 🔴 Critical | ${totalCritical} |\n`; + if (totalHigh > 0) message += `| 🟠 High | ${totalHigh} |\n`; + if (totalMedium > 0) message += `| 🟡 Medium | ${totalMedium} |\n`; + if (totalLow > 0) message += `| ⚪ Low | ${totalLow} |\n`; + message += `\n`; + } else if (!errors.length) { + message += `### ✅ No vulnerabilities found!\n\n`; + } + + // Per-image breakdown + for (const result of imageResults) { + message += `### 📦 ${result.name}\n\n`; + + if (result.error) { + message += `⚠️ Could not parse results for ${result.name}\n\n`; + } else if (result.total === 0) { + message += `✅ **No vulnerabilities found**\n\n`; + } else { + message += `| Severity | Count |\n|----------|-------|\n`; + if (result.critical > 0) + message += `| 🔴 Critical | ${result.critical} |\n`; + if (result.high > 0) message += `| 🟠 High | ${result.high} |\n`; + if (result.medium > 0) message += `| 🟡 Medium | ${result.medium} |\n`; + if (result.low > 0) message += `| ⚪ Low | ${result.low} |\n`; + message += `\n`; + } + } + + message += `\n---\n`; + message += `**View detailed results**: [Security tab](https://github.com/${repoOwner}/${repoName}/security/code-scanning)\n`; + message += `*Last updated: ${new Date() + .toISOString() + .replace("T", " ") + .replace(/\.\d{3}Z$/, " UTC")}*`; + + return message; +} + +/** + * Post or update PR comment + */ +async function postGitHubComment(github, context, message) { + const prNumber = context.payload.pull_request?.number; + + if (!prNumber) { + console.log("Not a PR, skipping GitHub comment"); + return; + } + + const comments = await github.rest.issues.listComments({ + issue_number: prNumber, + owner: context.repo.owner, + repo: context.repo.repo, + }); + + const botComment = comments.data.find( + (comment) => + comment.user.login === "github-actions[bot]" && + comment.body.includes("Security Scan Results"), + ); + + if (botComment) { + await github.rest.issues.updateComment({ + comment_id: botComment.id, + owner: context.repo.owner, + repo: context.repo.repo, + body: message, + }); + console.log("Updated existing security scan comment"); + } else { + await github.rest.issues.createComment({ + issue_number: prNumber, + owner: context.repo.owner, + repo: context.repo.repo, + body: message, + }); + console.log("Created new security scan comment"); + } +} + +/** + * For PR scans - posts comment to PR + */ +async function generatePRSummary(github, context, core, images = [], isLocalActRun = false) { + if (!images.length) return; // no images to generate results for + + // Parse all scan results + const scanResults = parseScanResults(images); + + const message = formatGitHubComment( + scanResults, + context.repo.owner, + context.repo.repo, + ); + + if (isLocalActRun) { + // log the message + core.info(message); + return; + } + + // Warn if critical or high vulnerabilities found + if (scanResults.totalCritical > 0 || scanResults.totalHigh > 0) { + core.warning( + `Found ${scanResults.totalCritical} critical and ${scanResults.totalHigh} high severity vulnerabilities`, + ); + } + + // Post GitHub comment + await postGitHubComment(github, context, message); +} + +module.exports = { + generatePRSummary +}; diff --git a/.github/workflows/pr-security-scan.yml b/.github/workflows/pr-security-scan.yml new file mode 100644 index 0000000..ed24b4a --- /dev/null +++ b/.github/workflows/pr-security-scan.yml @@ -0,0 +1,146 @@ +name: PR Security Scan + +on: + workflow_dispatch: + # TODO: uncomment this trigger block when we want this workflow to run on PRs + # pull_request: + # branches: + # - "**" + # merge_group: + # types: + # - checks_requested + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + TRIVY_SEVERITY: "CRITICAL,HIGH,MEDIUM,LOW" + +jobs: + build-and-scan: + runs-on: ubuntu-latest + + permissions: + contents: read + security-events: write + + strategy: + matrix: + image: + - name: "did-lambda" + dockerfile: "docker/lambda.Dockerfile" + fail-fast: false + + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Build image locally (no push) + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + env: + DOCKER_BUILD_RECORD_UPLOAD: ${{ !env.ACT }} + with: + context: . + file: ${{ matrix.image.dockerfile }} + load: true # Load locally for scanning only + tags: local-scan/${{ matrix.image.name }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Run Trivy vulnerability scanner (SARIF) + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + image-ref: local-scan/${{ matrix.image.name }}:${{ github.sha }} + format: "sarif" + output: "trivy-${{ matrix.image.name }}-results.sarif" + severity: ${{ env.TRIVY_SEVERITY }} + ignore-unfixed: false + timeout: "10m" + + - name: Run Trivy vulnerability scanner (JSON for PR comment) + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + image-ref: local-scan/${{ matrix.image.name }}:${{ github.sha }} + format: "json" + output: "trivy-${{ matrix.image.name }}-results.json" + severity: ${{ env.TRIVY_SEVERITY }} + ignore-unfixed: false + timeout: "10m" + + - name: Display Trivy results as table + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + image-ref: local-scan/${{ matrix.image.name }}:${{ github.sha }} + format: "table" + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + if: ${{ always() && !env.ACT }} + with: + sarif_file: "trivy-${{ matrix.image.name }}-results.sarif" + category: "pr-scan-${{ matrix.image.name }}" + + - name: Upload JSON results as artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: ${{ always() && !env.ACT }} + with: + name: trivy-results-${{ matrix.image.name }} + path: trivy-${{ matrix.image.name }}-results.json + retention-days: 5 + + - name: Generate scan summary + if: always() + run: | + echo "## Security Scan Results for ${{ matrix.image.name }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ -f "trivy-${{ matrix.image.name }}-results.sarif" ]; then + echo "Vulnerability scan completed successfully" >> $GITHUB_STEP_SUMMARY + echo "Results uploaded to Security tab" >> $GITHUB_STEP_SUMMARY + echo "Category: pr-scan-${{ matrix.image.name }}" >> $GITHUB_STEP_SUMMARY + else + echo "Scan failed or no results generated" >> $GITHUB_STEP_SUMMARY + fi + + scan-summary: + runs-on: ubuntu-latest + needs: build-and-scan + if: ${{ always() && github.event_name == 'pull_request' }} + + env: + SCAN_IMAGES_CSV: "did-lambda" + + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Download scan results + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: trivy-results-* + merge-multiple: true + + - name: PR Security Summary with Details + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const script = require('./.github/scripts/security-summary.js'); + + // parse `SCAN_IMAGES_CSV` to array + // ex: 'did-lambda,did-api' -> ['did-lambda', 'did-api'] + const images = (process.env.SCAN_IMAGES_CSV ?? '') + .split(',') + .map((x) => x.trim()) + .filter(Boolean); + + const isLocalActRun = process.env.ACT === "true"; + + // generate PR summary and add PR comment + await script.generatePRSummary(github, context, core, images, isLocalActRun);