From 6d2826ca10b3e5b74e4404ccf119425e825108fe Mon Sep 17 00:00:00 2001 From: Samantha Jayasinghe Date: Thu, 6 Aug 2026 11:07:49 +1200 Subject: [PATCH 1/2] feat: add FullSend JIRA integration polling workflow Adds a scheduled GitHub Actions workflow that polls JIRA for issues matching the ocm-agent-operator component in ROSAENG, excluding security-sensitive issues (Vulnerability, Weakness types and security/cve/embargo labels). Closes #323 Co-Authored-By: Claude Opus 4.6 --- .github/workflows/fullsend-poll-jira.yaml | 95 +++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 .github/workflows/fullsend-poll-jira.yaml diff --git a/.github/workflows/fullsend-poll-jira.yaml b/.github/workflows/fullsend-poll-jira.yaml new file mode 100644 index 0000000..c3c47b0 --- /dev/null +++ b/.github/workflows/fullsend-poll-jira.yaml @@ -0,0 +1,95 @@ +name: fullsend Jira poll + +on: + schedule: + - cron: "*/5 * * * *" + workflow_dispatch: {} + +permissions: + actions: write + contents: read + +jobs: + poll: + runs-on: ubuntu-24.04 + concurrency: + group: fullsend-jira-poll + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + + - name: Install fullsend + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release download --repo fullsend-ai/fullsend -p 'fullsend_*_linux_amd64.tar.gz' -O - | tar xz + sudo mv fullsend /usr/local/bin/ + + - name: Poll Jira + env: + JIRA_TOKEN: ${{ secrets.JIRA_TOKEN }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + JIRA_BASE_URL: ${{ vars.JIRA_BASE_URL }} + run: | + fullsend poll \ + --input-driver jira-poll \ + --jira-url "${JIRA_BASE_URL}" \ + --jira-project ROSAENG \ + --jql 'project = ROSAENG AND component = ocm-agent-operator AND issuetype in (Bug, Story, Feature) AND issuetype not in (Vulnerability, Weakness) AND labels not in (security, cve, embargo) AND status not in (Closed, Done, "Won'\''t Do")' \ + --target-repo "${{ github.repository }}" \ + --output dispatches.json \ + --fullsend-dir .fullsend + + - name: Dispatch agent workflows + env: + GH_TOKEN: ${{ github.token }} + JIRA_BASE_URL: ${{ vars.JIRA_BASE_URL }} + run: | + set -euo pipefail + + if ! jq -e 'length > 0' dispatches.json > /dev/null 2>&1; then + echo "No dispatches to process." + exit 0 + fi + + dispatched=0 + count=$(jq 'length' dispatches.json) + + for i in $(seq 0 $((count - 1))); do + record=$(jq -c ".[$i]" dispatches.json) + STAGE=$(echo "$record" | jq -r '.stage') + RESOURCE_KEY=$(echo "$record" | jq -r '.resource_key') + EVENT_TYPE=$(echo "$record" | jq -r '.event_type') + ISSUE_ID=$(echo "$record" | jq -r '.iid // 0') + + ISSUE_KEY="${RESOURCE_KEY#issue-}" + ISSUE_URL="${JIRA_BASE_URL}/browse/${ISSUE_KEY}" + + EVENT_PAYLOAD=$(jq -nc \ + --argjson number "$ISSUE_ID" \ + --arg url "$ISSUE_URL" \ + '{issue: {number: $number, html_url: $url}}') + + WORKFLOW_NAME="" + for wf in .github/workflows/*.yml .github/workflows/*.yaml; do + [[ -f "$wf" ]] || continue + if grep -qxF "# fullsend-stage: ${STAGE}" "$wf"; then + WORKFLOW_NAME=$(basename "$wf") + break + fi + done + if [[ -z "$WORKFLOW_NAME" ]]; then + echo "::warning::No workflow found for stage ${STAGE}, skipping ${RESOURCE_KEY}" + continue + fi + + echo "Dispatching ${WORKFLOW_NAME} for ${ISSUE_KEY} (${STAGE})" + gh workflow run "$WORKFLOW_NAME" \ + -f event_type="$EVENT_TYPE" \ + -f source_repo="${{ github.repository }}" \ + -f event_payload="$EVENT_PAYLOAD" + + dispatched=$((dispatched + 1)) + done + + echo "::notice::Dispatched ${dispatched} agent workflow(s)" From 8689dbdf010b234bd897ed414ff4c51c171c023f Mon Sep 17 00:00:00 2001 From: Samantha Jayasinghe Date: Mon, 24 Aug 2026 18:20:25 +1200 Subject: [PATCH 2/2] fix: harden fullsend Jira poll workflow per PR review - Pin actions/checkout to a full commit SHA (v4.2.2) and set persist-credentials: false (supply-chain / artipacked) - Pin the fullsend binary to v0.36.0 and checksum-verify the download before it is granted the Jira credentials (supply-chain) - Fix the JQL label filter so issues with no labels are included - Validate every Jira-derived value (stage, event_type, issue key) against strict allowlists before it reaches a gh CLI arg, URL, or workflow command, blocking GHA command injection - Drop the grep -x flag so stage markers match at any indentation - Coerce iid with tonumber, strip a trailing slash from JIRA_BASE_URL, validate JIRA_BASE_URL is set, and don't let one dispatch failure abort the remaining records - Add a header comment block, YAML doc marker, and lowercase locals for consistency with fullsend.yaml Co-Authored-By: Claude Opus 4.8 --- .github/workflows/fullsend-poll-jira.yaml | 114 ++++++++++++++++------ 1 file changed, 86 insertions(+), 28 deletions(-) diff --git a/.github/workflows/fullsend-poll-jira.yaml b/.github/workflows/fullsend-poll-jira.yaml index c3c47b0..32c95c7 100644 --- a/.github/workflows/fullsend-poll-jira.yaml +++ b/.github/workflows/fullsend-poll-jira.yaml @@ -1,14 +1,32 @@ -name: fullsend Jira poll +--- +# fullsend Jira poll +# +# Scheduled workflow that polls Jira for actionable issues/comments and +# dispatches the matching fullsend agent workflow for each one. +# +# Security model: +# - Runs only against this repo's trusted default-branch code (no PR +# checkout), so it is not exposed to "pwn request" style attacks. +# - The fullsend binary is pinned to a specific release and checksum-verified +# before it is given access to the Jira credentials. +# - Every Jira-derived value is validated against a strict allowlist before +# it flows into a gh CLI argument, URL, or workflow command, blocking GHA +# command injection from attacker-controlled issue content. +# +# Dispatch: agent workflows opt in by carrying a "# fullsend-stage: " +# marker line. No such consumer workflows exist in this repo yet, so records +# are logged and skipped until they land (tracked upstream in fullsend #2264). +name: fullsend jira poll + +permissions: + actions: write + contents: read on: schedule: - cron: "*/5 * * * *" workflow_dispatch: {} -permissions: - actions: write - contents: read - jobs: poll: runs-on: ubuntu-24.04 @@ -16,14 +34,23 @@ jobs: group: fullsend-jira-poll cancel-in-progress: false steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Install fullsend env: GH_TOKEN: ${{ github.token }} + FULLSEND_VERSION: v0.36.0 run: | - gh release download --repo fullsend-ai/fullsend -p 'fullsend_*_linux_amd64.tar.gz' -O - | tar xz + set -euo pipefail + asset="fullsend_${FULLSEND_VERSION#v}_linux_amd64.tar.gz" + gh release download "$FULLSEND_VERSION" --repo fullsend-ai/fullsend \ + -p "$asset" -p checksums.txt + sha256sum --ignore-missing -c checksums.txt + tar xzf "$asset" fullsend sudo mv fullsend /usr/local/bin/ + rm -f "$asset" checksums.txt - name: Poll Jira env: @@ -31,11 +58,16 @@ jobs: JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} JIRA_BASE_URL: ${{ vars.JIRA_BASE_URL }} run: | + set -euo pipefail + if [[ -z "${JIRA_BASE_URL:-}" ]]; then + echo "::error::JIRA_BASE_URL is not configured" + exit 1 + fi fullsend poll \ --input-driver jira-poll \ --jira-url "${JIRA_BASE_URL}" \ --jira-project ROSAENG \ - --jql 'project = ROSAENG AND component = ocm-agent-operator AND issuetype in (Bug, Story, Feature) AND issuetype not in (Vulnerability, Weakness) AND labels not in (security, cve, embargo) AND status not in (Closed, Done, "Won'\''t Do")' \ + --jql 'project = ROSAENG AND component = ocm-agent-operator AND issuetype in (Bug, Story, Feature) AND issuetype not in (Vulnerability, Weakness) AND (labels is EMPTY OR labels not in (security, cve, embargo)) AND status not in (Closed, Done, "Won'\''t Do")' \ --target-repo "${{ github.repository }}" \ --output dispatches.json \ --fullsend-dir .fullsend @@ -47,6 +79,11 @@ jobs: run: | set -euo pipefail + if [[ -z "${JIRA_BASE_URL:-}" ]]; then + echo "::error::JIRA_BASE_URL is not configured" + exit 1 + fi + if ! jq -e 'length > 0' dispatches.json > /dev/null 2>&1; then echo "No dispatches to process." exit 0 @@ -57,39 +94,60 @@ jobs: for i in $(seq 0 $((count - 1))); do record=$(jq -c ".[$i]" dispatches.json) - STAGE=$(echo "$record" | jq -r '.stage') - RESOURCE_KEY=$(echo "$record" | jq -r '.resource_key') - EVENT_TYPE=$(echo "$record" | jq -r '.event_type') - ISSUE_ID=$(echo "$record" | jq -r '.iid // 0') + stage=$(echo "$record" | jq -r '.stage') + resource_key=$(echo "$record" | jq -r '.resource_key') + event_type=$(echo "$record" | jq -r '.event_type') + issue_id=$(echo "$record" | jq -r '(.iid // 0) | tonumber') + issue_key="${resource_key#issue-}" - ISSUE_KEY="${RESOURCE_KEY#issue-}" - ISSUE_URL="${JIRA_BASE_URL}/browse/${ISSUE_KEY}" + # Validate every Jira-derived value against a strict allowlist before + # it reaches a gh CLI arg, URL, or workflow command. This fails + # closed on anything unexpected and blocks GHA command injection + # (embedded "::" or encoded newlines) from attacker-controlled data. + if [[ ! "$stage" =~ ^[a-z][a-z0-9-]*$ ]]; then + echo "::warning::Skipping record ${i}: invalid stage" + continue + fi + if [[ ! "$event_type" =~ ^[a-z][a-z0-9_-]*$ ]]; then + echo "::warning::Skipping record ${i}: invalid event_type" + continue + fi + if [[ ! "$issue_key" =~ ^[A-Z][A-Z0-9]*-[0-9]+$ ]]; then + echo "::warning::Skipping record ${i}: invalid issue key" + continue + fi - EVENT_PAYLOAD=$(jq -nc \ - --argjson number "$ISSUE_ID" \ - --arg url "$ISSUE_URL" \ + issue_url="${JIRA_BASE_URL%/}/browse/${issue_key}" + event_payload=$(jq -nc \ + --argjson number "$issue_id" \ + --arg url "$issue_url" \ '{issue: {number: $number, html_url: $url}}') - WORKFLOW_NAME="" + # Find the checked-in workflow that handles this stage, matched by a + # "# fullsend-stage: " marker line at any indentation. + workflow_name="" for wf in .github/workflows/*.yml .github/workflows/*.yaml; do [[ -f "$wf" ]] || continue - if grep -qxF "# fullsend-stage: ${STAGE}" "$wf"; then - WORKFLOW_NAME=$(basename "$wf") + if grep -qF "# fullsend-stage: ${stage}" "$wf"; then + workflow_name=$(basename "$wf") break fi done - if [[ -z "$WORKFLOW_NAME" ]]; then - echo "::warning::No workflow found for stage ${STAGE}, skipping ${RESOURCE_KEY}" + if [[ -z "$workflow_name" ]]; then + echo "::warning::No workflow found for stage ${stage}, skipping ${issue_key}" continue fi - echo "Dispatching ${WORKFLOW_NAME} for ${ISSUE_KEY} (${STAGE})" - gh workflow run "$WORKFLOW_NAME" \ - -f event_type="$EVENT_TYPE" \ + echo "Dispatching ${workflow_name} for ${issue_key} (${stage})" + # Don't let a single transient dispatch failure abort the rest. + if gh workflow run "$workflow_name" \ + -f event_type="$event_type" \ -f source_repo="${{ github.repository }}" \ - -f event_payload="$EVENT_PAYLOAD" - - dispatched=$((dispatched + 1)) + -f event_payload="$event_payload"; then + dispatched=$((dispatched + 1)) + else + echo "::warning::Failed to dispatch ${workflow_name} for ${issue_key}" + fi done echo "::notice::Dispatched ${dispatched} agent workflow(s)"