diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 70ab604e..8a190f71 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -23,6 +23,30 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + fetch-depth: 0 + + - name: Scan every proposed commit for secrets + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + scanner=scripts/scan_committed_secrets.py + if [[ "$EVENT_NAME" == pull_request ]] && git cat-file -e "$BASE_SHA:$scanner" 2>/dev/null; then + git show "$BASE_SHA:$scanner" >"$RUNNER_TEMP/trusted-secret-scanner.py" + scanner="$RUNNER_TEMP/trusted-secret-scanner.py" + fi + if [[ -z "$BASE_SHA" || "$BASE_SHA" =~ ^0+$ ]]; then + mapfile -t commits < <(git rev-list --reverse "$HEAD_SHA") + else + mapfile -t commits < <(git rev-list --reverse "$BASE_SHA..$HEAD_SHA") + fi + if [[ ! " ${commits[*]} " == *" $HEAD_SHA "* ]]; then + commits+=("$HEAD_SHA") + fi + for commit in "${commits[@]}"; do + python3 "$scanner" --repository "$GITHUB_WORKSPACE" --commit "$commit" + done - name: Install static analysis tools run: sudo apt-get update && sudo apt-get install -y shellcheck diff --git a/controller/Dockerfile b/controller/Dockerfile index a2c17990..1c5b92c1 100644 --- a/controller/Dockerfile +++ b/controller/Dockerfile @@ -3,6 +3,7 @@ FROM golang:1.26.5-bookworm AS build WORKDIR /src COPY go.mod *.go ./ RUN go mod tidy && go mod verify +RUN go test ./... ARG CI_FLEET_VERSION=dev ARG CI_FLEET_COMMIT=unknown RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${CI_FLEET_VERSION} -X main.commitSHA=${CI_FLEET_COMMIT}" -o /out/ci-fleet-controller . diff --git a/controller/admin.go b/controller/admin.go new file mode 100644 index 00000000..2c1df1ff --- /dev/null +++ b/controller/admin.go @@ -0,0 +1,76 @@ +package main + +import ( + "context" + "fmt" + + "github.com/actions/scaleset" +) + +type scaleSetAdmin interface { + GetRunnerGroupByName(context.Context, string) (*scaleset.RunnerGroup, error) + GetRunnerScaleSet(context.Context, int, string) (*scaleset.RunnerScaleSet, error) + GetRunnerScaleSetByID(context.Context, int) (*scaleset.RunnerScaleSet, error) + DeleteRunnerScaleSet(context.Context, int) error +} + +func removeIdleScaleSet(ctx context.Context, cfg Config, client scaleSetAdmin) (bool, error) { + runnerGroupID := 1 + if cfg.RunnerGroup != scaleset.DefaultRunnerGroup { + group, err := client.GetRunnerGroupByName(ctx, cfg.RunnerGroup) + if err != nil { + return false, fmt.Errorf("find runner group: %w", err) + } + runnerGroupID = group.ID + } + set, err := client.GetRunnerScaleSet(ctx, runnerGroupID, cfg.ScaleSetName) + if err != nil { + return false, fmt.Errorf("find runner scale set: %w", err) + } + if set == nil { + return false, nil + } + if set.Name != cfg.ScaleSetName || set.RunnerGroupID != runnerGroupID { + return false, fmt.Errorf("runner scale set identity does not match the selected configuration") + } + if set.Statistics == nil { + set, err = client.GetRunnerScaleSetByID(ctx, set.ID) + if err != nil { + return false, fmt.Errorf("read runner scale set statistics: %w", err) + } + if set == nil || set.Name != cfg.ScaleSetName || set.RunnerGroupID != runnerGroupID { + return false, fmt.Errorf("runner scale set identity changed while checking statistics") + } + } + if set.Statistics == nil { + return false, fmt.Errorf("runner scale set has no idle-state statistics") + } + if *set.Statistics != (scaleset.RunnerScaleSetStatistic{}) { + return false, fmt.Errorf("runner scale set is not idle") + } + if err := client.DeleteRunnerScaleSet(ctx, set.ID); err != nil { + return false, fmt.Errorf("delete idle runner scale set: %w", err) + } + return true, nil +} + +func deleteIdleScaleSet(ctx context.Context) error { + cfg, err := configFromEnv() + if err != nil { + return fmt.Errorf("configuration: %w", err) + } + client, err := cfg.scaleSetClient() + if err != nil { + return fmt.Errorf("create scale-set client: %w", err) + } + deleted, err := removeIdleScaleSet(ctx, cfg, client) + if err != nil { + return err + } + if deleted { + fmt.Printf("deleted idle runner scale set %q\n", cfg.ScaleSetName) + } else { + fmt.Printf("runner scale set %q is already absent\n", cfg.ScaleSetName) + } + return nil +} diff --git a/controller/admin_test.go b/controller/admin_test.go new file mode 100644 index 00000000..f68da0fd --- /dev/null +++ b/controller/admin_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + "testing" + + "github.com/actions/scaleset" +) + +type fakeScaleSetAdmin struct { + group *scaleset.RunnerGroup + set *scaleset.RunnerScaleSet + detail *scaleset.RunnerScaleSet + deleted bool +} + +func (f *fakeScaleSetAdmin) GetRunnerGroupByName(context.Context, string) (*scaleset.RunnerGroup, error) { + return f.group, nil +} + +func (f *fakeScaleSetAdmin) GetRunnerScaleSet(context.Context, int, string) (*scaleset.RunnerScaleSet, error) { + return f.set, nil +} + +func (f *fakeScaleSetAdmin) GetRunnerScaleSetByID(context.Context, int) (*scaleset.RunnerScaleSet, error) { + return f.detail, nil +} + +func (f *fakeScaleSetAdmin) DeleteRunnerScaleSet(context.Context, int) error { + f.deleted = true + return nil +} + +func TestRemoveIdleScaleSet(t *testing.T) { + cfg := Config{RunnerGroup: "trusted-private-ci", ScaleSetName: "docker-ci-example"} + idle := scaleset.RunnerScaleSetStatistic{} + newAdmin := func(statistics *scaleset.RunnerScaleSetStatistic) *fakeScaleSetAdmin { + return &fakeScaleSetAdmin{ + group: &scaleset.RunnerGroup{ID: 7, Name: cfg.RunnerGroup}, + set: &scaleset.RunnerScaleSet{ID: 42, Name: cfg.ScaleSetName, RunnerGroupID: 7, Statistics: statistics}, + } + } + + admin := newAdmin(&idle) + deleted, err := removeIdleScaleSet(context.Background(), cfg, admin) + if err != nil || !deleted || !admin.deleted { + t.Fatalf("idle scale set was not deleted: deleted=%v called=%v err=%v", deleted, admin.deleted, err) + } + + absent := newAdmin(&idle) + absent.set = nil + deleted, err = removeIdleScaleSet(context.Background(), cfg, absent) + if err != nil || deleted || absent.deleted { + t.Fatalf("absent scale set should be a no-op: deleted=%v called=%v err=%v", deleted, absent.deleted, err) + } + + withoutStatistics := newAdmin(nil) + withoutStatistics.detail = &scaleset.RunnerScaleSet{ID: 42, Name: cfg.ScaleSetName, RunnerGroupID: 7, Statistics: &idle} + deleted, err = removeIdleScaleSet(context.Background(), cfg, withoutStatistics) + if err != nil || !deleted || !withoutStatistics.deleted { + t.Fatalf("detailed idle statistics were not used: deleted=%v called=%v err=%v", deleted, withoutStatistics.deleted, err) + } + + busy := map[string]scaleset.RunnerScaleSetStatistic{ + "available_jobs": {TotalAvailableJobs: 1}, + "acquired_jobs": {TotalAcquiredJobs: 1}, + "assigned_jobs": {TotalAssignedJobs: 1}, + "running_jobs": {TotalRunningJobs: 1}, + "registered_runners": {TotalRegisteredRunners: 1}, + "busy_runners": {TotalBusyRunners: 1}, + "idle_runners": {TotalIdleRunners: 1}, + } + for name, statistics := range busy { + t.Run(name, func(t *testing.T) { + admin := newAdmin(&statistics) + deleted, err := removeIdleScaleSet(context.Background(), cfg, admin) + if err == nil || deleted || admin.deleted { + t.Fatalf("non-idle scale set was deletable: deleted=%v called=%v err=%v", deleted, admin.deleted, err) + } + }) + } +} diff --git a/controller/main.go b/controller/main.go index 2d2f0a74..0d93d1d4 100644 --- a/controller/main.go +++ b/controller/main.go @@ -24,7 +24,16 @@ var ( func main() { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() - if err := run(ctx); err != nil { + var err error + switch { + case len(os.Args) == 1: + err = run(ctx) + case len(os.Args) == 2 && os.Args[1] == "--delete-idle-scale-set": + err = deleteIdleScaleSet(ctx) + default: + err = fmt.Errorf("unsupported arguments") + } + if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } diff --git a/docs/DEPLOYMENT-PROTOTYPE.md b/docs/DEPLOYMENT-PROTOTYPE.md index cf235eaa..41fa2bfb 100644 --- a/docs/DEPLOYMENT-PROTOTYPE.md +++ b/docs/DEPLOYMENT-PROTOTYPE.md @@ -62,3 +62,14 @@ CI_FLEET_INSTANCE=HOST-ID scripts/cleanup.sh ``` Inspect the dry-run. After confirming there is no active job, rerun with `--apply`. Do not delete unrelated Docker resources. Removal of the GitHub-side scale set is normally performed by the controller during graceful shutdown; verify it in GitHub before deleting the App installation. + +### Recover an orphaned idle scale set + +An unclean controller exit can leave its exact GitHub-side scale-set name behind and make the replacement fail with `already exists`. Use the controller's `--delete-idle-scale-set` administrative mode only after all of these are true: + +- the selected scale-set name and runner group come from trusted installed configuration; +- no repository workflow targeting its label is queued or running; +- local managed runner count and project-container residue are both zero; +- the administrative controller image was built from a reviewed public commit. + +The command looks up only the configured runner group and exact scale-set name. It refuses deletion unless GitHub reports zero available, acquired, assigned, and running jobs and zero registered, busy, and idle runners. It prints no scale-set ID or credential data. After deletion, restart the previous controller first; recreating the same empty scale set and passing healthcheck is the rollback/recovery proof before desired-state adoption continues. diff --git a/scripts/scan_committed_secrets.py b/scripts/scan_committed_secrets.py index cd191118..e7bf24aa 100644 --- a/scripts/scan_committed_secrets.py +++ b/scripts/scan_committed_secrets.py @@ -4,7 +4,9 @@ from __future__ import annotations import argparse +import os import re +import stat import subprocess import sys from pathlib import Path @@ -26,42 +28,79 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--repository", type=Path, default=ROOT) - parser.add_argument("--commit", help="immutable commit whose blobs should be scanned") + revision = parser.add_mutually_exclusive_group() + revision.add_argument("--commit", help="immutable commit whose blobs should be scanned") + revision.add_argument("--commit-range", help="immutable base..head range whose commit trees should be scanned") return parser.parse_args() def main() -> int: args = parse_args() repository = args.repository.resolve() + git_root = Path( + subprocess.run( + ["git", "-C", str(repository), "rev-parse", "--show-toplevel"], + check=True, + stdout=subprocess.PIPE, + text=True, + ).stdout.strip() + ).resolve() + git_prefix = repository.relative_to(git_root).as_posix() if args.commit is not None and not re.fullmatch(r"[0-9a-f]{40}", args.commit): print("--commit must be a full lowercase commit SHA", file=sys.stderr) return 2 - listing = ["git", "-C", str(repository)] - listing += ["ls-tree", "-rz", "--name-only", args.commit] if args.commit else ["ls-files", "-z"] - tracked = subprocess.run( - listing, - check=True, - stdout=subprocess.PIPE, - ).stdout.split(b"\0") - findings: list[str] = [] - for raw in tracked: - if not raw: - continue - relative = raw.decode("utf-8") - if args.commit is None: - data = (repository / relative).read_bytes() - else: + if args.commit_range is not None and not re.fullmatch(r"[0-9a-f]{40}(?:\.\.[0-9a-f]{40})?", args.commit_range): + print("--commit-range must be a full lowercase head SHA or two full SHAs separated by ..", file=sys.stderr) + return 2 + commits: list[str | None] = [args.commit] + if args.commit_range: + commits = subprocess.run( + ["git", "-C", str(repository), "rev-list", "--reverse", args.commit_range], + check=True, + stdout=subprocess.PIPE, + text=True, + ).stdout.splitlines() + selected_head = args.commit_range.rsplit("..", 1)[-1] + if selected_head not in commits: + commits.append(selected_head) + findings: set[str] = set() + for commit in commits: + listing = ["git", "-C", str(repository)] + listing += ["ls-tree", "-rz", "--name-only", commit] if commit else ["ls-files", "-z"] + tracked = subprocess.run(listing, check=True, stdout=subprocess.PIPE).stdout.split(b"\0") + for raw in tracked: + if not raw: + continue + relative = raw.decode("utf-8") + object_path = relative if git_prefix == "." else f"{git_prefix}/{relative}" + revision = f"{commit}:{object_path}" if commit else f":{object_path}" data = subprocess.run( - ["git", "-C", str(repository), "cat-file", "blob", f"{args.commit}:{relative}"], + ["git", "-C", str(repository), "cat-file", "blob", revision], check=True, stdout=subprocess.PIPE, ).stdout - for match in PATTERN.finditer(data): - line = data.count(b"\n", 0, match.start()) + 1 - findings.append(f"{relative}:{line}") + for match in PATTERN.finditer(data): + line = data.count(b"\n", 0, match.start()) + 1 + prefix = f"{commit}:" if args.commit_range else "" + findings.add(f"{prefix}{relative}:{line}") + if commit is None: + working_path = repository / relative + try: + metadata = working_path.lstat() + except FileNotFoundError: + continue + if stat.S_ISLNK(metadata.st_mode): + working_data = os.readlink(working_path).encode("utf-8") + elif stat.S_ISREG(metadata.st_mode): + working_data = working_path.read_bytes() + else: + continue + for match in PATTERN.finditer(working_data): + line = working_data.count(b"\n", 0, match.start()) + 1 + findings.add(f"{relative}:{line}") if findings: print("possible committed secret detected:", file=sys.stderr) - print("\n".join(findings), file=sys.stderr) + print("\n".join(sorted(findings)), file=sys.stderr) return 1 print("OK: no high-confidence secret material in tracked files") return 0 diff --git a/templates/config-repository/.github/workflows/validate.yml b/templates/config-repository/.github/workflows/validate.yml index 27e1fd1d..fe492185 100644 --- a/templates/config-repository/.github/workflows/validate.yml +++ b/templates/config-repository/.github/workflows/validate.yml @@ -22,6 +22,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false + fetch-depth: 0 - name: Validate shell and Python syntax run: | @@ -32,7 +33,27 @@ jobs: run: python3 scripts/test_policy.py - name: Scan committed file contents for secrets - run: python3 scripts/scan_committed_secrets.py + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + scanner=scripts/scan_committed_secrets.py + if [[ "$EVENT_NAME" == pull_request ]] && git cat-file -e "$BASE_SHA:$scanner" 2>/dev/null; then + git show "$BASE_SHA:$scanner" >"$RUNNER_TEMP/trusted-secret-scanner.py" + scanner="$RUNNER_TEMP/trusted-secret-scanner.py" + fi + if [[ -z "$BASE_SHA" || "$BASE_SHA" =~ ^0+$ ]]; then + mapfile -t commits < <(git rev-list --reverse "$HEAD_SHA") + else + mapfile -t commits < <(git rev-list --reverse "$BASE_SHA..$HEAD_SHA") + fi + if [[ ! " ${commits[*]} " == *" $HEAD_SHA "* ]]; then + commits+=("$HEAD_SHA") + fi + for commit in "${commits[@]}"; do + python3 "$scanner" --repository "$GITHUB_WORKSPACE" --commit "$commit" + done - name: Validate reference configurations run: | diff --git a/templates/config-repository/AGENTS.md b/templates/config-repository/AGENTS.md index c6d46a8a..1332cfbf 100644 --- a/templates/config-repository/AGENTS.md +++ b/templates/config-repository/AGENTS.md @@ -18,6 +18,7 @@ Before committing configuration changes, run: - Never add addresses, VM IDs, storage identifiers, backup identifiers, SSH details, or rendered runtime configuration. - Do not weaken `public_repositories: false` for Docker-socket runner pools. - Infrastructure configuration owns capacity. Application workflows submit all independent jobs and do not use `max-parallel` to model fleet size. +- Each GitHub runner group belongs to exactly one runner pool; do not create ambiguous cross-pool assignments. - The sum of active and drained controller maxima must not exceed the pool capacity budget. - Controller engine revisions and reusable workflows must be pinned to full reviewed commit SHAs. - Production environments must require approval and must not deploy automatically. diff --git a/templates/config-repository/README.md b/templates/config-repository/README.md index 6b3e90d6..5476d06a 100644 --- a/templates/config-repository/README.md +++ b/templates/config-repository/README.md @@ -66,7 +66,9 @@ The controller ID is how a target host selects its declaration. A location is a ### Pool capacity is infrastructure policy -Each runner pool has a `capacity_budget`. The validator totals the maximum capacity of every active or drained controller assigned to the pool and rejects overcommit. Drained capacity remains reserved so an undrain cannot silently exceed the reviewed budget. Disabled controllers do not reserve capacity. +Each runner pool has a `capacity_budget` and a runner group that must not be assigned to any other pool. Unique runner-group assignment keeps routing and repository authorization unambiguous. The semantic validator enforces this cross-object rule because JSON Schema cannot compare values stored in separate object properties. + +The validator totals the maximum capacity of every active or drained controller assigned to the pool and rejects overcommit. Drained capacity remains reserved so an undrain cannot silently exceed the reviewed budget. Disabled controllers do not reserve capacity. Application repositories do not encode the number of available workers. They submit all independent tasks and shards. Do not use GitHub Actions `strategy.max-parallel` to model fleet size; controllers and the private configuration decide how many jobs run simultaneously. An application may limit concurrency only for a separately documented external-system constraint, not worker availability. @@ -110,6 +112,7 @@ Deleting one generic controller must not require application workflow changes. L - `./scripts/ci/run.sh fast` and `full` remain aggregate developer commands; fleet scheduling expands their named tasks across available workers. - Every matrix job has a five-minute hard timeout, while expected test payload targets four minutes or less to reserve startup and reporting time. - Application workflows submit all independent jobs; infrastructure configuration alone controls worker capacity. +- A GitHub runner group is assigned to exactly one runner pool. - CI runner pools and deployment host groups are separate trust roles. - Production deployment is manual and requires GitHub Environment approval. - Controller engine revisions, reusable workflows, and third-party actions are pinned to immutable commits. diff --git a/templates/config-repository/fleet.schema.json b/templates/config-repository/fleet.schema.json index a8f0716c..606536ad 100644 --- a/templates/config-repository/fleet.schema.json +++ b/templates/config-repository/fleet.schema.json @@ -21,6 +21,7 @@ }, "runner_pools": { "type": "object", + "description": "Logical CI pools. Each runner_group value must be unique across pools; scripts/validate.py enforces this cross-object rule.", "minProperties": 1, "propertyNames": {"$ref": "#/$defs/slug"}, "additionalProperties": {"$ref": "#/$defs/runner_pool"} diff --git a/templates/config-repository/scripts/scan_committed_secrets.py b/templates/config-repository/scripts/scan_committed_secrets.py index cd191118..e7bf24aa 100755 --- a/templates/config-repository/scripts/scan_committed_secrets.py +++ b/templates/config-repository/scripts/scan_committed_secrets.py @@ -4,7 +4,9 @@ from __future__ import annotations import argparse +import os import re +import stat import subprocess import sys from pathlib import Path @@ -26,42 +28,79 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--repository", type=Path, default=ROOT) - parser.add_argument("--commit", help="immutable commit whose blobs should be scanned") + revision = parser.add_mutually_exclusive_group() + revision.add_argument("--commit", help="immutable commit whose blobs should be scanned") + revision.add_argument("--commit-range", help="immutable base..head range whose commit trees should be scanned") return parser.parse_args() def main() -> int: args = parse_args() repository = args.repository.resolve() + git_root = Path( + subprocess.run( + ["git", "-C", str(repository), "rev-parse", "--show-toplevel"], + check=True, + stdout=subprocess.PIPE, + text=True, + ).stdout.strip() + ).resolve() + git_prefix = repository.relative_to(git_root).as_posix() if args.commit is not None and not re.fullmatch(r"[0-9a-f]{40}", args.commit): print("--commit must be a full lowercase commit SHA", file=sys.stderr) return 2 - listing = ["git", "-C", str(repository)] - listing += ["ls-tree", "-rz", "--name-only", args.commit] if args.commit else ["ls-files", "-z"] - tracked = subprocess.run( - listing, - check=True, - stdout=subprocess.PIPE, - ).stdout.split(b"\0") - findings: list[str] = [] - for raw in tracked: - if not raw: - continue - relative = raw.decode("utf-8") - if args.commit is None: - data = (repository / relative).read_bytes() - else: + if args.commit_range is not None and not re.fullmatch(r"[0-9a-f]{40}(?:\.\.[0-9a-f]{40})?", args.commit_range): + print("--commit-range must be a full lowercase head SHA or two full SHAs separated by ..", file=sys.stderr) + return 2 + commits: list[str | None] = [args.commit] + if args.commit_range: + commits = subprocess.run( + ["git", "-C", str(repository), "rev-list", "--reverse", args.commit_range], + check=True, + stdout=subprocess.PIPE, + text=True, + ).stdout.splitlines() + selected_head = args.commit_range.rsplit("..", 1)[-1] + if selected_head not in commits: + commits.append(selected_head) + findings: set[str] = set() + for commit in commits: + listing = ["git", "-C", str(repository)] + listing += ["ls-tree", "-rz", "--name-only", commit] if commit else ["ls-files", "-z"] + tracked = subprocess.run(listing, check=True, stdout=subprocess.PIPE).stdout.split(b"\0") + for raw in tracked: + if not raw: + continue + relative = raw.decode("utf-8") + object_path = relative if git_prefix == "." else f"{git_prefix}/{relative}" + revision = f"{commit}:{object_path}" if commit else f":{object_path}" data = subprocess.run( - ["git", "-C", str(repository), "cat-file", "blob", f"{args.commit}:{relative}"], + ["git", "-C", str(repository), "cat-file", "blob", revision], check=True, stdout=subprocess.PIPE, ).stdout - for match in PATTERN.finditer(data): - line = data.count(b"\n", 0, match.start()) + 1 - findings.append(f"{relative}:{line}") + for match in PATTERN.finditer(data): + line = data.count(b"\n", 0, match.start()) + 1 + prefix = f"{commit}:" if args.commit_range else "" + findings.add(f"{prefix}{relative}:{line}") + if commit is None: + working_path = repository / relative + try: + metadata = working_path.lstat() + except FileNotFoundError: + continue + if stat.S_ISLNK(metadata.st_mode): + working_data = os.readlink(working_path).encode("utf-8") + elif stat.S_ISREG(metadata.st_mode): + working_data = working_path.read_bytes() + else: + continue + for match in PATTERN.finditer(working_data): + line = working_data.count(b"\n", 0, match.start()) + 1 + findings.add(f"{relative}:{line}") if findings: print("possible committed secret detected:", file=sys.stderr) - print("\n".join(findings), file=sys.stderr) + print("\n".join(sorted(findings)), file=sys.stderr) return 1 print("OK: no high-confidence secret material in tracked files") return 0 diff --git a/templates/config-repository/scripts/test_policy.py b/templates/config-repository/scripts/test_policy.py index 7dc83468..e5e47ca6 100755 --- a/templates/config-repository/scripts/test_policy.py +++ b/templates/config-repository/scripts/test_policy.py @@ -85,6 +85,13 @@ def test_public_repository_access_is_rejected(self) -> None: config["runner_pools"]["trusted-ci"]["public_repositories"] = True self.assert_rejected(config, "trusted private repositories") + def test_duplicate_runner_group_is_rejected(self) -> None: + config = copy.deepcopy(reference_config()) + duplicate = copy.deepcopy(config["runner_pools"]["trusted-ci"]) + duplicate["routing_labels"] = ["other-ci"] + config["runner_pools"]["other-ci"] = duplicate + self.assert_rejected(config, "runner_group: must be unique") + def test_capacity_overcommit_is_rejected(self) -> None: config = copy.deepcopy(reference_config()) overcommit = config["runner_pools"]["trusted-ci"]["capacity_budget"] + 1 @@ -223,7 +230,7 @@ def test_template_ci_scans_committed_file_contents(self) -> None: scanner = ROOT / "scripts" / "scan_committed_secrets.py" self.assertTrue(scanner.is_file()) workflow = (ROOT / ".github" / "workflows" / "validate.yml").read_text(encoding="utf-8") - self.assertIn("python3 scripts/scan_committed_secrets.py", workflow) + self.assertIn('python3 "$scanner" --repository "$GITHUB_WORKSPACE" --commit "$commit"', workflow) with tempfile.TemporaryDirectory() as directory: repository = Path(directory) subprocess.run(["git", "init", "-q", str(repository)], check=True) @@ -242,6 +249,103 @@ def test_template_ci_scans_committed_file_contents(self) -> None: self.assertIn("scripts/scan_committed_secrets.py:1", result.stderr) self.assertIn("scripts/validate.sh:1", result.stderr) + def test_committed_secret_scanner_reads_symlink_blobs(self) -> None: + scanner = ROOT / "scripts" / "scan_committed_secrets.py" + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + subprocess.run(["git", "init", "-q", str(repository)], check=True) + target = "ghp_" + "x" * 20 + (repository / target).write_text("clean\n", encoding="utf-8") + (repository / "secret-link").symlink_to(target) + subprocess.run(["git", "-C", str(repository), "add", "."], check=True) + result = subprocess.run( + [sys.executable, str(scanner), "--repository", str(repository)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("secret-link:1", result.stderr) + + def test_committed_secret_scanner_reads_every_commit_in_range(self) -> None: + scanner = ROOT / "scripts" / "scan_committed_secrets.py" + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + subprocess.run(["git", "init", "-q", str(repository)], check=True) + subprocess.run(["git", "-C", str(repository), "config", "user.name", "Policy Test"], check=True) + subprocess.run(["git", "-C", str(repository), "config", "user.email", "policy@example.invalid"], check=True) + (repository / "README.md").write_text("clean\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repository), "add", "."], check=True) + subprocess.run(["git", "-C", str(repository), "commit", "-qm", "base"], check=True) + base = subprocess.check_output(["git", "-C", str(repository), "rev-parse", "HEAD"], text=True).strip() + leak = repository / "temporary-leak.txt" + leak.write_text("ghp_" + "x" * 20 + "\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repository), "add", "."], check=True) + subprocess.run(["git", "-C", str(repository), "commit", "-qm", "add leak"], check=True) + leaked = subprocess.check_output(["git", "-C", str(repository), "rev-parse", "HEAD"], text=True).strip() + leak.unlink() + subprocess.run(["git", "-C", str(repository), "add", "-u"], check=True) + subprocess.run(["git", "-C", str(repository), "commit", "-qm", "remove leak"], check=True) + head = subprocess.check_output(["git", "-C", str(repository), "rev-parse", "HEAD"], text=True).strip() + for revision in (f"{base}..{head}", head, f"{head}..{leaked}"): + with self.subTest(revision=revision): + result = subprocess.run( + [sys.executable, str(scanner), "--repository", str(repository), "--commit-range", revision], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("temporary-leak.txt:1", result.stderr) + + def test_committed_secret_scanner_reads_nested_repository_prefix(self) -> None: + scanner = ROOT / "scripts" / "scan_committed_secrets.py" + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + nested = repository / "config" + nested.mkdir() + subprocess.run(["git", "init", "-q", str(repository)], check=True) + (nested / "fleet.json").write_text("ghp_" + "x" * 20 + "\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repository), "add", "."], check=True) + result = subprocess.run( + [sys.executable, str(scanner), "--repository", str(nested)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("fleet.json:1", result.stderr) + + def test_committed_secret_scanner_reads_unstaged_tracked_edits(self) -> None: + scanner = ROOT / "scripts" / "scan_committed_secrets.py" + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + subprocess.run(["git", "init", "-q", str(repository)], check=True) + tracked = repository / "fleet.json" + tracked.write_text("clean\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repository), "add", "."], check=True) + tracked.write_text("ghp_" + "x" * 20 + "\n", encoding="utf-8") + result = subprocess.run( + [sys.executable, str(scanner), "--repository", str(repository)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("fleet.json:1", result.stderr) + + def test_workflow_uses_trusted_scanner_for_complete_history(self) -> None: + workflow = (ROOT / ".github" / "workflows" / "validate.yml").read_text(encoding="utf-8") + for required in ( + "fetch-depth: 0", + 'git show "$BASE_SHA:$scanner"', + 'git rev-list --reverse "$HEAD_SHA"', + 'git rev-list --reverse "$BASE_SHA..$HEAD_SHA"', + 'commits+=("$HEAD_SHA")', + '--commit "$commit"', + ): + self.assertIn(required, workflow) + def test_duplicate_json_controller_id_is_rejected(self) -> None: validation = Validation() with tempfile.TemporaryDirectory() as directory: diff --git a/templates/config-repository/scripts/validate.py b/templates/config-repository/scripts/validate.py index 5b70b92f..77cd706f 100755 --- a/templates/config-repository/scripts/validate.py +++ b/templates/config-repository/scripts/validate.py @@ -207,6 +207,7 @@ def validate_config(config: Any, validation: Validation, strict: bool) -> None: validation.errors.append("$.runner_pools: must be a non-empty object") pools = {} pool_capacity: dict[str, int] = {} + runner_groups: dict[str, str] = {} for name, pool in pools.items(): path = f"$.runner_pools.{name}" validation.require(isinstance(name, str) and bool(SLUG.fullmatch(name)), path, "pool name must be a lowercase slug") @@ -224,6 +225,11 @@ def validate_config(config: Any, validation: Validation, strict: bool) -> None: labels = pool.get("routing_labels") repos = pool.get("allowed_repositories") validation.require(isinstance(runner_group, str) and bool(SLUG.fullmatch(runner_group)), f"{path}.runner_group", "must be a lowercase logical runner-group slug") + if isinstance(runner_group, str) and SLUG.fullmatch(runner_group): + if runner_group in runner_groups: + validation.errors.append(f"{path}.runner_group: must be unique; also used by {runner_groups[runner_group]}") + else: + runner_groups[runner_group] = name validation.require(isinstance(labels, list) and bool(labels), f"{path}.routing_labels", "must be a non-empty list") if isinstance(labels, list): validation.require(len(labels) == len(set(labels)), f"{path}.routing_labels", "must contain unique labels")