diff --git a/.github/workflows/aquasec-scan.yml b/.github/workflows/aquasec-scan.yml index 6625842..a32dc53 100644 --- a/.github/workflows/aquasec-scan.yml +++ b/.github/workflows/aquasec-scan.yml @@ -66,13 +66,20 @@ on: required: true TEAMS_WEBHOOK_URL: required: false + GH_PROJECT_ONLY_TOKEN: + description: > + Classic PAT with 'project' scope on an account that is a member of the + org that owns the ProjectV2 board. Required only when the project lives + in a different organisation than the calling repository. When omitted, + github.token is used (works only for same-org projects). + required: false permissions: contents: read actions: read issues: write security-events: write - repository-projects: read + repository-projects: write jobs: aquasec-scan: @@ -127,6 +134,7 @@ jobs: - name: Run alert-to-issue sync env: GH_TOKEN: ${{ github.token }} + GH_PROJECT_ONLY_TOKEN: ${{ secrets.GH_PROJECT_ONLY_TOKEN }} TEAMS_WEBHOOK_URL: ${{ secrets.TEAMS_WEBHOOK_URL }} SEVERITY_PRIORITY_MAP: ${{ inputs.severity-priority-map }} PROJECT_NUMBER: ${{ inputs.project-number }} diff --git a/docs/security/example_workflows/aquasec-night-scan.yml b/docs/security/example_workflows/aquasec-night-scan.yml index 16c80cd..9763091 100644 --- a/docs/security/example_workflows/aquasec-night-scan.yml +++ b/docs/security/example_workflows/aquasec-night-scan.yml @@ -35,7 +35,7 @@ permissions: actions: read issues: write security-events: write - repository-projects: read + repository-projects: write jobs: scan: @@ -50,3 +50,6 @@ jobs: AQUA_GROUP_ID: ${{ secrets.AQUA_GROUP_ID }} AQUA_REPOSITORY_ID: ${{ secrets.AQUA_REPOSITORY_ID }} TEAMS_WEBHOOK_URL: ${{ secrets.TEAMS_WEBHOOK_URL }} + # Required only when project-org differs from this repository's org. + # See docs/security/security.md – "Cross-org project token" for how to create it. + GH_PROJECT_ONLY_TOKEN: ${{ secrets.GH_PROJECT_ONLY_TOKEN }} diff --git a/docs/security/security.md b/docs/security/security.md index 8dc17ec..4e32d9c 100644 --- a/docs/security/security.md +++ b/docs/security/security.md @@ -16,6 +16,7 @@ In one sentence: SARIF uploads create alerts; these scripts sync alerts into Iss - [Available reusable workflows](#available-reusable-workflows) - [How to adopt a shared workflow](#how-to-adopt-a-shared-workflow) - [Aquasec Night Scan](#aquasec-night-scan) + - [Cross-org project token](#cross-org-project-token) - [Remove sec:adept-to-close on close](#remove-secadept-to-close-on-close) - [Labels (contract)](#labels-contract) - [Issue metadata (secmeta)](#issue-metadata-secmeta) @@ -163,6 +164,7 @@ The caller needs the following **repository secrets** configured: | `AQUA_GROUP_ID` | yes | AquaSec group identifier | | `AQUA_REPOSITORY_ID` | yes | AquaSec repository identifier | | `TEAMS_WEBHOOK_URL` | no | Teams Incoming Webhook URL for new/reopened issue alerts | +| `GH_PROJECT_ONLY_TOKEN` | no (required for cross-org projects) | Classic PAT with `project` scope on an account that is a member of the org owning the ProjectV2 board – see [Cross-org project token](#cross-org-project-token) | Example caller (already available in [aquasec-night-scan.yml](/docs/security/example_workflows/aquasec-night-scan.yml)): @@ -195,6 +197,53 @@ jobs: TEAMS_WEBHOOK_URL: ${{ secrets.TEAMS_WEBHOOK_URL }} ``` +##### Cross-org project token + +When `project-org` is a **different organisation** than the one that owns the calling repository, the automatic `github.token` cannot resolve the ProjectV2 board and you will see: + +``` +WARNING - GraphQL call failed: gh: Could not resolve to a ProjectV2 with the number . +WARNING - Could not load project # metadata – priority sync disabled +``` + +The fix is a **Personal Access Token (classic)** with the `project` scope, created by an account that is a member of the org owning the project board. + +> **Why classic and not fine-grained?** Fine-grained PATs require the org admin to explicitly allow them under org settings. If that is not enabled, a classic PAT with the `project` scope is the practical alternative. Classic PATs are less granular (they apply to all orgs the account belongs to) but the `project` scope is the minimum checkbox needed and grants no code or issue access. + +**Step-by-step: create the token** + +1. Log in to the GitHub account that is a **member of the org that owns the project board** (e.g. `your-org`). Using a dedicated service/bot account is recommended over a personal account so the token does not expire when someone leaves. +2. Go to **that account's Settings → Developer settings → Personal access tokens → Tokens (classic)**. +3. Click **Generate new token (classic)**. +4. Fill in the form: + - **Note**: something descriptive, e.g. `aquasec-alert-to-issues-priority-sync` + - **Expiration**: choose a date; calendar a renewal reminder + - **Scopes**: check **`project`** (labelled *"Full control of user projects"*) — this is the only scope needed. Do **not** check `repo`, `admin:org`, or anything else. +5. Click **Generate token** and copy the value immediately (it is shown only once). + +**Step-by-step: store the token in the calling repository** + +1. In **your application repository** (the adopting repo, in your own org), go to **Settings → Secrets and variables → Actions**. +2. Click **New repository secret**. +3. Name: `GH_PROJECT_ONLY_TOKEN`, Value: paste the token from the step above. +4. Save. + +The example caller workflow already passes this secret: + +```yaml +secrets: + GH_PROJECT_ONLY_TOKEN: ${{ secrets.GH_PROJECT_ONLY_TOKEN }} +``` + +The reusable workflow forwards it to the Python script as the `GH_PROJECT_ONLY_TOKEN` environment variable. The script uses it **only** for ProjectV2 GraphQL calls; all other operations (issue list/create/update) continue to use the scoped `github.token`. + +**Minimum required scope summary** + +| Scope | Reason | +| --- | --- | +| `project` | Read/write access to org-level ProjectV2 — query metadata + set Priority field values | +| *(everything else)* | Not needed — do not check | + #### Remove sec:adept-to-close on close Example caller (already available in [remove-adept-to-close-on-issue-close.yml](/docs/security/example_workflows/remove-adept-to-close-on-issue-close.yml)): @@ -465,6 +514,9 @@ As of 2026-03, `promote_alerts.py` implements the fingerprint-based sync loop de - Permission errors in Actions: ensure the workflow has `security-events: read` and `issues: write` permissions. - `Output file alerts.json exists`: `collect_alert.py` refuses to overwrite output; delete the file, pass a different `--out` path, or run via `sync_security_alerts.py --force` (which deletes the file before invoking the collector). - `missing 'alert hash' in alert message`: the scanner/collector needs to include an `Alert hash: ...` line in the alert instance message text. +- `GraphQL call failed: gh: Could not resolve to a ProjectV2 with the number N` / `Could not load project #N metadata – priority sync disabled`: the token used for the GraphQL call cannot see the project. Two causes: + - **Same org, wrong permission**: ensure the caller workflow has `repository-projects: write` (not `read`). + - **Cross-org**: the calling repository is in a different org than the project board. Create a classic PAT with the `project` scope on an account that is a member of the project-owning org, and store it as `GH_PROJECT_ONLY_TOKEN` in the calling repository's secrets. See [Cross-org project token](#cross-org-project-token). ## References diff --git a/src/shared/common.py b/src/shared/common.py index 53c3144..6f175f6 100644 --- a/src/shared/common.py +++ b/src/shared/common.py @@ -23,6 +23,7 @@ import os import re import subprocess +from collections.abc import Mapping from datetime import datetime, timezone @@ -67,16 +68,26 @@ def normalize_path(path: str | None) -> str: return p -def run_cmd(cmd: list[str], *, capture_output: bool = True) -> subprocess.CompletedProcess: +def run_cmd( + cmd: list[str], + *, + capture_output: bool = True, + env: Mapping[str, str] | None = None, +) -> subprocess.CompletedProcess: """Run *cmd* as a subprocess and return the completed process.""" - return subprocess.run(cmd, check=False, capture_output=capture_output, text=True) + return subprocess.run(cmd, check=False, capture_output=capture_output, text=True, env=env) -def run_gh(args: list[str], *, capture_output: bool = True) -> subprocess.CompletedProcess: +def run_gh( + args: list[str], + *, + capture_output: bool = True, + env: Mapping[str, str] | None = None, +) -> subprocess.CompletedProcess: """Run a ``gh`` CLI command and return the completed process.""" cmd = ["gh"] + args try: - return run_cmd(cmd, capture_output=capture_output) + return run_cmd(cmd, capture_output=capture_output, env=env) except FileNotFoundError as exc: logging.error("gh CLI not found. Install and authenticate gh.") raise SystemExit(1) from exc diff --git a/src/shared/github_projects.py b/src/shared/github_projects.py index a11c4cf..8b7a462 100644 --- a/src/shared/github_projects.py +++ b/src/shared/github_projects.py @@ -21,6 +21,8 @@ import json import logging +import os +from collections.abc import Mapping from dataclasses import dataclass from typing import Any @@ -50,11 +52,21 @@ class ProjectPriorityField: def _run_graphql(query: str, variables: dict[str, Any] | None = None) -> dict[str, Any] | None: - """Execute a GraphQL query via ``gh api graphql`` and return parsed JSON.""" + """Execute a GraphQL query via ``gh api graphql`` and return parsed JSON. + + When ``GH_PROJECT_ONLY_TOKEN`` is set in the environment the GraphQL call is made + with that token instead of the default ``GH_TOKEN``. This allows cross-org + project access while the rest of the pipeline continues to use the scoped + ``github.token``. + """ args = ["api", "graphql", "-f", f"query={query}"] for k, v in (variables or {}).items(): args += ["-F", f"{k}={v}"] - res = run_gh(args) + env: Mapping[str, str] | None = None + project_token = os.environ.get("GH_PROJECT_ONLY_TOKEN", "") + if project_token: + env = {**os.environ, "GH_TOKEN": project_token} + res = run_gh(args, env=env) if res.returncode != 0: logging.warning(f"GraphQL call failed: {res.stderr}") return None