Skip to content
143 changes: 143 additions & 0 deletions .github/actions/trivy-fs-scan/action.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
name: Trivy fs Scan
description: Performs Trivy security scanning for filesystems with comprehensive reporting

inputs:
filesystem-ref:
description: 'Filesystem reference to scan (e.g., /path/to/filesystem)'
required: true
severity:
description: 'Comma-separated list of severity levels to report'
required: false
default: 'HIGH,CRITICAL,MEDIUM,LOW,UNKNOWN'
trivy-config:
description: 'Path to Trivy configuration file'
required: false
default: 'trivy.yaml'
artifact-name:
description: 'Name for the uploaded artifact'
required: false
default: 'trivy-fs-scan-results'
fail-on-critical-high:
description: 'Whether to fail the action on critical/high findings'
required: false
default: 'true'
ignore-unfixed:
description: 'Ignore unfixed vulnerabilities'
required: false
default: 'true'

outputs:
critical-count:
description: 'Number of critical severity findings'
value: ${{ steps.report.outputs.crit }}
high-count:
description: 'Number of high severity findings'
value: ${{ steps.report.outputs.high }}
report-path:
description: 'Path to the generated markdown report'
value: 'trivy_fs_report.md'

runs:
using: "composite"
steps:
- name: Check if Trivy config exists
id: trivy-config-check
shell: bash
run: |
if [[ -f "${{ inputs.trivy-config }}" ]]; then
echo "config-exists=true" >> "$GITHUB_OUTPUT"
echo "config-arg=${{ inputs.trivy-config }}" >> "$GITHUB_OUTPUT"
else
echo "config-exists=false" >> "$GITHUB_OUTPUT"
echo "config-arg=" >> "$GITHUB_OUTPUT"
fi

- name: Trivy fs scan
uses: aquasecurity/trivy-action@0.28.0
with:
scan-type: 'fs'
scan-ref: ${{ inputs.filesystem-ref }}
format: json
output: trivy-fs-scan.json
exit-code: 0
ignore-unfixed: ${{ inputs.ignore-unfixed }}
severity: ${{ inputs.severity }}
trivy-config: ${{ steps.trivy-config-check.outputs.config-arg }}

- name: Upload Trivy Scan Artifact
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.artifact-name }}
path: trivy-fs-scan.json

- name: Build summary & counts
id: report
shell: bash
run: |
# Filesystem scan report
jq -r '
def clean: (.|tostring) | gsub("\\|";"\\|") | gsub("\r?\n";" ");
def sev: ["CRITICAL","HIGH","MEDIUM","LOW","UNKNOWN"];
def counts:
([.Results[]? | .Vulnerabilities[]? | .Severity // "UNKNOWN"]
| reduce .[] as $s ({CRITICAL:0,HIGH:0,MEDIUM:0,LOW:0,UNKNOWN:0}; .[$s]+=1));
. as $root
| (counts) as $c
|
# ---- TOP BANNER (only if High/Critical present) ----
(
if (($c.CRITICAL + $c.HIGH) > 0) then
"🚫 **Trivy gate:** **\($c.CRITICAL) Critical**, **\($c.HIGH) High** vulnerability(s) found.\n\n"
else
"✅ **Trivy gate:** no Critical/High vulnerabilities.\n\n"
end
)
# ---- SUMMARY REPORT ----
+ "### Trivy Filesystem Scan Summary\n\n"
+ "**Filesystem:** " + ($root.ArtifactName // "'"'"'${{ inputs.filesystem-ref }}'"'"'") + "\n\n"
+ "| Severity | Count |\n|---|---|\n"
+ (sev | map("| " + . + " | " + ($c[.]|tostring) + " |") | join("\n"))
+ (if ([.Results[]? | .Vulnerabilities[]?] | length) == 0
then "\n\n✅ No vulnerabilities found.\n"
else
"\n\n<details><summary>Findings (top 50)</summary>\n\n"
+ "| Severity | ID | Package | Installed | Fixed | Source |\n|---|---|---|---|---|---|\n"
+ (
[ .Results[]? as $r
| $r.Vulnerabilities[]?
| "| \(.Severity) | \(.VulnerabilityID) | \(.PkgName) | \(.InstalledVersion) | \((.FixedVersion // "") | clean) | \(($r.Target) | clean) |"
] | .[:50] | join("\n")
)
+ "\n\n</details>\n"
end)
' trivy-fs-scan.json > trivy_fs_report.md

# Extract counts for gating/other steps
read CRIT HIGH < <(jq -r '
[.Results[]? | .Vulnerabilities[]? | .Severity // "UNKNOWN"]
| reduce .[] as $s ({CRITICAL:0,HIGH:0,MEDIUM:0,LOW:0,UNKNOWN:0}; .[$s]+=1)
| "\(.CRITICAL) \(.HIGH)"
' trivy-fs-scan.json)

echo "crit=$CRIT" >> "$GITHUB_OUTPUT"
echo "high=$HIGH" >> "$GITHUB_OUTPUT"

- name: Publish Trivy Summary
if: always()
shell: bash
run: cat trivy_fs_report.md >> "$GITHUB_STEP_SUMMARY"

- name: Update Trivy PR comment
if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork }}
uses: marocchino/sticky-pull-request-comment@v2
with:
header: ${{ inputs.artifact-name }}
path: trivy_fs_report.md

- name: Check Trivy Issue Thresholds
if: ${{ inputs.fail-on-critical-high == 'true' && (steps.report.outputs.crit != '0' || steps.report.outputs.high != '0') }}
shell: bash
run: |
echo "Critical vulnerabilities detected: ${{ steps.report.outputs.crit }}"
echo "High vulnerabilities detected: ${{ steps.report.outputs.high }}"
exit 1
129 changes: 124 additions & 5 deletions .github/workflows/preview-env.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -122,24 +122,143 @@ jobs:
if: github.event.action == 'closed'
run: |
FN="${{ steps.names.outputs.function_name }}"
echo "Deleting preview function: $FN"
echo "Deleting preview function: $FN"
aws lambda delete-function --function-name "$FN" || true

- name: Output function name
run: |
echo "function = ${{ steps.names.outputs.function_name }}"
echo "url = ${{ steps.names.outputs.preview_url }}"

# ---------- Wait on AWS tasks and notify ----------
- name: Get mTLS certs for testing
if: github.event.action != 'closed'
id: mtls-certs
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
with:
secret-ids: |
/cds/pathology/dev/mtls/client1-key-secret
/cds/pathology/dev/mtls/client1-key-public
name-transformation: lowercase

- name: Smoke test preview URL
if: github.event.action != 'closed'
id: smoke-test
env:
PREVIEW_URL: ${{ steps.names.outputs.preview_url }}
run: |
if [ -z "$PREVIEW_URL" ] || [ "$PREVIEW_URL" = "null" ]; then
echo "Preview URL missing"
echo "http_status=missing" >> "$GITHUB_OUTPUT"
echo "http_result=missing-url" >> "$GITHUB_OUTPUT"
exit 0
fi

# Reachability check: allow 404 (app routes might not exist yet) but fail otherwise
printf '%s' "$_cds_pathology_dev_mtls_client1_key_secret" > /tmp/client1-key.pem
printf '%s' "$_cds_pathology_dev_mtls_client1_key_public" > /tmp/client1-cert.pem
STATUS=$(curl \
--cert /tmp/client1-cert.pem \
--key /tmp/client1-key.pem \
--silent \
--output /tmp/preview.headers \
--write-out '%{http_code}' \
--head \
--max-time 30 "$PREVIEW_URL" || true)
rm -f /tmp/client1-key.pem
rm -f /tmp/client1-cert.pem

if [ "$STATUS" = "404" ]; then
echo "Preview responded with expected 404"
echo "http_status=404" >> "$GITHUB_OUTPUT"
echo "http_result=allowed-404" >> "$GITHUB_OUTPUT"
exit 0
fi

if [[ "$STATUS" =~ ^[0-9]{3}$ ]] && [ "$STATUS" -ge 200 ] && [ "$STATUS" -lt 400 ]; then
echo "Preview responded with status $STATUS"
echo "http_status=$STATUS" >> "$GITHUB_OUTPUT"
echo "http_result=success" >> "$GITHUB_OUTPUT"
exit 0
fi

echo "Preview responded with unexpected status $STATUS"
if [ -f /tmp/preview.headers ]; then
echo "Response headers:"
cat /tmp/preview.headers
fi
echo "http_status=$STATUS" >> "$GITHUB_OUTPUT"
echo "http_result=unexpected-status" >> "$GITHUB_OUTPUT"
exit 0

- name: Comment function name on PR
if: github.event_name == 'pull_request' && github.event.action != 'closed'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
with:
script: |
const fn = '${{ steps.names.outputs.function_name }}';
const url = '${{ steps.names.outputs.preview_url }}';
const owner = context.repo.owner;
const repo = context.repo.repo;
const issueNumber = context.issue.number;
const smokeStatus = '${{ steps.smoke-test.outputs.http_status }}' || 'n/a';
const smokeResult = '${{ steps.smoke-test.outputs.http_result }}' || 'not-run';

const smokeLabels = {
success: ':white_check_mark: Passed',
'allowed-404': ':white_check_mark: Allowed 404',
'unexpected-status': ':x: Unexpected status',
'missing-url': ':x: Missing URL',
};

const smokeReadable = smokeLabels[smokeResult] ?? smokeResult;

const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});

for (const comment of comments) {
const isBot = comment.user?.login === 'github-actions[bot]';
const isPreviewUpdate = comment.body?.includes('Deployment Complete');

if (isBot && isPreviewUpdate) {
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: comment.id,
});
}
}

const lines = [
'**Deployment Complete**',
`- Preview URL: [${url}](${url})`,
`- Smoke Test: ${smokeReadable} (HTTP ${smokeStatus})`,
`- Lambda Function: ${fn}`,
];

await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `Preview Lambda: \`${fn}\`\nPreview URL: ${url}`
owner,
repo,
issue_number: issueNumber,
body: lines.join('\n'),
});

# ---------- Perform trivy scan and notify ----------
- name: Prepare lambda artifact for trivy scan
if: github.event.action != 'closed'
run: |
cd infrastructure/environments/preview
rm -rf /tmp/artifact
mkdir -p /tmp/artifact
unzip -q artifact.zip -d /tmp/artifact

- name: Trivy filesystem scan
if: github.event.action != 'closed'
uses: ./.github/actions/trivy-fs-scan
with:
filesystem-ref: /tmp/artifact
artifact-name: trivy-fs-scan-${{ steps.branch.outputs.safe }}
Loading