From 4a81ba0f029309e8755c2d1746aec83a2695ed41 Mon Sep 17 00:00:00 2001 From: lex00 Date: Fri, 19 Jun 2026 09:05:41 -0600 Subject: [PATCH] feat(cycle): security-feature enforcement cycle (#13) Reconciles repo security features: advanced security / secret scanning / push protection via security_and_analysis PATCH, plus Dependabot vulnerability-alerts and automated-security-fixes via their dedicated endpoints. New RepoSecurityConfig + LiveRepoSecurity + repo-security diff type. License-gated writes surface as reported failed entries, not crashes. Registered, exported, action bundle rebuilt. Co-Authored-By: Claude Opus 4.8 --- action/index.mjs | 137 ++++++++++- src/cli/registry.ts | 2 + src/config/types.ts | 34 +++ src/cycles/security-features.test.ts | 332 +++++++++++++++++++++++++++ src/cycles/security-features.ts | 237 +++++++++++++++++++ src/index.ts | 4 + src/reconcile/diff.ts | 50 ++++ 7 files changed, 795 insertions(+), 1 deletion(-) create mode 100644 src/cycles/security-features.test.ts create mode 100644 src/cycles/security-features.ts diff --git a/action/index.mjs b/action/index.mjs index e94fbde..4b707b2 100644 --- a/action/index.mjs +++ b/action/index.mjs @@ -487,6 +487,7 @@ function diffRepos(desired, live, opts, out) { } diffBranchProtection(name, dr.branchProtection, lr.branchProtection ?? [], opts, out); diffRulesets(`${name}/`, "repo-ruleset", dr.rulesets, lr.rulesets ?? [], opts, out); + diffRepoSecurity(name, dr.security, lr.security, out); } for (const name of Object.keys(live)) { if (!Object.prototype.hasOwnProperty.call(desired, name)) { @@ -584,6 +585,24 @@ function diffRulesets(keyPrefix, resourceType, desired, live, opts, out) { } } } +function diffRepoSecurity(repoName, desired, live, out) { + if (desired === void 0) return; + if (live === void 0) { + out.push({ kind: "create", resourceType: "repo-security", key: repoName, after: desired }); + return; + } + const fields = diffObject(desired, live); + if (fields.length > 0) { + out.push({ + kind: "update", + resourceType: "repo-security", + key: repoName, + before: live, + after: desired, + fields + }); + } +} function diffObject(desired, live) { const fields = []; for (const key of Object.keys(desired)) { @@ -659,6 +678,7 @@ var init_diff = __esm({ "team-repo", "member", "repo", + "repo-security", "branch-protection", "repo-ruleset" ]; @@ -229948,6 +229968,120 @@ var rulesetsCycle = { } }; +// src/cycles/security-features.ts +function hasManagedSecurity(repo) { + return repo.security !== void 0; +} +function statusToBool(s) { + if (s == null || s.status == null) return void 0; + return s.status === "enabled"; +} +async function fetchRepoSecurity(client, org, repo, budget) { + const live = {}; + budget.use(1); + const repoData = await client.request("GET", `/repos/${org}/${repo}`); + const saa = repoData.security_and_analysis ?? {}; + const adv = statusToBool(saa.advanced_security); + const ss = statusToBool(saa.secret_scanning); + const ssp = statusToBool(saa.secret_scanning_push_protection); + if (adv !== void 0) live.advancedSecurity = adv; + if (ss !== void 0) live.secretScanning = ss; + if (ssp !== void 0) live.secretScanningPushProtection = ssp; + if (!budget.exhausted) { + budget.use(1); + try { + await client.request("GET", `/repos/${org}/${repo}/vulnerability-alerts`); + live.vulnerabilityAlerts = true; + } catch (err) { + if (err instanceof Error && err.message.includes("404")) { + live.vulnerabilityAlerts = false; + } else { + throw err; + } + } + } + if (!budget.exhausted) { + budget.use(1); + try { + const fixes = await client.request( + "GET", + `/repos/${org}/${repo}/automated-security-fixes` + ); + live.dependabotSecurityUpdates = fixes.enabled === true; + } catch (err) { + if (err instanceof Error && err.message.includes("404")) { + live.dependabotSecurityUpdates = false; + } else { + throw err; + } + } + } + return live; +} +function buildSecurityAnalysisBody(desired) { + const saa = {}; + if (desired.advancedSecurity !== void 0) { + saa.advanced_security = { status: desired.advancedSecurity ? "enabled" : "disabled" }; + } + if (desired.secretScanning !== void 0) { + saa.secret_scanning = { status: desired.secretScanning ? "enabled" : "disabled" }; + } + if (desired.secretScanningPushProtection !== void 0) { + saa.secret_scanning_push_protection = { + status: desired.secretScanningPushProtection ? "enabled" : "disabled" + }; + } + return saa; +} +var securityFeaturesCycle = { + name: "security-features", + // ── Part 2: fetchLive ────────────────────────────────────────────────────── + async fetchLive(client, orgLogin, scope, budget) { + if (budget.exhausted) { + const { BudgetExhaustedError: BudgetExhaustedError2 } = await Promise.resolve().then(() => (init_runner(), runner_exports)); + throw new BudgetExhaustedError2(); + } + const repos = {}; + for (const [name, repoConfig] of Object.entries(scope?.repos ?? {})) { + if (!hasManagedSecurity(repoConfig)) continue; + if (budget.exhausted) break; + repos[name] = { security: await fetchRepoSecurity(client, orgLogin, name, budget) }; + } + return { repos }; + }, + // ── Part 3: buildDesired ─────────────────────────────────────────────────── + buildDesired(orgConfig, _orgLogin, _scope) { + if (!orgConfig.repos) return {}; + const repos = {}; + for (const [name, repoConfig] of Object.entries(orgConfig.repos)) { + if (hasManagedSecurity(repoConfig)) repos[name] = { security: repoConfig.security }; + } + return { repos }; + }, + // ── Part 4: apply ────────────────────────────────────────────────────────── + async apply(client, entry, orgLogin, _scope, budget) { + if (entry.resourceType !== "repo-security") return; + if (entry.kind === "delete") return; + const repo = entry.key; + const desired = entry.after; + const saa = buildSecurityAnalysisBody(desired); + if (Object.keys(saa).length > 0) { + budget.use(1); + await client.request("PATCH", `/repos/${orgLogin}/${repo}`, { security_and_analysis: saa }); + } + if (desired.vulnerabilityAlerts !== void 0) { + budget.use(1); + const method = desired.vulnerabilityAlerts ? "PUT" : "DELETE"; + await client.request(method, `/repos/${orgLogin}/${repo}/vulnerability-alerts`); + } + if (desired.dependabotSecurityUpdates !== void 0) { + budget.use(1); + const method = desired.dependabotSecurityUpdates ? "PUT" : "DELETE"; + await client.request(method, `/repos/${orgLogin}/${repo}/automated-security-fixes`); + } + } +}; + // src/cli/registry.ts var CYCLE_REGISTRY = { [branchProtectionCycle.name]: branchProtectionCycle, @@ -229955,7 +230089,8 @@ var CYCLE_REGISTRY = { [repoSettingsCycle.name]: repoSettingsCycle, [membershipCycle.name]: membershipCycle, [teamsCycle.name]: teamsCycle, - [rulesetsCycle.name]: rulesetsCycle + [rulesetsCycle.name]: rulesetsCycle, + [securityFeaturesCycle.name]: securityFeaturesCycle }; // node_modules/@intentius/chant/src/audit/fetch.ts diff --git a/src/cli/registry.ts b/src/cli/registry.ts index b50d654..f310085 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -15,6 +15,7 @@ import { repoSettingsCycle } from "../cycles/repo-settings.js"; import { membershipCycle } from "../cycles/membership.js"; import { teamsCycle } from "../cycles/teams.js"; import { rulesetsCycle } from "../cycles/rulesets.js"; +import { securityFeaturesCycle } from "../cycles/security-features.js"; /** * Registry of all available governance cycles, keyed by the name accepted by @@ -30,4 +31,5 @@ export const CYCLE_REGISTRY: Record = { [membershipCycle.name]: membershipCycle, [teamsCycle.name]: teamsCycle, [rulesetsCycle.name]: rulesetsCycle, + [securityFeaturesCycle.name]: securityFeaturesCycle, }; diff --git a/src/config/types.ts b/src/config/types.ts index 384d131..dce3da6 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -100,6 +100,35 @@ export interface MemberConfig { role?: OrgMemberRole; } +// --------------------------------------------------------------------------- +// Repository security features +// --------------------------------------------------------------------------- + +/** + * Repository security-feature toggles. Absent fields are not managed. + * + * The first three map to the repo `security_and_analysis` object (set via + * `PATCH /repos/{o}/{r}`); the last two use dedicated endpoints + * (`vulnerability-alerts`, `automated-security-fixes`). + * + * License-gated note: GitHub Advanced Security features (`advancedSecurity`, + * and secret scanning on private repos) require a GHAS license. Where a feature + * is unavailable, GitHub rejects the enabling write; the cycle surfaces that as + * a reported failed entry rather than crashing the run (see cycle header). + */ +export interface RepoSecurityConfig { + /** GitHub Advanced Security (`security_and_analysis.advanced_security`). */ + advancedSecurity?: boolean; + /** Secret scanning (`security_and_analysis.secret_scanning`). */ + secretScanning?: boolean; + /** Secret scanning push protection (`security_and_analysis.secret_scanning_push_protection`). */ + secretScanningPushProtection?: boolean; + /** Dependabot vulnerability alerts (`vulnerability-alerts` endpoint). */ + vulnerabilityAlerts?: boolean; + /** Dependabot automated security fixes (`automated-security-fixes` endpoint). */ + dependabotSecurityUpdates?: boolean; +} + // --------------------------------------------------------------------------- // Rulesets (repo + org) // --------------------------------------------------------------------------- @@ -207,6 +236,11 @@ export interface RepoConfig { * Absent means repo rulesets are not managed by chant. */ rulesets?: RulesetConfig[]; + /** + * Repository security features (GHAS, secret scanning, Dependabot). + * Absent means security features are not managed by chant. + */ + security?: RepoSecurityConfig; } // --------------------------------------------------------------------------- diff --git a/src/cycles/security-features.test.ts b/src/cycles/security-features.test.ts new file mode 100644 index 0000000..398878e --- /dev/null +++ b/src/cycles/security-features.test.ts @@ -0,0 +1,332 @@ +/** + * Tests for the security-features cycle. + * + * All tests use a mock AppClient — no network calls. + * Coverage: + * - buildDesired: keeps repos with a security block, strips the rest + * - fetchRepoSecurity: security_and_analysis + the two Dependabot endpoints + * - buildSecurityAnalysisBody: declared flags → status objects + * - diff over the cycle: repo-security create / update / no-op + * - apply: PATCH security_and_analysis + PUT/DELETE Dependabot endpoints + * - runner integration: dry-run + apply; license-gated PATCH reported as failed + */ + +import { describe, it, expect } from "vitest"; +import { + securityFeaturesCycle, + fetchRepoSecurity, + buildSecurityAnalysisBody, +} from "./security-features.js"; +import type { SecurityFeaturesScope } from "./security-features.js"; +import type { AppClient } from "../auth/app-client.js"; +import type { RateBudget } from "../reconcile/runner.js"; +import { runReconcile, BudgetExhaustedError } from "../reconcile/runner.js"; +import { diff } from "../reconcile/diff.js"; +import type { LiveOrgState } from "../reconcile/diff.js"; +import type { GovernanceConfig, OrgConfig } from "../config/types.js"; + +// --------------------------------------------------------------------------- +// Mock helpers +// --------------------------------------------------------------------------- + +interface MockCall { + method: string; + path: string; + body?: unknown; +} + +interface MockClient extends AppClient { + calls: MockCall[]; + responses: Map; +} + +function makeMockClient(responses: Record = {}): MockClient { + const calls: MockCall[] = []; + const responseMap = new Map(Object.entries(responses)); + return { + calls, + responses: responseMap, + async request(method: string, path: string, body?: unknown): Promise { + calls.push({ method, path, body }); + const key = `${method} ${path}`; + if (responseMap.has(key)) return responseMap.get(key) as T; + return {} as T; + }, + }; +} + +function makeBudget(initial = 100): RateBudget { + let remaining = initial; + return { + get remaining() { + return remaining; + }, + get exhausted() { + return remaining <= 0; + }, + use(n = 1) { + if (remaining <= 0) throw new BudgetExhaustedError(); + remaining = Math.max(0, remaining - n); + }, + }; +} + +const scope: SecurityFeaturesScope = {}; + +// --------------------------------------------------------------------------- +// 1. buildDesired +// --------------------------------------------------------------------------- + +describe("securityFeaturesCycle.buildDesired", () => { + it("keeps only repos with a security block", () => { + const orgConfig: OrgConfig = { + repos: { + svc: { security: { secretScanning: true }, description: "x" }, + bare: { description: "no security" }, + }, + }; + const desired = securityFeaturesCycle.buildDesired(orgConfig, "test-org", scope); + expect(desired.repos!["svc"]).toEqual({ security: { secretScanning: true } }); + expect(desired.repos).not.toHaveProperty("bare"); + }); +}); + +// --------------------------------------------------------------------------- +// 2. fetchRepoSecurity +// --------------------------------------------------------------------------- + +describe("fetchRepoSecurity", () => { + it("maps security_and_analysis and both Dependabot endpoints", async () => { + const client = makeMockClient({ + "GET /repos/test-org/svc": { + security_and_analysis: { + advanced_security: { status: "enabled" }, + secret_scanning: { status: "disabled" }, + secret_scanning_push_protection: { status: "enabled" }, + }, + }, + // vulnerability-alerts present → 204-style empty (enabled) + "GET /repos/test-org/svc/vulnerability-alerts": {}, + "GET /repos/test-org/svc/automated-security-fixes": { enabled: true }, + }); + const live = await fetchRepoSecurity(client, "test-org", "svc", makeBudget()); + expect(live).toEqual({ + advancedSecurity: true, + secretScanning: false, + secretScanningPushProtection: true, + vulnerabilityAlerts: true, + dependabotSecurityUpdates: true, + }); + }); + + it("treats 404 on the Dependabot endpoints as disabled", async () => { + const client: MockClient = makeMockClient({ + "GET /repos/test-org/svc": { security_and_analysis: {} }, + }); + const base = client.request; + client.request = async (method: string, path: string, body?: unknown): Promise => { + if (path.endsWith("/vulnerability-alerts") || path.endsWith("/automated-security-fixes")) { + client.calls.push({ method, path }); + throw new Error("GET ... returned 404: Not Found"); + } + return base(method, path, body); + }; + const live = await fetchRepoSecurity(client, "test-org", "svc", makeBudget()); + expect(live.vulnerabilityAlerts).toBe(false); + expect(live.dependabotSecurityUpdates).toBe(false); + }); + + it("omits security_and_analysis features absent from the response (e.g. no GHAS)", async () => { + const client = makeMockClient({ + "GET /repos/test-org/svc": { security_and_analysis: { secret_scanning: { status: "enabled" } } }, + "GET /repos/test-org/svc/automated-security-fixes": { enabled: false }, + }); + const live = await fetchRepoSecurity(client, "test-org", "svc", makeBudget()); + expect(live.advancedSecurity).toBeUndefined(); + expect(live.secretScanning).toBe(true); + }); + + it("charges three calls per repo", async () => { + const client = makeMockClient({ "GET /repos/test-org/svc": { security_and_analysis: {} } }); + const budget = makeBudget(10); + await fetchRepoSecurity(client, "test-org", "svc", budget); + expect(budget.remaining).toBe(7); + }); +}); + +// --------------------------------------------------------------------------- +// 3. buildSecurityAnalysisBody +// --------------------------------------------------------------------------- + +describe("buildSecurityAnalysisBody", () => { + it("maps declared flags to status objects", () => { + expect( + buildSecurityAnalysisBody({ + advancedSecurity: true, + secretScanning: false, + secretScanningPushProtection: true, + }), + ).toEqual({ + advanced_security: { status: "enabled" }, + secret_scanning: { status: "disabled" }, + secret_scanning_push_protection: { status: "enabled" }, + }); + }); + + it("omits Dependabot-only flags (handled by dedicated endpoints)", () => { + expect(buildSecurityAnalysisBody({ vulnerabilityAlerts: true, dependabotSecurityUpdates: true })).toEqual({}); + }); +}); + +// --------------------------------------------------------------------------- +// 4. diff over the cycle +// --------------------------------------------------------------------------- + +describe("diff integration with security-features cycle", () => { + const desiredConfig: OrgConfig = { + repos: { svc: { security: { secretScanning: true, vulnerabilityAlerts: true } } }, + }; + + it("emits repo-security create when no live security exists", () => { + const desired = securityFeaturesCycle.buildDesired(desiredConfig, "test-org", scope); + const cs = diff("test-org", desired, { repos: { svc: {} } }); + expect(cs.entries).toHaveLength(1); + expect(cs.entries[0]!.resourceType).toBe("repo-security"); + expect(cs.entries[0]!.kind).toBe("create"); + expect(cs.entries[0]!.key).toBe("svc"); + }); + + it("emits update when a declared flag differs", () => { + const live: LiveOrgState = { + repos: { svc: { security: { secretScanning: false, vulnerabilityAlerts: true } } }, + }; + const desired = securityFeaturesCycle.buildDesired(desiredConfig, "test-org", scope); + const cs = diff("test-org", desired, live); + expect(cs.entries[0]!.kind).toBe("update"); + expect(cs.entries[0]!.fields!.map((f) => f.field)).toEqual(["secretScanning"]); + }); + + it("emits no entries when live matches desired", () => { + const live: LiveOrgState = { + repos: { svc: { security: { secretScanning: true, vulnerabilityAlerts: true } } }, + }; + const desired = securityFeaturesCycle.buildDesired(desiredConfig, "test-org", scope); + expect(diff("test-org", desired, live).entries).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// 5. apply +// --------------------------------------------------------------------------- + +describe("securityFeaturesCycle.apply", () => { + it("PATCHes security_and_analysis and toggles the Dependabot endpoints", async () => { + const client = makeMockClient(); + await securityFeaturesCycle.apply( + client, + { + kind: "update", + resourceType: "repo-security", + key: "svc", + after: { secretScanning: true, vulnerabilityAlerts: true, dependabotSecurityUpdates: false }, + }, + "my-org", + scope, + makeBudget(), + ); + const patch = client.calls.find((c) => c.method === "PATCH")!; + expect(patch.path).toBe("/repos/my-org/svc"); + expect(patch.body).toEqual({ security_and_analysis: { secret_scanning: { status: "enabled" } } }); + + const alerts = client.calls.find((c) => c.path.endsWith("/vulnerability-alerts"))!; + expect(alerts.method).toBe("PUT"); + const fixes = client.calls.find((c) => c.path.endsWith("/automated-security-fixes"))!; + expect(fixes.method).toBe("DELETE"); + }); + + it("skips the PATCH when only Dependabot flags are declared", async () => { + const client = makeMockClient(); + await securityFeaturesCycle.apply( + client, + { kind: "create", resourceType: "repo-security", key: "svc", after: { vulnerabilityAlerts: true } }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls.every((c) => c.method !== "PATCH")).toBe(true); + expect(client.calls).toHaveLength(1); + expect(client.calls[0]!.method).toBe("PUT"); + }); + + it("ignores foreign and delete entries", async () => { + const client = makeMockClient(); + await securityFeaturesCycle.apply( + client, + { kind: "delete", resourceType: "repo-security", key: "svc", before: {} }, + "my-org", + scope, + makeBudget(), + ); + await securityFeaturesCycle.apply( + client, + { kind: "create", resourceType: "repo", key: "svc", after: {} }, + "my-org", + scope, + makeBudget(), + ); + expect(client.calls).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// 6. Runner integration +// --------------------------------------------------------------------------- + +describe("securityFeaturesCycle via runReconcile", () => { + const config: GovernanceConfig = { + orgs: { "test-org": { repos: { svc: { security: { secretScanning: true } } } } }, + }; + const scopeWithRepos: SecurityFeaturesScope = { repos: config.orgs["test-org"]!.repos }; + + it("dry-run: reports an update without mutating", async () => { + const client = makeMockClient({ + "GET /repos/test-org/svc": { security_and_analysis: { secret_scanning: { status: "disabled" } } }, + }); + const result = await runReconcile({ + config, + client, + cycles: [securityFeaturesCycle], + scope: scopeWithRepos, + mode: "dry-run", + }); + expect(result.completed).toBe(true); + expect(result.cycles[0]!.counts.update).toBe(1); + expect(client.calls.every((c) => c.method === "GET")).toBe(true); + }); + + it("license-gated PATCH surfaces as a reported failed entry, not a crash", async () => { + const client: MockClient = makeMockClient({ + "GET /repos/test-org/svc": { security_and_analysis: { secret_scanning: { status: "disabled" } } }, + }); + const base = client.request; + client.request = async (method: string, path: string, body?: unknown): Promise => { + if (method === "PATCH") { + client.calls.push({ method, path, body }); + throw new Error("PATCH /repos/test-org/svc returned 422: Advanced Security is not available"); + } + return base(method, path, body); + }; + const result = await runReconcile({ + config, + client, + cycles: [securityFeaturesCycle], + scope: scopeWithRepos, + mode: "apply", + allowGuardrailOverride: true, + }); + const cr = result.cycles[0]!; + expect(cr.applied).toHaveLength(0); + expect(cr.failed).toHaveLength(1); + expect(cr.failed[0]!.error).toContain("Advanced Security is not available"); + }); +}); diff --git a/src/cycles/security-features.ts b/src/cycles/security-features.ts new file mode 100644 index 0000000..aa589d3 --- /dev/null +++ b/src/cycles/security-features.ts @@ -0,0 +1,237 @@ +/** + * Security-feature enforcement cycle. + * + * Reconciles repository security features: + * - GitHub Advanced Security, secret scanning, secret-scanning push + * protection — via the repo `security_and_analysis` object + * (`PATCH /repos/{o}/{r}`). + * - Dependabot vulnerability alerts — `vulnerability-alerts` endpoint. + * - Dependabot automated security fixes — `automated-security-fixes` endpoint. + * + * GET /repos/{o}/{r} — security_and_analysis state + * GET /repos/{o}/{r}/vulnerability-alerts — 204 enabled / 404 disabled + * GET /repos/{o}/{r}/automated-security-fixes — { enabled } / 404 + * PATCH /repos/{o}/{r} — set security_and_analysis + * PUT/DELETE the two dedicated endpoints — toggle Dependabot features + * + * Follows the four-part `Cycle` structure of the branch-protection template + * (`src/cycles/branch-protection.ts`). See `src/cycles/README.md`. + * + * ## License-gated graceful degradation + * + * Advanced Security (and secret scanning on private repos) requires a GHAS + * license. When unavailable, GitHub rejects the enabling PATCH; rather than + * crashing the run, that surfaces as a reported *failed entry* in the cycle + * result (the runner continues past it). So an org mixing GHAS and non-GHAS + * repos reconciles the available features everywhere and reports the rest. + * + * ## Scope + * + * Live state is fetched for repos in `scope.repos` that declare a `security` + * block (the branch-protection scope pattern). With no scope, declared repos + * appear as creates serviced idempotently by the apply writes. + */ + +import type { AppClient } from "../auth/app-client.js"; +import type { OrgConfig, RepoConfig, RepoSecurityConfig } from "../config/types.js"; +import type { ChangeSetEntry, LiveOrgState, LiveRepoSecurity } from "../reconcile/diff.js"; +import type { Cycle, RateBudget } from "../reconcile/runner.js"; + +// --------------------------------------------------------------------------- +// Public scope type +// --------------------------------------------------------------------------- + +/** Scope for the security-features cycle. Pass `repos` (typically `orgConfig.repos`). */ +export interface SecurityFeaturesScope { + repos?: Record; +} + +// --------------------------------------------------------------------------- +// GitHub REST API response shapes (only the fields we read) +// --------------------------------------------------------------------------- + +interface GhSecurityStatus { + status?: "enabled" | "disabled" | string | null; +} + +interface GhRepoSecurity { + security_and_analysis?: { + advanced_security?: GhSecurityStatus | null; + secret_scanning?: GhSecurityStatus | null; + secret_scanning_push_protection?: GhSecurityStatus | null; + } | null; +} + +interface GhAutomatedFixes { + enabled?: boolean; +} + +/** True when a repo config declares any security feature. */ +function hasManagedSecurity(repo: RepoConfig): boolean { + return repo.security !== undefined; +} + +// --------------------------------------------------------------------------- +// Live-state fetch +// --------------------------------------------------------------------------- + +function statusToBool(s: GhSecurityStatus | null | undefined): boolean | undefined { + if (s == null || s.status == null) return undefined; + return s.status === "enabled"; +} + +/** Fetch the security-feature state for one repo (up to 3 API calls). */ +export async function fetchRepoSecurity( + client: AppClient, + org: string, + repo: string, + budget: RateBudget, +): Promise { + const live: LiveRepoSecurity = {}; + + // 1. security_and_analysis via repo GET. + budget.use(1); + const repoData = await client.request("GET", `/repos/${org}/${repo}`); + const saa = repoData.security_and_analysis ?? {}; + const adv = statusToBool(saa.advanced_security); + const ss = statusToBool(saa.secret_scanning); + const ssp = statusToBool(saa.secret_scanning_push_protection); + if (adv !== undefined) live.advancedSecurity = adv; + if (ss !== undefined) live.secretScanning = ss; + if (ssp !== undefined) live.secretScanningPushProtection = ssp; + + // 2. Dependabot vulnerability alerts: 204 → enabled, 404 → disabled. + if (!budget.exhausted) { + budget.use(1); + try { + await client.request("GET", `/repos/${org}/${repo}/vulnerability-alerts`); + live.vulnerabilityAlerts = true; + } catch (err) { + if (err instanceof Error && err.message.includes("404")) { + live.vulnerabilityAlerts = false; + } else { + throw err; + } + } + } + + // 3. Dependabot automated security fixes. + if (!budget.exhausted) { + budget.use(1); + try { + const fixes = await client.request( + "GET", + `/repos/${org}/${repo}/automated-security-fixes`, + ); + live.dependabotSecurityUpdates = fixes.enabled === true; + } catch (err) { + if (err instanceof Error && err.message.includes("404")) { + live.dependabotSecurityUpdates = false; + } else { + throw err; + } + } + } + + return live; +} + +// --------------------------------------------------------------------------- +// Apply helpers +// --------------------------------------------------------------------------- + +/** Build the `security_and_analysis` PATCH body from declared flags (empty if none). */ +export function buildSecurityAnalysisBody(desired: RepoSecurityConfig): Record { + const saa: Record = {}; + if (desired.advancedSecurity !== undefined) { + saa.advanced_security = { status: desired.advancedSecurity ? "enabled" : "disabled" }; + } + if (desired.secretScanning !== undefined) { + saa.secret_scanning = { status: desired.secretScanning ? "enabled" : "disabled" }; + } + if (desired.secretScanningPushProtection !== undefined) { + saa.secret_scanning_push_protection = { + status: desired.secretScanningPushProtection ? "enabled" : "disabled", + }; + } + return saa; +} + +// --------------------------------------------------------------------------- +// securityFeaturesCycle — implements Cycle +// --------------------------------------------------------------------------- + +export const securityFeaturesCycle: Cycle = { + name: "security-features", + + // ── Part 2: fetchLive ────────────────────────────────────────────────────── + + async fetchLive( + client: AppClient, + orgLogin: string, + scope: SecurityFeaturesScope, + budget: RateBudget, + ): Promise { + if (budget.exhausted) { + const { BudgetExhaustedError } = await import("../reconcile/runner.js"); + throw new BudgetExhaustedError(); + } + + const repos: NonNullable = {}; + for (const [name, repoConfig] of Object.entries(scope?.repos ?? {})) { + if (!hasManagedSecurity(repoConfig)) continue; + if (budget.exhausted) break; + repos[name] = { security: await fetchRepoSecurity(client, orgLogin, name, budget) }; + } + + return { repos }; + }, + + // ── Part 3: buildDesired ─────────────────────────────────────────────────── + + buildDesired(orgConfig: OrgConfig, _orgLogin: string, _scope: SecurityFeaturesScope): OrgConfig { + if (!orgConfig.repos) return {}; + const repos: Record = {}; + for (const [name, repoConfig] of Object.entries(orgConfig.repos)) { + if (hasManagedSecurity(repoConfig)) repos[name] = { security: repoConfig.security }; + } + return { repos }; + }, + + // ── Part 4: apply ────────────────────────────────────────────────────────── + + async apply( + client: AppClient, + entry: ChangeSetEntry, + orgLogin: string, + _scope: SecurityFeaturesScope, + budget: RateBudget, + ): Promise { + if (entry.resourceType !== "repo-security") return; + if (entry.kind === "delete") return; // security features are toggled, never "deleted" + + const repo = entry.key; + const desired = entry.after as RepoSecurityConfig; + + // 1. security_and_analysis (advanced security, secret scanning, push protection). + const saa = buildSecurityAnalysisBody(desired); + if (Object.keys(saa).length > 0) { + budget.use(1); + await client.request("PATCH", `/repos/${orgLogin}/${repo}`, { security_and_analysis: saa }); + } + + // 2. Dependabot vulnerability alerts (dedicated endpoint). + if (desired.vulnerabilityAlerts !== undefined) { + budget.use(1); + const method = desired.vulnerabilityAlerts ? "PUT" : "DELETE"; + await client.request(method, `/repos/${orgLogin}/${repo}/vulnerability-alerts`); + } + + // 3. Dependabot automated security fixes (dedicated endpoint). + if (desired.dependabotSecurityUpdates !== undefined) { + budget.use(1); + const method = desired.dependabotSecurityUpdates ? "PUT" : "DELETE"; + await client.request(method, `/repos/${orgLogin}/${repo}/automated-security-fixes`); + } + }, +}; diff --git a/src/index.ts b/src/index.ts index b16a258..dd11f1c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ export type { RulesetConfig, RulesetTarget, RulesetEnforcement, + RepoSecurityConfig, } from "./config/types.js"; // Config loader @@ -39,6 +40,7 @@ export type { LiveBranchProtectionConfig, LiveRepoConfig, LiveRuleset, + LiveRepoSecurity, LiveOrgState, } from "./reconcile/diff.js"; export { diff, summarizeChangeSet, renderChangeSet } from "./reconcile/diff.js"; @@ -85,6 +87,8 @@ export { teamsCycle, mapTeamRepoPermission } from "./cycles/teams.js"; export type { TeamsScope } from "./cycles/teams.js"; export { rulesetsCycle, fetchRulesets, buildRulesetBody, mapRulesetToLive } from "./cycles/rulesets.js"; export type { RulesetsScope } from "./cycles/rulesets.js"; +export { securityFeaturesCycle, fetchRepoSecurity, buildSecurityAnalysisBody } from "./cycles/security-features.js"; +export type { SecurityFeaturesScope } from "./cycles/security-features.js"; // Reconcile: dump (export live state to desired-state config) export type { DumpOrgOptions, DumpResult } from "./reconcile/dump.js"; diff --git a/src/reconcile/diff.ts b/src/reconcile/diff.ts index d51283d..1539ed6 100644 --- a/src/reconcile/diff.ts +++ b/src/reconcile/diff.ts @@ -20,6 +20,7 @@ import type { RepoConfig, BranchProtectionConfig, RulesetConfig, + RepoSecurityConfig, } from "../config/types.js"; // --------------------------------------------------------------------------- @@ -185,6 +186,16 @@ export interface LiveRepoConfig { branchProtection?: LiveBranchProtectionConfig[]; topics?: string[]; rulesets?: LiveRuleset[]; + security?: LiveRepoSecurity; +} + +/** Live snapshot of a repo's security-feature toggles. Mirrors `RepoSecurityConfig`. */ +export interface LiveRepoSecurity { + advancedSecurity?: boolean; + secretScanning?: boolean; + secretScanningPushProtection?: boolean; + vulnerabilityAlerts?: boolean; + dependabotSecurityUpdates?: boolean; } export interface LiveOrgState { @@ -207,6 +218,7 @@ const RESOURCE_TYPE_ORDER = [ "team-repo", "member", "repo", + "repo-security", "branch-protection", "repo-ruleset", ] as const; @@ -522,6 +534,9 @@ function diffRepos( // Repository rulesets diffRulesets(`${name}/`, "repo-ruleset", dr.rulesets, lr.rulesets ?? [], opts, out); + + // Repository security features + diffRepoSecurity(name, dr.security, lr.security, out); } for (const name of Object.keys(live)) { @@ -660,6 +675,41 @@ function diffRulesets( } } +// --------------------------------------------------------------------------- +// Repository security features +// --------------------------------------------------------------------------- + +/** + * Diff a repo's security-feature toggles. Single object per repo, keyed by + * repo name, resource type "repo-security". Selective-by-omission: only + * declared flags are compared; an absent `security` block is not managed. + */ +function diffRepoSecurity( + repoName: string, + desired: RepoSecurityConfig | undefined, + live: LiveRepoSecurity | undefined, + out: ChangeSetEntry[], +): void { + if (desired === undefined) return; + + if (live === undefined) { + out.push({ kind: "create", resourceType: "repo-security", key: repoName, after: desired }); + return; + } + + const fields = diffObject(desired as Record, live as Record); + if (fields.length > 0) { + out.push({ + kind: "update", + resourceType: "repo-security", + key: repoName, + before: live, + after: desired, + fields, + }); + } +} + // --------------------------------------------------------------------------- // Object-level field diffing helpers // ---------------------------------------------------------------------------