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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Every prefix `ci_lint` implements or reserves, and the next free number in each
| `PY` | 001–004 | `PY-005` | 001 (scoped to `[suites.perf].run`'s declared entry point(s)), 002 (typed-dataclass records; ratchet over `ci_lint`'s own source, `ci_lint.typed_records`, which resolves module-local type aliases; burned down to zero in every module, #193 -- the baseline now only forbids regressions), 003 (no pipe-captured subprocess output, `ci_lint.subprocess_capture` / `ci-lint py lint`), 004 (ruff C901 complexity ratchet, `ci_lint.rules.complexity` / `ci-lint complexity`, #229) |
| `PERF` | 001–002 | `PERF-003` | 001 (distinct from the implemented `ci-lint perf compare` runtime command -- see policy-general.md); 002 candidate, decided policy adopted from #151 |
| `FLEET` | 001–003 | `FLEET-004` | 001, 002, 003 (fleet-scan-only rules, live, `ci-lint fleet scan`; 003 = default-branch push runs are >=20% of run wall time with no `reuse-check` and no live merge queue, #159); `python3 -m ci_lint history` diffs recorded `fleet scan --json` files (new/persisting/resolved/unscanned, #99; daily cadence, docs/agent-guide.md) |
| `REL` | 001–006 | `REL-007` | 001, 002, 003, 004, 005 (issue #8/#74; 004's reuse half -- `release resume-plan`/`verify-frozen`, `ci_lint.release_resume` -- #109); 006 candidate, decided policy (#462: macOS Developer ID signing + notarization in release jobs only, `actions/app-signing-macos`), not yet enforced |
| `REL` | 001–006 | `REL-007` | 001, 002, 003, 004, 005 (issue #8/#74; 004's reuse half -- `release resume-plan`/`verify-frozen`, `ci_lint.release_resume` -- #109); 006 partial (#462: static precheck, `ci_lint.rules.macos_signing` -- macOS signing secrets/`actions/app-signing-macos` never reach a `pull_request`/`pull_request_target`/`merge_group` job, no `environment:` is needs_review; the signing-procedure and unsigned-publish clauses stay candidate) |
| `GATE` | 001–013 | `GATE-014` | 001, 002, 003, 004, 005, 007, 008, 009, 010, 012 (issues #166/#168/#177/#190/#196/#198/#206; 006 candidate; 012 = checks act cannot run never gate a PR -- CodeRabbit suppressed, act-impossible steps confined, zero wait on app checks, `ci_lint.remote_only`; 009 = isolated runner proves its tree (`[gate.isolation] proves-tree`, nonce echo); 010 is `ci-attestations.yml`, phase 1, co-developed with zackees/soldr#3534; 011 = lane fidelity (native/vm/emulation; emulation never proves `test`); a new family, so it cannot collide with the contested `GEN-022` -- see #154); 013 candidate: a PR gate/verify job with `if: always()` goes red on every superseded/cancelled run -- use `!cancelled()` plus a head-SHA check) |
| `CPP` | 001–004 | `CPP-005` | 001 (#393, `ci_lint.rules.cpp_ctest`: CI `ctest` with no `-j`/`--parallel`/`CTEST_PARALLEL_LEVEL`/test-preset `execution.jobs`; static precheck and `local-gate lint`); 002 candidate (format-only lane keyed on the whole tree), 003 candidate (native Windows/macOS gate attested by a Linux lane, or a native job in `[gate.trust].skip`), 004 candidate (header-aware lane inputs from `compile_commands.json` + Ninja depfiles) -- see docs/policy-cpp.md |
| `GHAPI` | 001 | `GHAPI-002` | 001 (static precheck group 20, `ci_lint.rules.ghapi_reread`: unbounded GitHub re-read loops and gh watch loops in `run:`, `ci/` and skill/tool scripts; its broker-bypass and machine-shared-cache clauses stay candidate until the broker ships, #224, zackees/clud#1743) |
Expand Down
2 changes: 2 additions & 0 deletions ci_lint/precheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
from ci_lint.rules.py_benchmark import check_group14
from ci_lint.rules.doc_claims import check_gen_010_static
from ci_lint.rules.ghapi_reread import check_ghapi_001
from ci_lint.rules.macos_signing import check_rel_006
from ci_lint.rules.host_sweeps import check_gen_030
from ci_lint.rules.status_badges import check_gen_026
from ci_lint.rules.release_gate import check_group11
Expand Down Expand Up @@ -221,6 +222,7 @@ def run_precheck(repo_root: Path, *, title: str = "", local: bool = False, live:
all_findings.extend(check_complexity(repo_root).findings)
all_findings.extend(check_rust_019(repo_root))
all_findings.extend(check_ghapi_001(repo_root))
all_findings.extend(check_rel_006(repo_root))
all_findings.extend(check_gen_023(repo_root))
all_findings.extend(check_gen_030(repo_root))
all_findings.extend(check_gen_026(repo_root))
Expand Down
220 changes: 220 additions & 0 deletions ci_lint/rules/macos_signing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
"""REL-006 static clauses: macOS signing secrets never reach PR jobs (#462).

docs/policy-general.md "macOS app signing and notarization" decides that
Developer ID signing and notarization run in release jobs only -- tag
pushes, `release` events and release `workflow_dispatch` runs -- and never
on `pull_request`, `pull_request_target` or `merge_group`. This module
enforces the part of REL-006 a workflow file can show:

- A *signing job* is a job that references one of the documented signing
secrets (`secrets.MACOS_SIGNING_*`, `secrets.MACOS_NOTARY_*`, or the
accepted fallback `MACOS_NOTARIZATION_USERNAME/PASSWORD`), in its own
body or through a workflow-level `env:`, or that uses
`actions/app-signing-macos` (local `./actions/...` or
`zackees/ci.yml/actions/app-signing-macos@...`).
- In a workflow triggered by `pull_request_target`, a signing job is a
violation whatever its `if:` says: that event runs with secrets in the
base repository's context for fork PRs, so the secrets must not be
reachable from such a workflow at all.
- In a workflow triggered by `pull_request`/`merge_group`, a signing job
with no `if:` is a violation; one whose `if:` confines it to tag pushes,
`release` or `workflow_dispatch` passes; any other `if:` (including one
that merely excludes PR events) is `needs_review`.
- A local reusable workflow called from such a workflow is scanned with the
caller job's confinement, as GATE-012 does.
- A signing job anywhere with no `environment:` is `needs_review`: the
policy keeps the secrets in a GitHub environment restricted to the default
branch and `v*` tags, but also allows repository-level secrets, so the
absence alone cannot be proven wrong offline.

Not enforced statically (Candidate): the signing procedure itself (inside-out
`codesign`, keychain cleanup, notarize/staple/spctl) when a repository does
not use the reference action, and "an unsigned macOS artifact is published".
"""

from __future__ import annotations

import re
from enum import Enum
from pathlib import Path

from ci_lint.finding import Finding, Status
from ci_lint.workflow_scan import ParsedYamlFile, as_dict, get_on_section, jobs_of, load_workflows, steps_of
from ci_lint.yaml_io import LoadStatus, YamlValue

RULE = "REL-006"
POLICY = "docs/policy-general.md#macos-app-signing-and-notarization-candidate-rel-006"

SIGNING_SECRETS: tuple[str, ...] = (
"MACOS_SIGNING_IDENTITY",
"MACOS_SIGNING_CERT",
"MACOS_SIGNING_CERT_PASSWORD",
"MACOS_NOTARY_API_KEY",
"MACOS_NOTARY_API_KEY_ID",
"MACOS_NOTARY_API_ISSUER_ID",
# The accepted Apple ID + app-specific password fallback.
"MACOS_NOTARIZATION_USERNAME",
"MACOS_NOTARIZATION_PASSWORD",
)
_SECRET_REF = re.compile(r"secrets\s*\.\s*(" + "|".join(SIGNING_SECRETS) + r")\b|secrets\[\s*['\"](" + "|".join(SIGNING_SECRETS) + r")['\"]\s*\]")
_ACTION = re.compile(r"^(\./|zackees/ci\.yml/)actions/app-signing-macos(@|/?$)", re.IGNORECASE)

PR_EVENTS: frozenset[str] = frozenset({"pull_request", "pull_request_target", "merge_group"})

# Conditions that keep a job to tag pushes, release events or dispatch runs.
_RELEASE_ONLY = (
re.compile(r"github\.event_name\s*==\s*'(release|workflow_dispatch)'"),
re.compile(r"startswith\(\s*github\.ref\s*,\s*'refs/tags/"),
re.compile(r"github\.ref\s*==\s*'refs/tags/"),
re.compile(r"github\.ref_type\s*==\s*'tag'"),
re.compile(r"^false$"),
)


class Gate(str, Enum):
NONE = "none"
RELEASE_ONLY = "release-only"
UNKNOWN = "unknown"


def classify_if(expr: YamlValue) -> Gate:
if expr is None:
return Gate.NONE
if expr is False:
return Gate.RELEASE_ONLY
if not isinstance(expr, str):
return Gate.UNKNOWN
text = expr.strip()
if text.startswith("${{") and text.endswith("}}"):
text = text[3:-2]
text = re.sub(r"\s+", " ", text.replace('"', "'")).strip().lower()
parts = [p.strip(" ()") for p in text.split("||")] if "||" in text else [text]
if all(any(rx.search(part) for rx in _RELEASE_ONLY) for part in parts):
return Gate.RELEASE_ONLY
return Gate.UNKNOWN


def _combine(outer: Gate, inner: Gate) -> Gate:
if Gate.RELEASE_ONLY in (outer, inner):
return Gate.RELEASE_ONLY
if Gate.UNKNOWN in (outer, inner):
return Gate.UNKNOWN
return Gate.NONE


def _strings(value: YamlValue) -> list[str]:
if isinstance(value, str):
return [value]
if isinstance(value, dict):
return [s for v in value.values() for s in _strings(v)]
if isinstance(value, list):
return [s for v in value for s in _strings(v)]
return []


def _secrets_in(value: YamlValue) -> list[str]:
names: list[str] = []
for text in _strings(value):
for match in _SECRET_REF.finditer(text):
name = match.group(1) or match.group(2)
if name not in names:
names.append(name)
return names


def _uses_action(job: dict[str, YamlValue]) -> bool:
return any(isinstance(s.get("uses"), str) and _ACTION.match(str(s.get("uses"))) for s in steps_of(job))


def _what(secrets: list[str], action: bool) -> str:
parts = []
if action:
parts.append("uses `actions/app-signing-macos`")
if secrets:
parts.append("references " + ", ".join(f"`secrets.{n}`" for n in secrets))
return " and ".join(parts)


def _events(document: YamlValue) -> set[str]:
return {str(e) for e in get_on_section(as_dict(document))}


def _local_reusable(uses: YamlValue) -> str | None:
if isinstance(uses, str) and uses.startswith("./.github/workflows/"):
return uses[2:].split("@", 1)[0]
return None


def _job_findings(wf: ParsedYamlFile, events: set[str], context: Gate) -> list[Finding]:
doc = as_dict(wf.document)
wf_secrets = _secrets_in(doc.get("env"))
findings: list[Finding] = []
for job_id, job in jobs_of(doc).items():
secrets = [*wf_secrets, *(n for n in _secrets_in(job) if n not in wf_secrets)]
action = _uses_action(job)
if not secrets and not action:
continue
where = f"job '{job_id}'"
what = _what(secrets, action)
if "pull_request_target" in events:
findings.append(Finding(
rule=RULE, path=wf.path,
message=f"{where} {what} in a workflow triggered by `pull_request_target`, which runs with "
"the base repository's secrets for fork PRs",
fix="move the signing job to a workflow triggered only by tag pushes, `release` or "
f"`workflow_dispatch`; never expose signing secrets to `pull_request_target` ({POLICY})",
))
elif events & PR_EVENTS:
gate = _combine(context, classify_if(job.get("if")))
pr = ", ".join(f"`{e}`" for e in sorted(events & PR_EVENTS))
if gate == Gate.NONE:
findings.append(Finding(
rule=RULE, path=wf.path,
message=f"{where} {what} and runs on every {pr} event; signing secrets must never reach a PR job",
fix="add `if: startsWith(github.ref, 'refs/tags/v')` (or `github.event_name == 'release'`/"
f"`'workflow_dispatch'`), or move the job to a release-only workflow ({POLICY})",
))
elif gate == Gate.UNKNOWN:
findings.append(Finding(
rule=RULE, path=wf.path, status=Status.NEEDS_REVIEW,
message=f"{where} {what} in a {pr} workflow; its `if:` is not recognized as confining it "
"to tag pushes, `release` or `workflow_dispatch`",
fix="confine it with `if: startsWith(github.ref, 'refs/tags/v')` or "
f"`github.event_name == 'release'`/`'workflow_dispatch'` ({POLICY})",
))
if "environment" not in job and "uses" not in job:
findings.append(Finding(
rule=RULE, path=wf.path, status=Status.NEEDS_REVIEW,
message=f"{where} {what} but declares no `environment:`; the policy keeps signing secrets in a "
"GitHub environment restricted to the default branch and `v*` tags",
fix="store the signing secrets in an environment such as `release` (deployment branches: default "
f"branch and `v*` tags) and add `environment: release` to this job ({POLICY})",
))
return findings


def check_rel_006(repo_root: Path) -> list[Finding]:
workflows = [wf for wf in load_workflows(repo_root) if wf.status == LoadStatus.OK]
by_path = {wf.path: wf for wf in workflows}
findings: list[Finding] = []
scanned_as_callee: set[str] = set()
for wf in workflows:
events = _events(wf.document)
if not events & PR_EVENTS:
continue
for job in jobs_of(as_dict(wf.document)).values():
callee = _local_reusable(job.get("uses"))
target = by_path.get(callee) if callee else None
if target is None or callee in scanned_as_callee or _events(target.document) & PR_EVENTS:
continue
gate = classify_if(job.get("if"))
if gate == Gate.RELEASE_ONLY and "pull_request_target" not in events:
continue
scanned_as_callee.add(target.path)
findings.extend(_job_findings(target, events & PR_EVENTS, gate))
for wf in workflows:
events = _events(wf.document)
if wf.path in scanned_as_callee:
continue
findings.extend(_job_findings(wf, events, Gate.NONE))
return findings
98 changes: 98 additions & 0 deletions ci_lint/tests/test_rel_006.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""REL-006 static clauses (#462): macOS signing secrets never reach PR jobs."""

from __future__ import annotations

import unittest

from ci_lint.finding import Status
from ci_lint.rules.macos_signing import check_rel_006
from ci_lint.tests.test_complexity import _Repo

RELEASE_JOB = """\
sign:
{if_line}
runs-on: macos-latest
{env_line}
steps:
- uses: zackees/ci.yml/actions/app-signing-macos@0123456789abcdef0123456789abcdef01234567
with:
identity: ${{{{ secrets.MACOS_SIGNING_IDENTITY }}}}
"""


def _wf(on: str, if_line: str = "", env_line: str = "environment: release") -> str:
return f"on: {on}\njobs:\n" + RELEASE_JOB.format(if_line=if_line, env_line=env_line)


def _run(text: str, extra: dict[str, str] | None = None):
files = {".github/workflows/ci.yml": text, **(extra or {})}
with _Repo(files) as root:
return check_rel_006(root)


class Rel006Test(unittest.TestCase):
def test_green_tag_confined_in_pr_workflow(self) -> None:
self.assertEqual([], _run(_wf("[pull_request, push]", "if: startsWith(github.ref, 'refs/tags/v')")))

def test_green_release_only_workflow(self) -> None:
self.assertEqual([], _run(_wf("{push: {tags: ['v*']}}")))

def test_green_dispatch_or_release_if(self) -> None:
cond = "if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'"
self.assertEqual([], _run(_wf("[merge_group, release, workflow_dispatch]", cond)))

def test_unconfined_pr_job_is_violation(self) -> None:
findings = _run(_wf("[pull_request]"))
self.assertEqual(1, len(findings))
self.assertEqual(Status.VIOLATION, findings[0].status)
self.assertEqual("REL-006", findings[0].rule)

def test_merge_group_unconfined_is_violation(self) -> None:
self.assertEqual([Status.VIOLATION], [f.status for f in _run(_wf("[merge_group]"))])

def test_pull_request_target_is_violation_regardless(self) -> None:
findings = _run(_wf("[pull_request_target]", "if: startsWith(github.ref, 'refs/tags/v')"))
self.assertEqual([Status.VIOLATION], [f.status for f in findings])
self.assertIn("pull_request_target", findings[0].message)

def test_pr_exclusion_only_is_needs_review(self) -> None:
findings = _run(_wf("[pull_request, push]", "if: github.event_name != 'pull_request'"))
self.assertEqual([Status.NEEDS_REVIEW], [f.status for f in findings])

def test_bare_secret_reference_without_action(self) -> None:
text = (
"on: pull_request\njobs:\n build:\n runs-on: macos-latest\n environment: release\n"
" steps:\n - run: echo \"$K\"\n env:\n K: ${{ secrets.MACOS_NOTARY_API_KEY }}\n"
)
findings = _run(text)
self.assertEqual(1, len(findings))
self.assertIn("MACOS_NOTARY_API_KEY", findings[0].message)

def test_workflow_level_env_counts_for_every_job(self) -> None:
text = (
"on: pull_request\nenv:\n P: ${{ secrets.MACOS_NOTARIZATION_PASSWORD }}\njobs:\n"
" a:\n runs-on: ubuntu-latest\n environment: release\n steps:\n - run: true\n"
)
self.assertEqual(1, len(_run(text)))

def test_unrelated_secrets_and_jobs_pass(self) -> None:
text = "on: pull_request\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - run: echo ${{ secrets.GITHUB_TOKEN }}\n"
self.assertEqual([], _run(text))

def test_missing_environment_is_needs_review(self) -> None:
findings = _run(_wf("{push: {tags: ['v*']}}", env_line=""))
self.assertEqual([Status.NEEDS_REVIEW], [f.status for f in findings])
self.assertIn("environment", findings[0].message)

def test_local_reusable_called_from_pr_workflow(self) -> None:
caller = "on: pull_request\njobs:\n rel:\n uses: ./.github/workflows/sign.yml\n"
callee = _wf("workflow_call")
findings = _run(caller, {".github/workflows/sign.yml": callee})
self.assertEqual([Status.VIOLATION], [f.status for f in findings])
self.assertEqual(".github/workflows/sign.yml", findings[0].path)
confined = caller.replace(" uses:", " if: github.ref_type == 'tag'\n uses:")
self.assertEqual([], _run(confined, {".github/workflows/sign.yml": callee}))


if __name__ == "__main__":
unittest.main()
Loading