From 6c14a24e3697bbb85268f6c5ea1b5b40a5de06a5 Mon Sep 17 00:00:00 2001 From: kevinfiol Date: Mon, 13 Jul 2026 13:45:14 -0400 Subject: [PATCH 1/6] add security scan workflow --- .dockerignore | 30 ++++ .github/scripts/security-summary.js | 214 +++++++++++++++++++++++++ .github/workflows/pr-security-scan.yml | 142 ++++++++++++++++ 3 files changed, 386 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/scripts/security-summary.js create mode 100644 .github/workflows/pr-security-scan.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b130d74 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ +# Git and tooling +.git +.github +.agents +.codex +.gram +.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 +tmp +docs +prototype +packages/cli diff --git a/.github/scripts/security-summary.js b/.github/scripts/security-summary.js new file mode 100644 index 0000000..820a861 --- /dev/null +++ b/.github/scripts/security-summary.js @@ -0,0 +1,214 @@ +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(scanResults, github, context) { + const prNumber = context.payload.pull_request?.number; + + if (!prNumber) { + console.log("Not a PR, skipping GitHub comment"); + return; + } + + const message = formatGitHubComment( + scanResults, + context.repo.owner, + context.repo.repo, + ); + + 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 = []) { + if (!images.length) return; // no images to generate results for + + // Parse all scan results + const scanResults = parseScanResults(images); + + // 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(scanResults, github, context); +} + +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..9da004c --- /dev/null +++ b/.github/workflows/pr-security-scan.yml @@ -0,0 +1,142 @@ +name: PR Security Scan + +on: + pull_request: + branches: + - "**" # run on prs to all branches + merge_group: + types: + - checks_requested + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + TRIVY_SEVERITY: "CRITICAL,HIGH,MEDIUM,LOW" + +# https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idstepsuses +# https://docs.github.com/en/actions/reference/security/secure-use#using-third-party-actions +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 + 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() + 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() + 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() + + 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); + + // generate PR summary and add PR comment + await script.generatePRSummary(github, context, core, images); From 1ae19cc8ecc4873bb1340c1b6b004259e6ee57a8 Mon Sep 17 00:00:00 2001 From: kevinfiol Date: Mon, 13 Jul 2026 15:00:48 -0400 Subject: [PATCH 2/6] disable certain steps when running locally with act --- .github/scripts/security-summary.js | 24 +++++++++++++++--------- .github/workflows/pr-security-scan.yml | 10 +++++++--- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/.github/scripts/security-summary.js b/.github/scripts/security-summary.js index 820a861..fe5b14d 100644 --- a/.github/scripts/security-summary.js +++ b/.github/scripts/security-summary.js @@ -144,7 +144,7 @@ function formatGitHubComment(scanResults, repoOwner, repoName) { /** * Post or update PR comment */ -async function postGitHubComment(scanResults, github, context) { +async function postGitHubComment(github, context, message) { const prNumber = context.payload.pull_request?.number; if (!prNumber) { @@ -152,12 +152,6 @@ async function postGitHubComment(scanResults, github, context) { return; } - const message = formatGitHubComment( - scanResults, - context.repo.owner, - context.repo.repo, - ); - const comments = await github.rest.issues.listComments({ issue_number: prNumber, owner: context.repo.owner, @@ -192,12 +186,24 @@ async function postGitHubComment(scanResults, github, context) { /** * For PR scans - posts comment to PR */ -async function generatePRSummary(github, context, core, images = []) { +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( @@ -206,7 +212,7 @@ async function generatePRSummary(github, context, core, images = []) { } // Post GitHub comment - await postGitHubComment(scanResults, github, context); + await postGitHubComment(github, context, message); } module.exports = { diff --git a/.github/workflows/pr-security-scan.yml b/.github/workflows/pr-security-scan.yml index 9da004c..aa6fef5 100644 --- a/.github/workflows/pr-security-scan.yml +++ b/.github/workflows/pr-security-scan.yml @@ -41,6 +41,8 @@ jobs: - 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 }} @@ -77,14 +79,14 @@ jobs: - name: Upload Trivy scan results to GitHub Security tab uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 - if: always() + 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() + if: ${{ always() && !env.ACT }} with: name: trivy-results-${{ matrix.image.name }} path: trivy-${{ matrix.image.name }}-results.json @@ -138,5 +140,7 @@ jobs: .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); + await script.generatePRSummary(github, context, core, images, isLocalActRun); From 1d698e18a277fe35005f2b0bf41c338c09ccc98a Mon Sep 17 00:00:00 2001 From: kevinfiol Date: Mon, 13 Jul 2026 15:19:21 -0400 Subject: [PATCH 3/6] remove these comments --- .github/workflows/pr-security-scan.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/pr-security-scan.yml b/.github/workflows/pr-security-scan.yml index aa6fef5..39efbb6 100644 --- a/.github/workflows/pr-security-scan.yml +++ b/.github/workflows/pr-security-scan.yml @@ -15,8 +15,6 @@ concurrency: env: TRIVY_SEVERITY: "CRITICAL,HIGH,MEDIUM,LOW" -# https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idstepsuses -# https://docs.github.com/en/actions/reference/security/secure-use#using-third-party-actions jobs: build-and-scan: runs-on: ubuntu-latest From 206600be4ae2e4e5ac1620114e9ffd064fd61431 Mon Sep 17 00:00:00 2001 From: kevinfiol Date: Mon, 13 Jul 2026 15:26:32 -0400 Subject: [PATCH 4/6] update docker ignore file --- .dockerignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.dockerignore b/.dockerignore index b130d74..edc81ac 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,6 @@ # Git and tooling .git .github -.agents -.codex .gram .justscripts .vscode From 204171354e4f643bedc7e6e6b253b0baad3878fc Mon Sep 17 00:00:00 2001 From: kevinfiol Date: Mon, 13 Jul 2026 15:26:58 -0400 Subject: [PATCH 5/6] update docker ignore --- .dockerignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.dockerignore b/.dockerignore index edc81ac..01081ab 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,6 @@ # Git and tooling .git .github -.gram .justscripts .vscode @@ -22,7 +21,6 @@ dist coverage.xml # Not used by this image -tmp docs prototype packages/cli From 0fc9e1b1764b6be47919398926512b5b388005df Mon Sep 17 00:00:00 2001 From: kevinfiol Date: Mon, 13 Jul 2026 15:31:44 -0400 Subject: [PATCH 6/6] run workflow manually --- .github/workflows/pr-security-scan.yml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pr-security-scan.yml b/.github/workflows/pr-security-scan.yml index 39efbb6..ed24b4a 100644 --- a/.github/workflows/pr-security-scan.yml +++ b/.github/workflows/pr-security-scan.yml @@ -1,12 +1,14 @@ name: PR Security Scan on: - pull_request: - branches: - - "**" # run on prs to all branches - merge_group: - types: - - checks_requested + 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 }} @@ -106,7 +108,7 @@ jobs: scan-summary: runs-on: ubuntu-latest needs: build-and-scan - if: always() + if: ${{ always() && github.event_name == 'pull_request' }} env: SCAN_IMAGES_CSV: "did-lambda"