Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
220 changes: 220 additions & 0 deletions .github/scripts/security-summary.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
const fs = require("fs");

@kevinfiol kevinfiol Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is mostly copied from refiner's security-summary.js, but with only functions relevant to the scope of this ticket of work (trivy workflow).


/**
* 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`
}
Comment on lines +96 to +100

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this small change. If parseScanResults throws while trying to parse the trivy JSON, add this snippet to the PR comment so that JSON parsing failures are not silently ignored.


// 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;
}
Comment on lines +201 to +205

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this script is run locally, only log the message, as local act runs won't be making comments to any PRs.


// 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
};
146 changes: 146 additions & 0 deletions .github/workflows/pr-security-scan.yml
Original file line number Diff line number Diff line change
@@ -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 }}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disable uploading the build record when running locally with 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 }}
Comment on lines +82 to +89

@kevinfiol kevinfiol Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trivy uploads also disabled when running the workflow locally with act (for now).

Once nektos/act is updated to support new fields set by actions/upload-artifact (nektos/act#6039), we should be able to remove the && !env.ACT for the Upload JSON step and run the scan-summary job using the --artifact-server-path flag to test this locally .

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);
Comment on lines +136 to +141

@kevinfiol kevinfiol Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parses the SCAN_IMAGES_CSV env variable set above for what images to build PR summaries for.

This was changed from the original refiner script because the refiner image names were hard-coded in security-summary.js.


const isLocalActRun = process.env.ACT === "true";

// generate PR summary and add PR comment
await script.generatePRSummary(github, context, core, images, isLocalActRun);