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
106 changes: 103 additions & 3 deletions action/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ function diff(org, desired, live, opts = {}) {
diffSecrets("", "org-secret", desired.secrets, live.secrets ?? [], opts, entries);
diffVariables("", "org-variable", desired.variables, live.variables ?? [], opts, entries);
diffRepoBaselines(desired.repoBaselines, live.repos ?? {}, entries);
diffTokenGrants(desired.tokenPolicy, live.tokenGrants ?? [], opts, entries);
diffTeams(desired.teams, live.teams ?? {}, opts, entries);
diffMembers(desired.members, live.members ?? [], opts, entries);
diffRepos(desired.repos, live.repos ?? {}, opts, entries);
Expand Down Expand Up @@ -688,6 +689,31 @@ function diffVariables(keyPrefix, resourceType, desired, live, opts, out) {
}
}
}
function evaluateTokenViolation(grant, policy, nowMs) {
if (policy.revokeExpired !== false && grant.expired === true) return "expired";
if (policy.maxLifetimeDays != null && nowMs != null && grant.grantedAtMs != null && (nowMs - grant.grantedAtMs) / MS_PER_DAY > policy.maxLifetimeDays) {
return "exceeds-max-lifetime";
}
if (policy.maxIdleDays != null && nowMs != null && grant.lastUsedAtMs != null && (nowMs - grant.lastUsedAtMs) / MS_PER_DAY > policy.maxIdleDays) {
return "idle";
}
return null;
}
function diffTokenGrants(policy, grants, opts, out) {
if (policy === void 0) return;
for (const grant of grants) {
const reason = evaluateTokenViolation(grant, policy, opts.nowMs);
if (!reason) continue;
out.push({
kind: "update",
resourceType: "token-grant",
key: String(grant.id),
before: grant,
after: { revoke: true, reason, ownerLogin: grant.ownerLogin },
fields: [{ field: "access", before: "granted", after: `revoked (${reason})` }]
});
}
}
function diffRepoBaselines(desired, liveRepos, out) {
if (desired === void 0) return;
for (const baseline of desired) {
Expand Down Expand Up @@ -776,7 +802,7 @@ function fmt(v) {
const json2 = JSON.stringify(v);
return json2.length > 60 ? `${json2.slice(0, 57)}...` : json2;
}
var RESOURCE_TYPE_ORDER, RULESET_FIELDS, ENVIRONMENT_FIELDS;
var RESOURCE_TYPE_ORDER, RULESET_FIELDS, ENVIRONMENT_FIELDS, MS_PER_DAY;
var init_diff = __esm({
"src/reconcile/diff.ts"() {
"use strict";
Expand All @@ -785,6 +811,7 @@ var init_diff = __esm({
"org-ruleset",
"org-secret",
"org-variable",
"token-grant",
"repo-baseline",
"team",
"team-member",
Expand All @@ -806,6 +833,7 @@ var init_diff = __esm({
"reviewers",
"deploymentBranchPolicy"
];
MS_PER_DAY = 864e5;
}
});

Expand Down Expand Up @@ -1021,7 +1049,10 @@ async function runReconcile(opts) {
});
continue;
}
const changeSet = diff(orgLogin, desired, live, diffOptions);
const changeSet = diff(orgLogin, desired, live, {
...diffOptions,
nowMs: diffOptions.nowMs ?? Date.now()
});
const guardrailResult = runGuardrails(changeSet, live, guardrailConfig);
const counts = { create: 0, update: 0, delete: 0 };
for (const e of changeSet.entries) counts[e.kind]++;
Expand Down Expand Up @@ -230632,6 +230663,74 @@ var repoBaselineCycle = {
}
};

// src/cycles/token-governance.ts
var PER_PAGE6 = 100;
function toMs(iso) {
if (!iso) return void 0;
const ms = Date.parse(iso);
return Number.isNaN(ms) ? void 0 : ms;
}
function mapTokenGrant(raw) {
const grant = { id: raw.id };
if (raw.owner?.login) grant.ownerLogin = raw.owner.login;
if (typeof raw.token_expired === "boolean") grant.expired = raw.token_expired;
const exp = toMs(raw.token_expires_at);
if (exp !== void 0) grant.expiresAtMs = exp;
const last = toMs(raw.token_last_used_at);
if (last !== void 0) grant.lastUsedAtMs = last;
const granted = toMs(raw.access_granted_at);
if (granted !== void 0) grant.grantedAtMs = granted;
return grant;
}
var tokenGovernanceCycle = {
name: "token-governance",
// ── 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 grants = [];
let page = 1;
for (; ; ) {
if (budget.exhausted) break;
budget.use(1);
let batch;
try {
batch = await client.request(
"GET",
`/orgs/${orgLogin}/personal-access-tokens?per_page=${PER_PAGE6}&page=${page}`
);
} catch (err) {
if (err instanceof Error && (err.message.includes("404") || err.message.includes("403"))) {
return { tokenGrants: [] };
}
throw err;
}
if (!Array.isArray(batch) || batch.length === 0) break;
for (const g of batch) if (g && typeof g.id === "number") grants.push(mapTokenGrant(g));
if (batch.length < PER_PAGE6) break;
page++;
}
return { tokenGrants: grants };
},
// ── Part 3: buildDesired ───────────────────────────────────────────────────
buildDesired(orgConfig, _orgLogin, _scope) {
if (!orgConfig.tokenPolicy) return {};
return { tokenPolicy: orgConfig.tokenPolicy };
},
// ── Part 4: apply ──────────────────────────────────────────────────────────
async apply(client, entry, orgLogin, _scope, budget) {
if (entry.resourceType !== "token-grant") return;
if (entry.kind !== "update") return;
const patId = entry.key;
budget.use(1);
await client.request("POST", `/orgs/${orgLogin}/personal-access-tokens/${patId}`, {
action: "revoke"
});
}
};

// src/cli/registry.ts
var CYCLE_REGISTRY = {
[branchProtectionCycle.name]: branchProtectionCycle,
Expand All @@ -230644,7 +230743,8 @@ var CYCLE_REGISTRY = {
[environmentsCycle.name]: environmentsCycle,
[secretsVariablesCycle.name]: secretsVariablesCycle,
[dependencyHygieneCycle.name]: dependencyHygieneCycle,
[repoBaselineCycle.name]: repoBaselineCycle
[repoBaselineCycle.name]: repoBaselineCycle,
[tokenGovernanceCycle.name]: tokenGovernanceCycle
};

// node_modules/@intentius/chant/src/audit/fetch.ts
Expand Down
2 changes: 2 additions & 0 deletions src/cli/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { environmentsCycle } from "../cycles/environments.js";
import { secretsVariablesCycle } from "../cycles/secrets-variables.js";
import { dependencyHygieneCycle } from "../cycles/dependency-hygiene.js";
import { repoBaselineCycle } from "../cycles/repo-baseline.js";
import { tokenGovernanceCycle } from "../cycles/token-governance.js";

/**
* Registry of all available governance cycles, keyed by the name accepted by
Expand All @@ -40,4 +41,5 @@ export const CYCLE_REGISTRY: Record<string, Cycle> = {
[secretsVariablesCycle.name]: secretsVariablesCycle,
[dependencyHygieneCycle.name]: dependencyHygieneCycle,
[repoBaselineCycle.name]: repoBaselineCycle,
[tokenGovernanceCycle.name]: tokenGovernanceCycle,
};
27 changes: 27 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,28 @@ export interface MemberConfig {
role?: OrgMemberRole;
}

// ---------------------------------------------------------------------------
// Token governance
// ---------------------------------------------------------------------------

/**
* Org fine-grained PAT governance policy. Drives the token-governance cycle's
* scheduled sweep: it revokes a token grant's ORG ACCESS when the grant
* violates the policy.
*
* PLATFORM WALL: user PATs cannot be created or rotated on a user's behalf via
* the API. warden can only inventory grants, gate approval (#16), and revoke
* org access — these token APIs are callable ONLY by a GitHub App.
*/
export interface TokenPolicyConfig {
/** Revoke org access for an expired grant still listed. Default true. */
revokeExpired?: boolean;
/** Maximum grant lifetime in days (1–366). Older grants are revoked. */
maxLifetimeDays?: number;
/** Maximum idle days since last use. Staler grants are revoked. */
maxIdleDays?: number;
}

// ---------------------------------------------------------------------------
// Dependency hygiene (Dependabot config file)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -438,6 +460,11 @@ export interface OrgConfig {
* machine user from a person, so this list is operator-declared.
*/
machineUsers?: string[];
/**
* Fine-grained PAT governance policy (scheduled sweep). Absent means token
* grants are not governed by chant.
*/
tokenPolicy?: TokenPolicyConfig;
}

/**
Expand Down
Loading
Loading