From f75b8a9f6785150200f16a8923f7ac30592a7100 Mon Sep 17 00:00:00 2001 From: lex00 Date: Fri, 19 Jun 2026 09:49:56 -0600 Subject: [PATCH] feat(report): identity & service-account hygiene pass (#17) Detect-and-report (report-style, per roadmap decision): inventories the org's installed Apps and flags operator-declared machine users (OrgConfig.machineUsers) that are seat-consuming org members, recommending migration to Apps. Pure buildIdentityReport + render, folded into the compliance report (optional identity field, flips clean) and surfaced via `report --identity`. Machine users are operator-declared because the API can't reliably mark them. Action bundle rebuilt. Co-Authored-By: Claude Opus 4.8 --- action/index.mjs | 91 +++++++++++++++++++++++- src/cli.ts | 48 ++++++++++++- src/cli/cli.test.ts | 5 ++ src/config/types.ts | 7 ++ src/index.ts | 2 + src/report/compliance.test.ts | 14 ++++ src/report/compliance.ts | 14 +++- src/report/identity.test.ts | 65 +++++++++++++++++ src/report/identity.ts | 127 ++++++++++++++++++++++++++++++++++ 9 files changed, 368 insertions(+), 5 deletions(-) create mode 100644 src/report/identity.test.ts create mode 100644 src/report/identity.ts diff --git a/action/index.mjs b/action/index.mjs index 5ad4473..ac0b007 100644 --- a/action/index.mjs +++ b/action/index.mjs @@ -233710,8 +233710,49 @@ function shouldFail(report, failOn) { } } +// src/report/identity.ts +function buildIdentityReport(installations, memberLogins, machineUserLogins) { + const apps = installations.map((i) => ({ + slug: i.app_slug ?? "unknown", + appId: i.app_id, + permissionCount: i.permissions ? Object.keys(i.permissions).length : 0 + })); + const memberSet = new Set(memberLogins); + const declared = [...new Set(machineUserLogins)]; + const flagged = declared.filter((l) => memberSet.has(l)); + const notMembers = declared.filter((l) => !memberSet.has(l)); + const recommendations = []; + for (const login of flagged) { + recommendations.push( + `Machine user "${login}" consumes an org seat \u2014 replace it with a GitHub App (Apps consume no seat).` + ); + } + return { + installations: { count: apps.length, apps }, + machineUsers: { flagged, notMembers }, + summary: { installationCount: apps.length, flaggedMachineUsers: flagged.length }, + recommendations + }; +} +function renderIdentityReport(report) { + const lines = []; + lines.push("--- identity & service-account hygiene ---"); + lines.push(` installed apps: ${report.installations.count} (seat-free)`); + for (const a of report.installations.apps) { + lines.push(` ${a.slug} permissions=${a.permissionCount}`); + } + lines.push( + ` machine users: ${report.machineUsers.flagged.length} flagged` + (report.machineUsers.notMembers.length ? `, ${report.machineUsers.notMembers.length} declared-not-member` : "") + ); + for (const r of report.recommendations) { + lines.push(` \u26A0 ${r}`); + } + lines.push(""); + return lines.join("\n"); +} + // src/report/compliance.ts -function buildComplianceReport(results, audit) { +function buildComplianceReport(results, audit, identity) { const cycles = []; const errored = []; const deferred = []; @@ -233757,11 +233798,12 @@ function buildComplianceReport(results, audit) { mergeWorthy: auditMergeWorthy }; } - const clean = drift === 0 && guardrailTrips === 0 && failed === 0 && errored.length === 0 && deferred.length === 0 && auditMergeWorthy === 0; + const clean = drift === 0 && guardrailTrips === 0 && failed === 0 && errored.length === 0 && deferred.length === 0 && auditMergeWorthy === 0 && (identity?.summary.flaggedMachineUsers ?? 0) === 0; return { modes: [...modeSet], cycles, audit: auditCompliance, + identity, totals: { drift, guardrailTrips, @@ -233812,6 +233854,10 @@ function renderComplianceReport(report) { ` total=${report.audit.total} merge-worthy=${report.audit.mergeWorthy} (quick-win=${report.audit.quickWin}, needs-review=${report.audit.needsReview}, report-only=${report.audit.reportOnly})` ); } + if (report.identity) { + lines.push(""); + lines.push(renderIdentityReport(report.identity).trimEnd()); + } lines.push(""); lines.push("--- totals ---"); const t = report.totals; @@ -234020,6 +234066,7 @@ function parseReportArgs(argv) { cycles: [], out: void 0, audit: false, + identity: false, failOn: "none" }; const knownFlags = /* @__PURE__ */ new Set([ @@ -234030,6 +234077,7 @@ function parseReportArgs(argv) { "--cycles", "--out", "--audit", + "--identity", "--fail-on" ]); let i = 0; @@ -234079,6 +234127,10 @@ function parseReportArgs(argv) { args.audit = true; break; } + case "--identity": { + args.identity = true; + break; + } case "--fail-on": { const val = argv[++i]; if (val !== "none" && val !== "attention") { @@ -234544,7 +234596,39 @@ async function runReport(argv) { } } } - const report = buildComplianceReport([result], auditReport); + let identityReport; + if (reportArgs.identity) { + try { + const installations = []; + const memberLogins = []; + const machineUsers = []; + for (const [orgName, orgCfg] of Object.entries(config2.orgs)) { + try { + const data = await client.request( + "GET", + `/orgs/${orgName}/installations?per_page=100` + ); + installations.push(...data.installations ?? []); + } catch (err) { + if (!(err instanceof Error && (err.message.includes("404") || err.message.includes("403")))) throw err; + } + try { + const members = await client.request( + "GET", + `/orgs/${orgName}/members?per_page=100` + ); + for (const m of members ?? []) if (typeof m.login === "string") memberLogins.push(m.login); + } catch (err) { + if (!(err instanceof Error && (err.message.includes("404") || err.message.includes("403")))) throw err; + } + machineUsers.push(...orgCfg.machineUsers ?? []); + } + identityReport = buildIdentityReport(installations, memberLogins, machineUsers); + } catch (err) { + die(3, `identity pass failed: ${errMsg(err)}`); + } + } + const report = buildComplianceReport([result], auditReport, identityReport); report.generatedAt = (/* @__PURE__ */ new Date()).toISOString(); process.stdout.write(renderComplianceReport(report)); if (reportArgs.out) { @@ -234595,6 +234679,7 @@ function printUsage() { " --cycles Cycles to include (default: all).", " --out Write the JSON compliance artifact to this path.", " --audit Include an audit pass in the report.", + " --identity Include an identity & service-account hygiene pass.", " --fail-on none|attention Exit 4 when the report needs attention (default: none).", "", "Exit codes:", diff --git a/src/cli.ts b/src/cli.ts index 8abbb3b..06ff8cc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -45,6 +45,7 @@ import { auditRepos } from "./audit/engine.js"; import { renderPostureSummary, shouldFail, type FailOn } from "./audit/summary.js"; import type { Cycle, ReconcileResult } from "./reconcile/runner.js"; import { buildComplianceReport, renderComplianceReport, complianceArtifact } from "./report/compliance.js"; +import { buildIdentityReport, type RawInstallation } from "./report/identity.js"; // --------------------------------------------------------------------------- // Arg parser @@ -320,6 +321,8 @@ export interface ReportArgs { out: string | undefined; /** Include an audit pass in the report. */ audit: boolean; + /** Include an identity & service-account hygiene pass in the report. */ + identity: boolean; /** Exit non-zero when the report needs attention. */ failOn: "none" | "attention"; } @@ -345,6 +348,7 @@ export function parseReportArgs(argv: string[]): ReportArgs { cycles: [], out: undefined, audit: false, + identity: false, failOn: "none", }; @@ -356,6 +360,7 @@ export function parseReportArgs(argv: string[]): ReportArgs { "--cycles", "--out", "--audit", + "--identity", "--fail-on", ]); @@ -407,6 +412,10 @@ export function parseReportArgs(argv: string[]): ReportArgs { args.audit = true; break; } + case "--identity": { + args.identity = true; + break; + } case "--fail-on": { const val = argv[++i]; if (val !== "none" && val !== "attention") { @@ -1082,8 +1091,44 @@ async function runReport(argv: string[]): Promise { } } + // ── Optional identity & service-account hygiene pass ─────────────────────── + let identityReport; + if (reportArgs.identity) { + try { + const installations: RawInstallation[] = []; + const memberLogins: string[] = []; + const machineUsers: string[] = []; + for (const [orgName, orgCfg] of Object.entries(config.orgs)) { + // App installations on the org (tolerate 403/404 — no access / none). + try { + const data = await client.request<{ installations?: RawInstallation[] }>( + "GET", + `/orgs/${orgName}/installations?per_page=100`, + ); + installations.push(...(data.installations ?? [])); + } catch (err) { + if (!(err instanceof Error && (err.message.includes("404") || err.message.includes("403")))) throw err; + } + // Org members (logins). + try { + const members = await client.request>( + "GET", + `/orgs/${orgName}/members?per_page=100`, + ); + for (const m of members ?? []) if (typeof m.login === "string") memberLogins.push(m.login); + } catch (err) { + if (!(err instanceof Error && (err.message.includes("404") || err.message.includes("403")))) throw err; + } + machineUsers.push(...(orgCfg.machineUsers ?? [])); + } + identityReport = buildIdentityReport(installations, memberLogins, machineUsers); + } catch (err) { + die(3, `identity pass failed: ${errMsg(err)}`); + } + } + // ── Aggregate + output ───────────────────────────────────────────────────── - const report = buildComplianceReport([result], auditReport); + const report = buildComplianceReport([result], auditReport, identityReport); report.generatedAt = new Date().toISOString(); process.stdout.write(renderComplianceReport(report)); @@ -1136,6 +1181,7 @@ function printUsage() { " --cycles Cycles to include (default: all).", " --out Write the JSON compliance artifact to this path.", " --audit Include an audit pass in the report.", + " --identity Include an identity & service-account hygiene pass.", " --fail-on none|attention Exit 4 when the report needs attention (default: none).", "", "Exit codes:", diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index 4697457..0ecb3fa 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -151,6 +151,11 @@ describe("parseReportArgs", () => { expect(args.failOn).toBe("attention"); }); + it("parses --identity", () => { + const args = parseReportArgs(["--config", "g.yml", "--token-env", "GH_TOKEN", "--identity"]); + expect(args.identity).toBe(true); + }); + it("throws code 2 when auth is missing", () => { expect(() => parseReportArgs(["--config", "g.yml"])).toThrow( expect.objectContaining({ code: 2 }), diff --git a/src/config/types.ts b/src/config/types.ts index a43b21d..ac4d5a4 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -431,6 +431,13 @@ export interface OrgConfig { * Absent means repo provisioning is not managed by chant. */ repoBaselines?: RepoBaselineConfig[]; + /** + * Known machine / service-account logins. The identity report flags any of + * these that are seat-consuming org members and recommends migrating them to + * GitHub Apps (Apps consume no seat). The API cannot reliably distinguish a + * machine user from a person, so this list is operator-declared. + */ + machineUsers?: string[]; } /** diff --git a/src/index.ts b/src/index.ts index 95e80ab..7614f87 100644 --- a/src/index.ts +++ b/src/index.ts @@ -125,3 +125,5 @@ export type { ComplianceError, } from "./report/compliance.js"; export { buildComplianceReport, renderComplianceReport, complianceArtifact } from "./report/compliance.js"; +export type { IdentityReport, InstalledApp, RawInstallation } from "./report/identity.js"; +export { buildIdentityReport, renderIdentityReport } from "./report/identity.js"; diff --git a/src/report/compliance.test.ts b/src/report/compliance.test.ts index 9757b09..9cd3244 100644 --- a/src/report/compliance.test.ts +++ b/src/report/compliance.test.ts @@ -12,6 +12,7 @@ import { } from "./compliance.js"; import type { ReconcileResult, CycleResult } from "../reconcile/runner.js"; import type { PostureReport } from "../audit/engine.js"; +import { buildIdentityReport } from "./identity.js"; // --------------------------------------------------------------------------- // Builders for mock run results @@ -152,6 +153,19 @@ describe("buildComplianceReport", () => { expect(report.clean).toBe(true); }); + it("folds in an identity report and flips clean when machine users are flagged", () => { + const identity = buildIdentityReport([], ["ci-bot"], ["ci-bot"]); + const report = buildComplianceReport([reconcileResult({ cycles: [cycleResult()] })], undefined, identity); + expect(report.identity).toBe(identity); + expect(report.clean).toBe(false); // flagged machine user + }); + + it("stays clean with an identity report that flags nobody", () => { + const identity = buildIdentityReport([{ app_slug: "warden" }], ["alice"], []); + const report = buildComplianceReport([reconcileResult({ cycles: [cycleResult()] })], undefined, identity); + expect(report.clean).toBe(true); + }); + it("merges multiple reconcile results and dedupes modes", () => { const report = buildComplianceReport([ reconcileResult({ mode: "dry-run", cycles: [cycleResult()] }), diff --git a/src/report/compliance.ts b/src/report/compliance.ts index d04c0b8..dd9aafd 100644 --- a/src/report/compliance.ts +++ b/src/report/compliance.ts @@ -16,6 +16,8 @@ import type { ReconcileResult } from "../reconcile/runner.js"; import type { PostureReport } from "../audit/engine.js"; +import type { IdentityReport } from "./identity.js"; +import { renderIdentityReport } from "./identity.js"; // --------------------------------------------------------------------------- // Public types @@ -63,6 +65,8 @@ export interface ComplianceReport { cycles: CycleComplianceEntry[]; /** Audit totals, when an audit report was supplied. */ audit?: AuditCompliance; + /** Identity & service-account hygiene, when an identity pass was run. */ + identity?: IdentityReport; /** Cross-cutting roll-ups. */ totals: { /** Total change-set entries across all cycles (total drift). */ @@ -103,6 +107,7 @@ export interface ComplianceReport { export function buildComplianceReport( results: ReconcileResult[], audit?: PostureReport, + identity?: IdentityReport, ): ComplianceReport { const cycles: CycleComplianceEntry[] = []; const errored: ComplianceError[] = []; @@ -163,12 +168,14 @@ export function buildComplianceReport( failed === 0 && errored.length === 0 && deferred.length === 0 && - auditMergeWorthy === 0; + auditMergeWorthy === 0 && + (identity?.summary.flaggedMachineUsers ?? 0) === 0; return { modes: [...modeSet], cycles, audit: auditCompliance, + identity, totals: { drift, guardrailTrips, @@ -238,6 +245,11 @@ export function renderComplianceReport(report: ComplianceReport): string { ); } + if (report.identity) { + lines.push(""); + lines.push(renderIdentityReport(report.identity).trimEnd()); + } + lines.push(""); lines.push("--- totals ---"); const t = report.totals; diff --git a/src/report/identity.test.ts b/src/report/identity.test.ts new file mode 100644 index 0000000..6a48a68 --- /dev/null +++ b/src/report/identity.test.ts @@ -0,0 +1,65 @@ +/** + * Tests for the identity & service-account hygiene report. + * + * Pure unit tests over mock inputs — no network. + */ + +import { describe, it, expect } from "vitest"; +import { buildIdentityReport, renderIdentityReport } from "./identity.js"; + +describe("buildIdentityReport", () => { + it("inventories installed apps with permission counts", () => { + const report = buildIdentityReport( + [ + { app_slug: "dependabot", app_id: 1, permissions: { contents: "read", metadata: "read" } }, + { app_slug: "warden", app_id: 2, permissions: {} }, + ], + [], + [], + ); + expect(report.installations.count).toBe(2); + expect(report.installations.apps[0]).toEqual({ slug: "dependabot", appId: 1, permissionCount: 2 }); + expect(report.installations.apps[1]!.permissionCount).toBe(0); + }); + + it("flags declared machine users that are org members and recommends Apps", () => { + const report = buildIdentityReport( + [], + ["alice", "ci-bot", "deploy-bot"], + ["ci-bot", "deploy-bot", "retired-bot"], + ); + expect(report.machineUsers.flagged).toEqual(["ci-bot", "deploy-bot"]); + expect(report.machineUsers.notMembers).toEqual(["retired-bot"]); + expect(report.summary.flaggedMachineUsers).toBe(2); + expect(report.recommendations).toHaveLength(2); + expect(report.recommendations[0]).toContain("ci-bot"); + expect(report.recommendations[0]).toContain("Apps consume no seat"); + }); + + it("de-dupes declared machine users", () => { + const report = buildIdentityReport([], ["bot"], ["bot", "bot"]); + expect(report.machineUsers.flagged).toEqual(["bot"]); + }); + + it("defaults a missing app slug to 'unknown'", () => { + const report = buildIdentityReport([{ app_id: 9 }], [], []); + expect(report.installations.apps[0]!.slug).toBe("unknown"); + }); +}); + +describe("renderIdentityReport", () => { + it("renders apps and flagged machine users", () => { + const out = renderIdentityReport( + buildIdentityReport( + [{ app_slug: "warden", app_id: 1, permissions: { contents: "write" } }], + ["ci-bot"], + ["ci-bot"], + ), + ); + expect(out).toContain("identity & service-account hygiene"); + expect(out).toContain("installed apps: 1"); + expect(out).toContain("warden permissions=1"); + expect(out).toContain("1 flagged"); + expect(out).toContain("⚠"); + }); +}); diff --git a/src/report/identity.ts b/src/report/identity.ts new file mode 100644 index 0000000..a76b438 --- /dev/null +++ b/src/report/identity.ts @@ -0,0 +1,127 @@ +/** + * Identity & service-account hygiene report. + * + * A detect-and-report pass (no mutation): inventories the org's installed + * GitHub Apps and flags operator-declared machine/service-account logins that + * are seat-consuming org members, recommending migration to Apps (which consume + * no seat). + * + * Pure and deterministic. The CLI fetches the raw inputs (installations, + * members) and the operator declares known machine users via config; this + * module only classifies and renders. + * + * ## Why machine users are operator-declared + * + * GitHub's API does not reliably mark a "machine user" — they are ordinary user + * accounts used as bots. So warden cannot auto-detect them; the org declares + * the known ones (`OrgConfig.machineUsers`) and this report cross-references + * them against live membership. + */ + +// --------------------------------------------------------------------------- +// Input shapes (subset of the GitHub installation object we read) +// --------------------------------------------------------------------------- + +/** Raw GitHub app-installation fields we read. */ +export interface RawInstallation { + app_slug?: string; + app_id?: number; + permissions?: Record | null; +} + +// --------------------------------------------------------------------------- +// Public report types +// --------------------------------------------------------------------------- + +/** An installed App (consumes no seat). */ +export interface InstalledApp { + slug: string; + appId?: number; + /** Number of permission scopes granted to the installation. */ + permissionCount: number; +} + +/** The identity & service-account hygiene report. */ +export interface IdentityReport { + installations: { + count: number; + apps: InstalledApp[]; + }; + machineUsers: { + /** Declared machine users that ARE org members (seat-consuming). */ + flagged: string[]; + /** Declared machine users not currently org members. */ + notMembers: string[]; + }; + summary: { + installationCount: number; + flaggedMachineUsers: number; + }; + /** Human-readable recommendations. */ + recommendations: string[]; +} + +// --------------------------------------------------------------------------- +// buildIdentityReport +// --------------------------------------------------------------------------- + +/** + * Build the identity report from installed apps, the org member logins, and the + * declared machine-user logins. Pure. + */ +export function buildIdentityReport( + installations: RawInstallation[], + memberLogins: string[], + machineUserLogins: string[], +): IdentityReport { + const apps: InstalledApp[] = installations.map((i) => ({ + slug: i.app_slug ?? "unknown", + appId: i.app_id, + permissionCount: i.permissions ? Object.keys(i.permissions).length : 0, + })); + + const memberSet = new Set(memberLogins); + // De-dupe declared machine users while preserving order. + const declared = [...new Set(machineUserLogins)]; + const flagged = declared.filter((l) => memberSet.has(l)); + const notMembers = declared.filter((l) => !memberSet.has(l)); + + const recommendations: string[] = []; + for (const login of flagged) { + recommendations.push( + `Machine user "${login}" consumes an org seat — replace it with a GitHub App (Apps consume no seat).`, + ); + } + + return { + installations: { count: apps.length, apps }, + machineUsers: { flagged, notMembers }, + summary: { installationCount: apps.length, flaggedMachineUsers: flagged.length }, + recommendations, + }; +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +/** Render the identity report to a human-readable section. */ +export function renderIdentityReport(report: IdentityReport): string { + const lines: string[] = []; + lines.push("--- identity & service-account hygiene ---"); + lines.push(` installed apps: ${report.installations.count} (seat-free)`); + for (const a of report.installations.apps) { + lines.push(` ${a.slug} permissions=${a.permissionCount}`); + } + lines.push( + ` machine users: ${report.machineUsers.flagged.length} flagged` + + (report.machineUsers.notMembers.length + ? `, ${report.machineUsers.notMembers.length} declared-not-member` + : ""), + ); + for (const r of report.recommendations) { + lines.push(` ⚠ ${r}`); + } + lines.push(""); + return lines.join("\n"); +}