From 02bc7c82fbc6b0727c9785a233221e2b772ede97 Mon Sep 17 00:00:00 2001 From: Nicholas Skaggs Date: Mon, 18 May 2026 15:19:33 -0400 Subject: [PATCH] feat(octosts): add daily policy detector Daily GHA cron that queries BigQuery for OctoSTS identities returning 'no trust policy' errors, diffs against the trust policy files in this repo, and opens a Linear issue per missing identity. The issue is labeled for the linear-materializer (chainguard-dev/mono/bots/linear-materializer) which then opens a draft PR with a stub .sts.yaml using the format described in .claude/skills/octosts-policy.md. Modeled on .github/workflows/ghaudit.yaml in this repo. Requires (separate setup): - GCP SA octosts-policy-detector@octo-sts.iam.gserviceaccount.com with roles/bigquery.dataViewer on the cloudevents_octo_sts_recorder dataset, bound to the existing internal-tools WIF pool for this workflow - LINEAR_CLIENT_ID and LINEAR_CLIENT_SECRET secrets in this repo (reusing the materializer's OAuth app) - chainguard-dev/.github added to the materializer's AllowedRepoOwners in chainguard-dev/mono/bots/linear-materializer prod IaC DEV-1372 Signed-off-by: Nicholas Skaggs --- .claude/skills/octosts-policy.md | 61 +++++ .github/scripts/octosts-policy-detector.py | 234 ++++++++++++++++++ .../workflows/octosts-policy-detector.yaml | 69 ++++++ 3 files changed, 364 insertions(+) create mode 100644 .claude/skills/octosts-policy.md create mode 100755 .github/scripts/octosts-policy-detector.py create mode 100644 .github/workflows/octosts-policy-detector.yaml diff --git a/.claude/skills/octosts-policy.md b/.claude/skills/octosts-policy.md new file mode 100644 index 0000000..6c66c73 --- /dev/null +++ b/.claude/skills/octosts-policy.md @@ -0,0 +1,61 @@ +--- +name: octosts-policy +description: Generate a stub OctoSTS trust policy file in `.github/chainguard/` when a Linear issue reports a missing policy. +--- + +# OctoSTS Trust Policy Stub + +When the linear-materializer picks up an issue titled `OctoSTS: trust policy missing for \`\``, generate a stub trust policy file at `.github/chainguard/.sts.yaml`. + +## Inputs (from the issue body) + +The issue body contains: +- `issuer` — the OIDC issuer URL from `actor.iss` in BQ (e.g. `https://accounts.google.com`, `https://token.actions.githubusercontent.com`) +- `subject` — the subject claim from `actor.sub` (e.g. a numeric GCP service-account ID, or a GitHub Actions subject) +- `identity` — the OctoSTS identity name (from the issue title; equals the filename stem) + +## Stub format + +```yaml +# Copyright YYYY Chainguard, Inc. +# SPDX-License-Identifier: Apache-2.0 +# +# AUTO-GENERATED STUB. Do not merge as-is — fill in repositories and permissions. + +issuer: +subject: "" + +permissions: + # TODO: scope permissions to the minimum required for this caller. + # Common choices: + # pull_requests: read # read PR review state + # pull_requests: write # open/update PRs + # contents: read # read repo contents + # contents: write # push branches / commits + # issues: read | write + # checks: read | write # post check runs (pins consumer to a single STS install — use carefully) + # workflows: write # trigger / manage GitHub Actions + pull_requests: read + +repositories: [] # TODO: specify the repos this identity needs access to, or leave [] for org-wide read-only. +``` + +## Reference policies in this repo + +- Image-gen pattern (write to `stereo`): `.github/chainguard/dev-mrisharma-image-gen.sts.yaml`, `.github/chainguard/dev-zkranurag-image-gen.sts.yaml` +- Read-only org-wide: `.github/chainguard/slack-reactor.sts.yaml` +- Bot scoped to specific repos: `.github/chainguard/eco-fixer-reconciler.sts.yaml` + +Use the closest existing example as a starting point. **Always** include the `Do not merge as-is` comment line — the human reviewer must approve the repositories and permissions before merge. + +## Commit message format + +`feat(octosts): add stub trust policy for ` with a one-line body pointing at the originating Linear issue. + +## PR title + +`feat(octosts): add stub trust policy for ` + +## Test plan section in PR description + +> Manually verify the `subject` claim matches the service that needs access. After merge, the next `dev_octo-sts_exchange` from this identity should succeed; if it still fails with `no trust policy`, the subject is wrong. diff --git a/.github/scripts/octosts-policy-detector.py b/.github/scripts/octosts-policy-detector.py new file mode 100755 index 0000000..38611f5 --- /dev/null +++ b/.github/scripts/octosts-policy-detector.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +# Copyright 2026 Chainguard, Inc. +# SPDX-License-Identifier: Apache-2.0 +"""OctoSTS policy detector. + +Daily cron: + 1. Query BigQuery for OctoSTS identities that returned `no trust policy` + errors in the last 24h, along with the issuer/subject claims. + 2. List existing trust-policy files in `.github/chainguard/`. + 3. For each identity active in BQ but missing from the repo, ensure a + Linear issue exists in the DEV team (creating one if needed) with the + `materializer:managed` + `repo:chainguard-dev/.github` + `FY27Q2` labels. + +The linear-materializer (bots/linear-materializer in chainguard-dev/mono) +picks up the issue and produces a stub PR in this repo. The skill file at +`.claude/skills/octosts-policy.md` tells the materializer the stub format. +""" + +from __future__ import annotations + +import json +import os +import pathlib +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request + +POLICY_DIR = pathlib.Path(".github/chainguard") +LINEAR_API = "https://api.linear.app/graphql" +LINEAR_TOKEN_URL = "https://api.linear.app/oauth/token" # noqa: S105 -- URL, not a secret +LINEAR_SCOPES = "read,write,issues:create" +LOOKBACK_HOURS = 24 + +# BQ query: identities returning "no trust policy" errors in the lookback window, +# with one sample row's issuer and subject claim. +BQ_QUERY = f""" +SELECT + identity, + ANY_VALUE(actor.iss) AS issuer, + ANY_VALUE(actor.sub) AS subject, + COUNT(*) AS exchanges, + ANY_VALUE(error) AS sample_error +FROM `octo-sts.cloudevents_octo_sts_recorder.dev_octo-sts_exchange` +WHERE time > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL {LOOKBACK_HOURS} HOUR) + AND error LIKE '%no trust policy%' +GROUP BY identity +ORDER BY exchanges DESC +LIMIT 200 +""" + + +def list_existing_policies() -> set[str]: + """Return identity names corresponding to `.sts.yaml` files in the repo.""" + if not POLICY_DIR.is_dir(): + raise SystemExit(f"missing policy directory: {POLICY_DIR}") + return {p.name.removesuffix(".sts.yaml") for p in POLICY_DIR.glob("*.sts.yaml")} + + +def run_bq_query() -> list[dict]: + """Execute the BQ query via `bq` and return parsed rows.""" + proc = subprocess.run( + [ + "bq", + "query", + "--use_legacy_sql=false", + "--format=json", + "--max_rows=200", + "--nouse_cache", + BQ_QUERY, + ], + capture_output=True, + text=True, + check=False, + ) + if proc.returncode != 0: + sys.stderr.write(f"bq query failed: {proc.stderr}\n") + raise SystemExit(1) + return json.loads(proc.stdout or "[]") + + +def mint_linear_token() -> str: + """Mint a Linear OAuth access token via the client_credentials flow.""" + client_id = os.environ["LINEAR_CLIENT_ID"] + client_secret = os.environ["LINEAR_CLIENT_SECRET"] + form = urllib.parse.urlencode( + { + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + "scope": LINEAR_SCOPES, + } + ).encode() + req = urllib.request.Request(LINEAR_TOKEN_URL, data=form, method="POST") + req.add_header("Content-Type", "application/x-www-form-urlencoded") + with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 -- fixed URL + body = json.loads(resp.read()) + token = body.get("access_token") + if not token: + raise SystemExit(f"linear token response missing access_token: {body!r}") + return token + + +def linear_graphql(token: str, query: str, variables: dict | None = None) -> dict: + """Execute a Linear GraphQL query/mutation.""" + payload = json.dumps({"query": query, "variables": variables or {}}).encode() + req = urllib.request.Request(LINEAR_API, data=payload, method="POST") + req.add_header("Authorization", f"Bearer {token}") + req.add_header("Content-Type", "application/json") + with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 -- fixed URL + result = json.loads(resp.read()) + if "errors" in result: + raise SystemExit(f"linear graphql error: {result['errors']}") + return result["data"] + + +def find_open_issue(token: str, identity: str) -> str | None: + """Return URL of an existing open issue for this identity, or None.""" + query = """ + query ($title: String!, $projectId: ID!) { + issues( + filter: { + project: { id: { eq: $projectId } } + title: { containsIgnoreCase: $title } + state: { type: { nin: ["completed", "canceled"] } } + } + first: 5 + ) { nodes { id identifier title url } } + } + """ + data = linear_graphql( + token, + query, + {"title": identity, "projectId": os.environ["LINEAR_PROJECT_ID"]}, + ) + for node in data.get("issues", {}).get("nodes", []): + if identity in node.get("title", ""): + return node["url"] + return None + + +def create_issue( + token: str, + identity: str, + issuer: str, + subject: str, + exchanges: int, + sample_error: str, +) -> str: + """Create a Linear issue and return its URL.""" + body = ( + f"## Detection\n\n" + f"OctoSTS identity `{identity}` was called {exchanges} time(s) in the last " + f"{LOOKBACK_HOURS}h, but no trust policy file exists at " + f"`.github/chainguard/{identity}.sts.yaml` in this repo.\n\n" + f"## Subject claim (for the stub)\n\n" + f"- `issuer`: `{issuer}`\n" + f"- `subject`: `{subject}`\n\n" + f"## Sample error\n\n" + f"```\n{sample_error}\n```\n\n" + f"## Action\n\n" + f"Add a trust policy at `.github/chainguard/{identity}.sts.yaml`. " + f"See [`.claude/skills/octosts-policy.md`](.claude/skills/octosts-policy.md) " + f"in this repo for the stub format. The linear-materializer should pick " + f"this up automatically and open a draft PR.\n" + ) + mutation = """ + mutation ($input: IssueCreateInput!) { + issueCreate(input: $input) { + success + issue { identifier title url } + } + } + """ + label_ids = [s.strip() for s in os.environ["LINEAR_LABEL_IDS"].split(",") if s.strip()] + data = linear_graphql( + token, + mutation, + { + "input": { + "title": f"OctoSTS: trust policy missing for `{identity}`", + "description": body, + "teamId": os.environ["LINEAR_TEAM_ID"], + "projectId": os.environ["LINEAR_PROJECT_ID"], + "labelIds": label_ids, + } + }, + ) + create = data.get("issueCreate") or {} + if not create.get("success"): + raise SystemExit(f"failed to create linear issue for {identity}: {data!r}") + return create["issue"]["url"] + + +def main() -> int: + existing = list_existing_policies() + print(f"existing trust policies in repo: {len(existing)}") + + rows = run_bq_query() + print(f"identities with `no trust policy` errors in last {LOOKBACK_HOURS}h: {len(rows)}") + + missing = [r for r in rows if r["identity"] not in existing] + print(f"missing trust policies: {len(missing)}") + if not missing: + return 0 + + token = mint_linear_token() + + created = 0 + skipped = 0 + for row in missing: + identity = row["identity"] + if existing_url := find_open_issue(token, identity): + print(f" - {identity}: open issue already exists ({existing_url}), skipping") + skipped += 1 + continue + url = create_issue( + token=token, + identity=identity, + issuer=(row.get("issuer") or "(unknown)"), + subject=(row.get("subject") or "(unknown)"), + exchanges=int(row.get("exchanges") or 0), + sample_error=(row.get("sample_error") or "").replace("```", "ʼʼʼ"), + ) + print(f" - {identity}: created issue {url}") + created += 1 + + print(f"summary: created {created}, skipped {skipped} (deduped)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/octosts-policy-detector.yaml b/.github/workflows/octosts-policy-detector.yaml new file mode 100644 index 0000000..6dc8568 --- /dev/null +++ b/.github/workflows/octosts-policy-detector.yaml @@ -0,0 +1,69 @@ +# Copyright 2026 Chainguard, Inc. +# SPDX-License-Identifier: Apache-2.0 + +name: OctoSTS Policy Detector + +on: + workflow_dispatch: + schedule: + # Daily at 14:00 UTC (07:00 PT). + - cron: "0 14 * * *" + +permissions: {} + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + detect: + name: Detect missing trust policies + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read # Clone the repository + id-token: write # Federate to GCP via Workload Identity + + steps: + - name: Harden the runner + uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0 + with: + egress-policy: audit + + - name: Check out code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Authenticate to GCP + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 + with: + workload_identity_provider: projects/96355665038/locations/global/workloadIdentityPools/internal-tools/providers/github-provider + service_account: octosts-policy-detector@octo-sts.iam.gserviceaccount.com + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1 + + - name: Run policy detector + env: + LINEAR_CLIENT_ID: ${{ secrets.LINEAR_CLIENT_ID }} + LINEAR_CLIENT_SECRET: ${{ secrets.LINEAR_CLIENT_SECRET }} + LINEAR_TEAM_ID: fb3471a4-4052-4177-86f3-22322880d710 # DEV + LINEAR_PROJECT_ID: ad5e3c08-82d8-441c-bd51-39fb5236f5e2 # Insure against GitHub API spikes + # Labels: materializer:managed, repo:chainguard-dev/.github, FY27Q2 + LINEAR_LABEL_IDS: 7924b64d-1f53-48de-af56-db261221d226,35cfc7ed-3afb-4145-a9c8-98ddb3b7cd04,1038209e-ad24-44d8-8caf-42178c6b6d91 + run: python3 .github/scripts/octosts-policy-detector.py + + - name: Slack notify on failure + if: ${{ failure() }} + uses: step-security/action-slack-notify@e04c77a65bae8b6c0373478a1cb8fd7e012637e6 # v2.3.5 + env: + SLACK_ICON: http://github.com/chainguard-dev.png?size=48 + SLACK_USERNAME: octosts-policy-detector + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + SLACK_CHANNEL: "#octo-sts-alerts" + SLACK_COLOR: "#8E1600" + MSG_MINIMAL: "true" + SLACK_TITLE: OctoSTS Policy Detector failed + SLACK_MESSAGE: | + For detailed logs: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}