Skip to content
Open
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
169 changes: 169 additions & 0 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
name: Test Coverage

on:
pull_request:
branches: [ master ]

permissions:
pull-requests: write
contents: read

jobs:
coverage:
runs-on: ubuntu-latest
steps:
- name: Checkout PR branch
uses: actions/checkout@v3
with:
fetch-depth: 0

- name: Set up JDK 11
uses: actions/setup-java@v3
with:
java-version: '11'
distribution: 'temurin'

- name: Cache Gradle packages
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-gradle-

- name: Run tests and generate coverage report (PR branch)
run: ./gradlew test jacocoTestReport

- name: Save PR branch coverage report
run: cp build/reports/jacoco/test/jacocoTestReport.xml pr-coverage.xml

- name: Extract PR branch coverage percentage
id: pr_coverage
run: |
# Extract the report-level LINE counter (last occurrence in the XML)
line=$(grep -oP 'counter type="LINE" missed="\d+" covered="\d+"' pr-coverage.xml | tail -1)
missed=$(echo "$line" | grep -oP 'missed="\K\d+')
covered=$(echo "$line" | grep -oP 'covered="\K\d+')
total=$((missed + covered))
if [ "$total" -gt 0 ]; then
pct=$(awk "BEGIN {printf \"%.2f\", ($covered/$total)*100}")
else
pct="0.00"
fi
echo "coverage=$pct" >> "$GITHUB_OUTPUT"
echo "PR branch line coverage: $pct% (missed=$missed, covered=$covered)"

- name: Checkout base branch
run: git checkout ${{ github.base_ref }}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

🟡 Shell injection via unquoted github.base_ref in run: block

On line 58, ${{ github.base_ref }} is interpolated directly into a shell run: command without quotes: git checkout ${{ github.base_ref }}. While the workflow restricts to PRs targeting master (mitigating this somewhat), directly interpolating GitHub context values into shell commands is a security anti-pattern. If the workflow trigger was ever broadened to allow other base branches, a branch name containing shell metacharacters could execute arbitrary commands. The safe pattern is to use an environment variable: env: BASE_REF: ${{ github.base_ref }} and then git checkout "$BASE_REF".

Prompt for agents
In .github/workflows/coverage.yml line 57-58, change the step to use an environment variable instead of direct interpolation:

  - name: Checkout base branch
    run: git checkout "$BASE_REF"
    env:
      BASE_REF: ${{ github.base_ref }}

This prevents shell injection if the workflow trigger is ever broadened to allow base branches with special characters.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


- name: Run tests and generate coverage report (base branch)
run: |
# Base branch may not have JaCoCo configured yet; skip gracefully
./gradlew clean test jacocoTestReport || true

- name: Extract base branch coverage percentage
id: base_coverage
run: |
if [ -f build/reports/jacoco/test/jacocoTestReport.xml ]; then
line=$(grep -oP 'counter type="LINE" missed="\d+" covered="\d+"' build/reports/jacoco/test/jacocoTestReport.xml | tail -1)
missed=$(echo "$line" | grep -oP 'missed="\K\d+')
covered=$(echo "$line" | grep -oP 'covered="\K\d+')
total=$((missed + covered))
if [ "$total" -gt 0 ]; then
pct=$(awk "BEGIN {printf \"%.2f\", ($covered/$total)*100}")
else
pct="0.00"
fi
echo "coverage=$pct" >> "$GITHUB_OUTPUT"
echo "has_base=true" >> "$GITHUB_OUTPUT"
echo "Base branch line coverage: $pct% (missed=$missed, covered=$covered)"
else
echo "coverage=N/A" >> "$GITHUB_OUTPUT"
echo "has_base=false" >> "$GITHUB_OUTPUT"
echo "No JaCoCo report found on base branch"
fi
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

- name: Checkout PR branch again
run: git checkout ${{ github.event.pull_request.head.sha }}

- name: Restore PR coverage report
run: |
mkdir -p build/reports/jacoco/test
cp pr-coverage.xml build/reports/jacoco/test/jacocoTestReport.xml

- name: Post coverage comparison comment
uses: actions/github-script@v6
with:
script: |
const prCoverage = '${{ steps.pr_coverage.outputs.coverage }}';
const baseCoverage = '${{ steps.base_coverage.outputs.coverage }}';
const hasBase = '${{ steps.base_coverage.outputs.has_base }}' === 'true';

let diff = '';
let emoji = '';
if (hasBase && baseCoverage !== 'N/A') {
const delta = (parseFloat(prCoverage) - parseFloat(baseCoverage)).toFixed(2);
if (delta > 0) {
emoji = '🟢';
diff = `+${delta}%`;
} else if (delta < 0) {
emoji = '🔴';
diff = `${delta}%`;
} else {
emoji = '⚪';
diff = `±0.00%`;
}
}

let body = `## Test Coverage Report\n\n`;
body += `| Metric | Coverage |\n`;
body += `|--------|----------|\n`;
if (hasBase) {
body += `| **Base branch** (\`${{ github.base_ref }}\`) | ${baseCoverage}% |\n`;
} else {
body += `| **Base branch** (\`${{ github.base_ref }}\`) | _No JaCoCo configured_ |\n`;
}
body += `| **PR branch** (\`${{ github.head_ref }}\`) | ${prCoverage}% |\n`;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

🔴 Script injection via github.head_ref in JavaScript template literal

On line 127, ${{ github.head_ref }} is interpolated directly into a JavaScript template literal (backtick string). Since github.head_ref is attacker-controlled (the PR author chooses their branch name), and Git branch names can contain $, {, and } characters, an attacker can craft a branch name like exploit${require('child_process').execSync('malicious command')} which would be evaluated as JavaScript code inside the template literal. The workflow has pull-requests: write permissions, so the attacker could use this to post arbitrary comments, exfiltrate the GITHUB_TOKEN, or read repository contents. The fix is to pass github.head_ref as an environment variable and reference it via process.env inside the script instead of using ${{ }} interpolation.

Vulnerable interpolation pattern
# Line 127 - github.head_ref injected into JS template literal
body += `| **PR branch** (\`${{ github.head_ref }}\`) | ${prCoverage}% |\n`;

Should instead use:

env:
  HEAD_REF: ${{ github.head_ref }}
  BASE_REF: ${{ github.base_ref }}
script: |
  const headRef = process.env.HEAD_REF;
  ...
  body += `| **PR branch** (\`${headRef}\`) | ${prCoverage}% |\n`;
Prompt for agents
In .github/workflows/coverage.yml, the actions/github-script step at line 96-157 directly interpolates github.head_ref and github.base_ref using ${{ }} syntax inside JavaScript template literals, which allows script injection. Fix by:

1. Add an `env` block to the step (after `with:` at line 97):
   env:
     HEAD_REF: ${{ github.head_ref }}
     BASE_REF: ${{ github.base_ref }}

2. Inside the script block, replace all uses of ${{ github.head_ref }} with process.env.HEAD_REF and all uses of ${{ github.base_ref }} with process.env.BASE_REF.

Specifically, change lines 123, 125, and 127 from:
  body += `| **Base branch** (\`${{ github.base_ref }}\`) | ${baseCoverage}% |\n`;
to:
  body += `| **Base branch** (\`${process.env.BASE_REF}\`) | ${baseCoverage}% |\n`;

And line 127 from:
  body += `| **PR branch** (\`${{ github.head_ref }}\`) | ${prCoverage}% |\n`;
to:
  body += `| **PR branch** (\`${process.env.HEAD_REF}\`) | ${prCoverage}% |\n`;
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

if (hasBase && baseCoverage !== 'N/A') {
body += `| **Diff** | ${emoji} ${diff} |\n`;
}

// Find and update existing comment or create new one
const marker = '<!-- coverage-report -->';
body = marker + '\n' + body;

const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});

const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}

- name: Post per-file coverage details
uses: madrapps/jacoco-report@v1.6.1
with:
paths: build/reports/jacoco/test/jacocoTestReport.xml
token: ${{ secrets.GITHUB_TOKEN }}
min-coverage-overall: 34
min-coverage-changed-files: 60
title: "Per-File Coverage Details"
update-comment: true
pass-emoji: ':green_circle:'
fail-emoji: ':red_circle:'
34 changes: 33 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ plugins {
id 'java'
id "com.netflix.dgs.codegen" version "5.0.6"
id "com.diffplug.spotless" version "6.2.1"
id 'jacoco'
}

version = '0.0.1-SNAPSHOT'
Expand Down Expand Up @@ -56,8 +57,39 @@ dependencies {
testImplementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter-test:2.2.2'
}

jacoco {
toolVersion = "0.8.8"
}

tasks.named('jacocoTestReport') {
dependsOn tasks.named('test')
reports {
xml.required = true
csv.required = false
html.required = true
}
}

tasks.named('jacocoTestCoverageVerification') {
violationRules {
rule {
limit {
counter = 'LINE'
value = 'COVEREDRATIO'
// TODO: raise this threshold over time as test coverage improves
minimum = 0.34
}
}
}
}

tasks.named('test') {
useJUnitPlatform()
useJUnitPlatform()
finalizedBy tasks.named('jacocoTestReport')
}

tasks.named('check') {
dependsOn tasks.named('jacocoTestCoverageVerification')
}

tasks.named('clean') {
Expand Down
Loading