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
91 changes: 88 additions & 3 deletions action/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -234020,6 +234066,7 @@ function parseReportArgs(argv) {
cycles: [],
out: void 0,
audit: false,
identity: false,
failOn: "none"
};
const knownFlags = /* @__PURE__ */ new Set([
Expand All @@ -234030,6 +234077,7 @@ function parseReportArgs(argv) {
"--cycles",
"--out",
"--audit",
"--identity",
"--fail-on"
]);
let i = 0;
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -234595,6 +234679,7 @@ function printUsage() {
" --cycles <name[,name...]> Cycles to include (default: all).",
" --out <path> 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:",
Expand Down
48 changes: 47 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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";
}
Expand All @@ -345,6 +348,7 @@ export function parseReportArgs(argv: string[]): ReportArgs {
cycles: [],
out: undefined,
audit: false,
identity: false,
failOn: "none",
};

Expand All @@ -356,6 +360,7 @@ export function parseReportArgs(argv: string[]): ReportArgs {
"--cycles",
"--out",
"--audit",
"--identity",
"--fail-on",
]);

Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -1082,8 +1091,44 @@ async function runReport(argv: string[]): Promise<void> {
}
}

// ── 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<Array<{ login?: string }>>(
"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));

Expand Down Expand Up @@ -1136,6 +1181,7 @@ function printUsage() {
" --cycles <name[,name...]> Cycles to include (default: all).",
" --out <path> 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:",
Expand Down
5 changes: 5 additions & 0 deletions src/cli/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
7 changes: 7 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
14 changes: 14 additions & 0 deletions src/report/compliance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()] }),
Expand Down
14 changes: 13 additions & 1 deletion src/report/compliance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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). */
Expand Down Expand Up @@ -103,6 +107,7 @@ export interface ComplianceReport {
export function buildComplianceReport(
results: ReconcileResult[],
audit?: PostureReport,
identity?: IdentityReport,
): ComplianceReport {
const cycles: CycleComplianceEntry[] = [];
const errored: ComplianceError[] = [];
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading