From 941c8a86b41e62b9e8ca91b75dd7a33663cee83a Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Tue, 5 May 2026 17:20:22 +0530 Subject: [PATCH] fix: use a dedicated PAT for ghsa linear sync gh action (#14364) The default `GITHUB_TOKEN` cannot read `security-advisories`; that endpoint requires the `repository_advisories` permission, which is not available to the GitHub Actions installation token. Switched to a fine-grained PAT stored in `GHSA_READ_TOKEN`. Tested locally: the same PAT returns the full triage list Changes ---- - Switch to custom token - Add a discord alert for new advisories - Switch to python --- .github/scripts/ghsa_linear_sync.py | 195 +++++++++++++++++++++++++ .github/workflows/ghsa-linear-sync.yml | 94 ++---------- 2 files changed, 208 insertions(+), 81 deletions(-) create mode 100644 .github/scripts/ghsa_linear_sync.py diff --git a/.github/scripts/ghsa_linear_sync.py b/.github/scripts/ghsa_linear_sync.py new file mode 100644 index 00000000000..064361b2608 --- /dev/null +++ b/.github/scripts/ghsa_linear_sync.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""Sync triage GitHub security advisories to Linear issues.""" + +from __future__ import annotations + +import os +import sys +from typing import Any + +import requests + +GITHUB_API = "https://api.github.com" +LINEAR_API = "https://api.linear.app/graphql" + +SEVERITY_PRIORITY = {"critical": 1, "high": 2, "medium": 3, "low": 4} +SEVERITY_COLOR = { + "critical": 15548997, + "high": 15105570, + "medium": 15844367, + "low": 3066993, +} +DEFAULT_COLOR = 9807270 + + +def required_env(name: str) -> str: + value = os.environ.get(name) + if not value: + sys.exit(f"Missing required env var: {name}") + return value + + +def fetch_triage_advisories(repo: str, token: str) -> list[dict[str, Any]]: + url: str | None = f"{GITHUB_API}/repos/{repo}/security-advisories" + params: dict[str, Any] | None = {"state": "triage", "per_page": 100} + headers = { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + } + advisories: list[dict[str, Any]] = [] + while url: + r = requests.get(url, headers=headers, params=params, timeout=30) + r.raise_for_status() + advisories.extend(r.json()) + next_link = r.links.get("next") + url = next_link["url"] if next_link else None + params = None + return advisories + + +def linear_call(query: str, variables: dict[str, Any], api_key: str) -> dict[str, Any]: + r = requests.post( + LINEAR_API, + headers={"Authorization": api_key}, + json={"query": query, "variables": variables}, + timeout=30, + ) + r.raise_for_status() + return r.json() + + +def linear_issue_exists(ghsa_id: str, api_key: str) -> bool: + query = ( + "query($q: String!) { issues(filter: {title: {contains: $q}}, first: 1) " + "{ nodes { id } } }" + ) + resp = linear_call(query, {"q": ghsa_id}, api_key) + return len(resp.get("data", {}).get("issues", {}).get("nodes", [])) > 0 + + +def linear_create_issue(input_data: dict[str, Any], api_key: str) -> dict[str, str] | None: + query = ( + "mutation($input: IssueCreateInput!) { issueCreate(input: $input) " + "{ success issue { identifier url } } }" + ) + resp = linear_call(query, {"input": input_data}, api_key) + create = resp.get("data", {}).get("issueCreate") or {} + if not create.get("success"): + return None + return create.get("issue") + + +def reporter_login(advisory: dict[str, Any]) -> str: + for credit in advisory.get("credits") or []: + user = (credit or {}).get("user") or {} + if user.get("login"): + return user["login"] + return "unknown" + + +def cvss_score(advisory: dict[str, Any]) -> str: + score = (advisory.get("cvss") or {}).get("score") + return str(score) if score is not None else "n/a" + + +def build_description(adv: dict[str, Any]) -> str: + return ( + f"**GHSA:** {adv['ghsa_id']}\n" + f"**CVE:** {adv.get('cve_id') or 'n/a'}\n" + f"**Severity:** {adv.get('severity') or 'unknown'} (CVSS {cvss_score(adv)})\n" + f"**Reporter:** {reporter_login(adv)}\n" + f"**Reported:** {(adv.get('created_at') or '').split('T')[0]}\n" + f"**Advisory:** {adv['html_url']}\n\n" + f"---\n\n" + f"{adv.get('description') or 'No description provided.'}" + ) + + +def post_discord(adv: dict[str, Any], issue: dict[str, str], webhook_url: str) -> None: + severity = adv.get("severity") or "unknown" + title = f"[{adv['ghsa_id']}] {adv['summary']}"[:250] + payload = { + "username": "GHSA Sync", + "embeds": [ + { + "title": title, + "url": issue["url"], + "color": SEVERITY_COLOR.get(severity, DEFAULT_COLOR), + "fields": [ + {"name": "Linear", "value": issue["identifier"], "inline": True}, + { + "name": "Severity", + "value": f"{severity} (CVSS {cvss_score(adv)})", + "inline": True, + }, + { + "name": "Advisory", + "value": f"[GitHub]({adv['html_url']})", + "inline": True, + }, + ], + } + ], + } + try: + requests.post(webhook_url, json=payload, timeout=10) + except requests.RequestException: + pass + + +def main() -> int: + repo = required_env("GITHUB_REPOSITORY") + gh_token = required_env("GHSA_READ_TOKEN") + linear_api_key = required_env("LINEAR_API_KEY") + team_id = required_env("LINEAR_TEAM_ID") + project_id = required_env("LINEAR_PROJECT_ID") + label_id = required_env("LINEAR_LABEL_ID") + discord_webhook = os.environ.get("DISCORD_WEBHOOK_URL") or None + + advisories = fetch_triage_advisories(repo, gh_token) + print(f"Fetched {len(advisories)} triage advisories") + + created = skipped = failed = 0 + + for adv in advisories: + ghsa_id = adv.get("ghsa_id") + if not ghsa_id: + failed += 1 + continue + + try: + if linear_issue_exists(ghsa_id, linear_api_key): + skipped += 1 + continue + + severity = adv.get("severity") or "unknown" + issue = linear_create_issue( + { + "title": f"[{ghsa_id}] {adv.get('summary', '')}", + "description": build_description(adv), + "teamId": team_id, + "projectId": project_id, + "labelIds": [label_id], + "priority": SEVERITY_PRIORITY.get(severity, 3), + }, + linear_api_key, + ) + except requests.RequestException: + failed += 1 + continue + + if not issue: + failed += 1 + continue + + created += 1 + if discord_webhook: + post_discord(adv, issue, discord_webhook) + + print(f"Created {created}, skipped {skipped}, failed {failed}") + return 1 if failed > 0 else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/ghsa-linear-sync.yml b/.github/workflows/ghsa-linear-sync.yml index 961114bc984..a21fbea7f69 100644 --- a/.github/workflows/ghsa-linear-sync.yml +++ b/.github/workflows/ghsa-linear-sync.yml @@ -5,93 +5,25 @@ on: - cron: '0 4 * * *' # daily at 09:30 IST workflow_dispatch: {} +permissions: + contents: read + jobs: sync: runs-on: ubuntu-latest - permissions: - security-events: read steps: - - name: Fetch triage advisories - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh api --paginate \ - -H "Accept: application/vnd.github+json" \ - "/repos/${{ github.repository }}/security-advisories?state=triage&per_page=100" \ - | jq -cs 'add | [.[] | { - ghsa_id, cve_id, summary, severity, state, html_url, - description, created_at, - cvss_score: .cvss.score, - reporter: ([.credits[]?.user.login] | first // "unknown") - }]' > advisories.json - echo "Fetched $(jq 'length' advisories.json) triage advisories" - - - name: Create Linear issues for new advisories + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install dependencies + run: pip install requests==2.32.3 + - name: Sync advisories env: + GHSA_READ_TOKEN: ${{ secrets.GHSA_READ_TOKEN }} LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }} LINEAR_PROJECT_ID: ${{ secrets.LINEAR_PROJECT_ID }} LINEAR_LABEL_ID: ${{ secrets.LINEAR_LABEL_ID }} - run: | - created_count=0 - skipped_count=0 - failed_count=0 - while read -r advisory; do - ghsa_id=$(printf '%s' "$advisory" | jq -r '.ghsa_id') - summary=$(printf '%s' "$advisory" | jq -r '.summary') - severity=$(printf '%s' "$advisory" | jq -r '.severity // "unknown"') - cve_id=$(printf '%s' "$advisory" | jq -r '.cve_id // "n/a"') - cvss=$(printf '%s' "$advisory" | jq -r '.cvss_score // "n/a"') - reporter=$(printf '%s' "$advisory" | jq -r '.reporter') - html_url=$(printf '%s' "$advisory" | jq -r '.html_url') - created_date=$(printf '%s' "$advisory" | jq -r '.created_at' | cut -dT -f1) - description=$(printf '%s' "$advisory" | jq -r '.description // "No description provided."') - - existing=$(curl -s -X POST https://api.linear.app/graphql \ - -H "Content-Type: application/json" \ - -H "Authorization: $LINEAR_API_KEY" \ - -d "$(jq -n --arg q "$ghsa_id" '{query: "query($q: String!) { issues(filter: {title: {contains: $q}}, first: 1) { nodes { id } } }", variables: {q: $q}}')" \ - | jq '.data.issues.nodes | length') - - if [ "${existing:-0}" -gt 0 ] 2>/dev/null; then - skipped_count=$((skipped_count+1)) - continue - fi - - priority=3 - case "$severity" in - critical) priority=1 ;; - high) priority=2 ;; - medium) priority=3 ;; - low) priority=4 ;; - esac - - title="[$ghsa_id] $summary" - body=$(printf '**GHSA:** %s\n**CVE:** %s\n**Severity:** %s (CVSS %s)\n**Reporter:** %s\n**Reported:** %s\n**Advisory:** %s\n\n---\n\n%s' \ - "$ghsa_id" "$cve_id" "$severity" "$cvss" "$reporter" "$created_date" "$html_url" "$description") - - success=$(curl -s -X POST https://api.linear.app/graphql \ - -H "Content-Type: application/json" \ - -H "Authorization: $LINEAR_API_KEY" \ - -d "$(jq -n \ - --arg title "$title" \ - --arg body "$body" \ - --arg teamId "$LINEAR_TEAM_ID" \ - --arg projectId "$LINEAR_PROJECT_ID" \ - --arg labelId "$LINEAR_LABEL_ID" \ - --argjson priority "$priority" \ - '{ - query: "mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success } }", - variables: {input: {title: $title, description: $body, teamId: $teamId, projectId: $projectId, labelIds: [$labelId], priority: $priority}} - }')" | jq -r '.data.issueCreate.success // false') - - if [ "$success" = "true" ]; then - created_count=$((created_count+1)) - else - failed_count=$((failed_count+1)) - fi - done < <(jq -c '.[]' advisories.json) - echo "Created $created_count, skipped $skipped_count, failed $failed_count" - if [ "$failed_count" -gt 0 ]; then - exit 1 - fi + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + run: python3 .github/scripts/ghsa_linear_sync.py