Skip to content
Merged
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
10 changes: 9 additions & 1 deletion .github/workflows/aquasec-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Comment on lines +69 to +74
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I am not a big fan of the naming, what only stands for in this case? What about ORG_PROJECT_PAT,CROSS_ORG_PROJECT_TOKEN or something like this. Then in the description I would capitalize the word ONLY, to catch an eye.

required: false

permissions:
contents: read
actions: read
issues: write
security-events: write
repository-projects: read
repository-projects: write

jobs:
aquasec-scan:
Expand Down Expand Up @@ -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 }}
Expand Down
5 changes: 4 additions & 1 deletion docs/security/example_workflows/aquasec-night-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ permissions:
actions: read
issues: write
security-events: write
repository-projects: read
repository-projects: write

jobs:
scan:
Expand All @@ -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.
Comment on lines +53 to +54
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
# 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.
# Required only when project-org differs from AbsaOSS.
# See docs/security/security.md – "Cross-org project token" for how to create it.

If it really is about differing from THIS repository's org, it is always AbsaOSS.

GH_PROJECT_ONLY_TOKEN: ${{ secrets.GH_PROJECT_ONLY_TOKEN }}
52 changes: 52 additions & 0 deletions docs/security/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)):

Expand Down Expand Up @@ -195,6 +197,53 @@ jobs:
TEAMS_WEBHOOK_URL: ${{ secrets.TEAMS_WEBHOOK_URL }}
```

##### Cross-org project token
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nitpicking: having a 5th level header for this big chapter is not correct. Should this doc be refactored in the future less needed PR.


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:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
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:
When `project-org` is in a **different organisation** than the AbsaOSS, 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 <N>.
WARNING - Could not load project #<N> 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.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Do we need to overwhelm people with information why it is not working?


**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 |

Comment on lines +231 to +246
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I would keep the docs as little as needed to hand over the information to the final consumer.

#### 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)):
Expand Down Expand Up @@ -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

Expand Down
19 changes: 15 additions & 4 deletions src/shared/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import os
import re
import subprocess
from collections.abc import Mapping
from datetime import datetime, timezone


Expand Down Expand Up @@ -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)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Again, what is the cmd? I would rename the cmd, the env here as well.

except FileNotFoundError as exc:
logging.error("gh CLI not found. Install and authenticate gh.")
raise SystemExit(1) from exc
16 changes: 14 additions & 2 deletions src/shared/github_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@

import json
import logging
import os
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any

Expand Down Expand Up @@ -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``.
"""
Comment on lines +55 to +61
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
"""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``.
"""
"""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``.
"""

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", "")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is some specific token, idea is to have GH_PROJECT_ONLY_TOKEN (hope renamed) will be 1:1 with this current project_token. It is specific project token for this situation.

if project_token:
env = {**os.environ, "GH_TOKEN": project_token}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If this is the specific env for some scenario, we should rename this env.

res = run_gh(args, env=env)
if res.returncode != 0:
logging.warning(f"GraphQL call failed: {res.stderr}")
return None
Expand Down