Skip to content

Commit f553b00

Browse files
lex00claude
andauthored
feat(cycle): security-feature enforcement cycle (#13) (#27)
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 <noreply@anthropic.com>
1 parent 42c514c commit f553b00

7 files changed

Lines changed: 795 additions & 1 deletion

File tree

action/index.mjs

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,7 @@ function diffRepos(desired, live, opts, out) {
487487
}
488488
diffBranchProtection(name, dr.branchProtection, lr.branchProtection ?? [], opts, out);
489489
diffRulesets(`${name}/`, "repo-ruleset", dr.rulesets, lr.rulesets ?? [], opts, out);
490+
diffRepoSecurity(name, dr.security, lr.security, out);
490491
}
491492
for (const name of Object.keys(live)) {
492493
if (!Object.prototype.hasOwnProperty.call(desired, name)) {
@@ -584,6 +585,24 @@ function diffRulesets(keyPrefix, resourceType, desired, live, opts, out) {
584585
}
585586
}
586587
}
588+
function diffRepoSecurity(repoName, desired, live, out) {
589+
if (desired === void 0) return;
590+
if (live === void 0) {
591+
out.push({ kind: "create", resourceType: "repo-security", key: repoName, after: desired });
592+
return;
593+
}
594+
const fields = diffObject(desired, live);
595+
if (fields.length > 0) {
596+
out.push({
597+
kind: "update",
598+
resourceType: "repo-security",
599+
key: repoName,
600+
before: live,
601+
after: desired,
602+
fields
603+
});
604+
}
605+
}
587606
function diffObject(desired, live) {
588607
const fields = [];
589608
for (const key of Object.keys(desired)) {
@@ -659,6 +678,7 @@ var init_diff = __esm({
659678
"team-repo",
660679
"member",
661680
"repo",
681+
"repo-security",
662682
"branch-protection",
663683
"repo-ruleset"
664684
];
@@ -229948,14 +229968,129 @@ var rulesetsCycle = {
229948229968
}
229949229969
};
229950229970

229971+
// src/cycles/security-features.ts
229972+
function hasManagedSecurity(repo) {
229973+
return repo.security !== void 0;
229974+
}
229975+
function statusToBool(s) {
229976+
if (s == null || s.status == null) return void 0;
229977+
return s.status === "enabled";
229978+
}
229979+
async function fetchRepoSecurity(client, org, repo, budget) {
229980+
const live = {};
229981+
budget.use(1);
229982+
const repoData = await client.request("GET", `/repos/${org}/${repo}`);
229983+
const saa = repoData.security_and_analysis ?? {};
229984+
const adv = statusToBool(saa.advanced_security);
229985+
const ss = statusToBool(saa.secret_scanning);
229986+
const ssp = statusToBool(saa.secret_scanning_push_protection);
229987+
if (adv !== void 0) live.advancedSecurity = adv;
229988+
if (ss !== void 0) live.secretScanning = ss;
229989+
if (ssp !== void 0) live.secretScanningPushProtection = ssp;
229990+
if (!budget.exhausted) {
229991+
budget.use(1);
229992+
try {
229993+
await client.request("GET", `/repos/${org}/${repo}/vulnerability-alerts`);
229994+
live.vulnerabilityAlerts = true;
229995+
} catch (err) {
229996+
if (err instanceof Error && err.message.includes("404")) {
229997+
live.vulnerabilityAlerts = false;
229998+
} else {
229999+
throw err;
230000+
}
230001+
}
230002+
}
230003+
if (!budget.exhausted) {
230004+
budget.use(1);
230005+
try {
230006+
const fixes = await client.request(
230007+
"GET",
230008+
`/repos/${org}/${repo}/automated-security-fixes`
230009+
);
230010+
live.dependabotSecurityUpdates = fixes.enabled === true;
230011+
} catch (err) {
230012+
if (err instanceof Error && err.message.includes("404")) {
230013+
live.dependabotSecurityUpdates = false;
230014+
} else {
230015+
throw err;
230016+
}
230017+
}
230018+
}
230019+
return live;
230020+
}
230021+
function buildSecurityAnalysisBody(desired) {
230022+
const saa = {};
230023+
if (desired.advancedSecurity !== void 0) {
230024+
saa.advanced_security = { status: desired.advancedSecurity ? "enabled" : "disabled" };
230025+
}
230026+
if (desired.secretScanning !== void 0) {
230027+
saa.secret_scanning = { status: desired.secretScanning ? "enabled" : "disabled" };
230028+
}
230029+
if (desired.secretScanningPushProtection !== void 0) {
230030+
saa.secret_scanning_push_protection = {
230031+
status: desired.secretScanningPushProtection ? "enabled" : "disabled"
230032+
};
230033+
}
230034+
return saa;
230035+
}
230036+
var securityFeaturesCycle = {
230037+
name: "security-features",
230038+
// ── Part 2: fetchLive ──────────────────────────────────────────────────────
230039+
async fetchLive(client, orgLogin, scope, budget) {
230040+
if (budget.exhausted) {
230041+
const { BudgetExhaustedError: BudgetExhaustedError2 } = await Promise.resolve().then(() => (init_runner(), runner_exports));
230042+
throw new BudgetExhaustedError2();
230043+
}
230044+
const repos = {};
230045+
for (const [name, repoConfig] of Object.entries(scope?.repos ?? {})) {
230046+
if (!hasManagedSecurity(repoConfig)) continue;
230047+
if (budget.exhausted) break;
230048+
repos[name] = { security: await fetchRepoSecurity(client, orgLogin, name, budget) };
230049+
}
230050+
return { repos };
230051+
},
230052+
// ── Part 3: buildDesired ───────────────────────────────────────────────────
230053+
buildDesired(orgConfig, _orgLogin, _scope) {
230054+
if (!orgConfig.repos) return {};
230055+
const repos = {};
230056+
for (const [name, repoConfig] of Object.entries(orgConfig.repos)) {
230057+
if (hasManagedSecurity(repoConfig)) repos[name] = { security: repoConfig.security };
230058+
}
230059+
return { repos };
230060+
},
230061+
// ── Part 4: apply ──────────────────────────────────────────────────────────
230062+
async apply(client, entry, orgLogin, _scope, budget) {
230063+
if (entry.resourceType !== "repo-security") return;
230064+
if (entry.kind === "delete") return;
230065+
const repo = entry.key;
230066+
const desired = entry.after;
230067+
const saa = buildSecurityAnalysisBody(desired);
230068+
if (Object.keys(saa).length > 0) {
230069+
budget.use(1);
230070+
await client.request("PATCH", `/repos/${orgLogin}/${repo}`, { security_and_analysis: saa });
230071+
}
230072+
if (desired.vulnerabilityAlerts !== void 0) {
230073+
budget.use(1);
230074+
const method = desired.vulnerabilityAlerts ? "PUT" : "DELETE";
230075+
await client.request(method, `/repos/${orgLogin}/${repo}/vulnerability-alerts`);
230076+
}
230077+
if (desired.dependabotSecurityUpdates !== void 0) {
230078+
budget.use(1);
230079+
const method = desired.dependabotSecurityUpdates ? "PUT" : "DELETE";
230080+
await client.request(method, `/repos/${orgLogin}/${repo}/automated-security-fixes`);
230081+
}
230082+
}
230083+
};
230084+
229951230085
// src/cli/registry.ts
229952230086
var CYCLE_REGISTRY = {
229953230087
[branchProtectionCycle.name]: branchProtectionCycle,
229954230088
[orgSettingsCycle.name]: orgSettingsCycle,
229955230089
[repoSettingsCycle.name]: repoSettingsCycle,
229956230090
[membershipCycle.name]: membershipCycle,
229957230091
[teamsCycle.name]: teamsCycle,
229958-
[rulesetsCycle.name]: rulesetsCycle
230092+
[rulesetsCycle.name]: rulesetsCycle,
230093+
[securityFeaturesCycle.name]: securityFeaturesCycle
229959230094
};
229960230095

229961230096
// node_modules/@intentius/chant/src/audit/fetch.ts

src/cli/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { repoSettingsCycle } from "../cycles/repo-settings.js";
1515
import { membershipCycle } from "../cycles/membership.js";
1616
import { teamsCycle } from "../cycles/teams.js";
1717
import { rulesetsCycle } from "../cycles/rulesets.js";
18+
import { securityFeaturesCycle } from "../cycles/security-features.js";
1819

1920
/**
2021
* Registry of all available governance cycles, keyed by the name accepted by
@@ -30,4 +31,5 @@ export const CYCLE_REGISTRY: Record<string, Cycle> = {
3031
[membershipCycle.name]: membershipCycle,
3132
[teamsCycle.name]: teamsCycle,
3233
[rulesetsCycle.name]: rulesetsCycle,
34+
[securityFeaturesCycle.name]: securityFeaturesCycle,
3335
};

src/config/types.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,35 @@ export interface MemberConfig {
100100
role?: OrgMemberRole;
101101
}
102102

103+
// ---------------------------------------------------------------------------
104+
// Repository security features
105+
// ---------------------------------------------------------------------------
106+
107+
/**
108+
* Repository security-feature toggles. Absent fields are not managed.
109+
*
110+
* The first three map to the repo `security_and_analysis` object (set via
111+
* `PATCH /repos/{o}/{r}`); the last two use dedicated endpoints
112+
* (`vulnerability-alerts`, `automated-security-fixes`).
113+
*
114+
* License-gated note: GitHub Advanced Security features (`advancedSecurity`,
115+
* and secret scanning on private repos) require a GHAS license. Where a feature
116+
* is unavailable, GitHub rejects the enabling write; the cycle surfaces that as
117+
* a reported failed entry rather than crashing the run (see cycle header).
118+
*/
119+
export interface RepoSecurityConfig {
120+
/** GitHub Advanced Security (`security_and_analysis.advanced_security`). */
121+
advancedSecurity?: boolean;
122+
/** Secret scanning (`security_and_analysis.secret_scanning`). */
123+
secretScanning?: boolean;
124+
/** Secret scanning push protection (`security_and_analysis.secret_scanning_push_protection`). */
125+
secretScanningPushProtection?: boolean;
126+
/** Dependabot vulnerability alerts (`vulnerability-alerts` endpoint). */
127+
vulnerabilityAlerts?: boolean;
128+
/** Dependabot automated security fixes (`automated-security-fixes` endpoint). */
129+
dependabotSecurityUpdates?: boolean;
130+
}
131+
103132
// ---------------------------------------------------------------------------
104133
// Rulesets (repo + org)
105134
// ---------------------------------------------------------------------------
@@ -207,6 +236,11 @@ export interface RepoConfig {
207236
* Absent means repo rulesets are not managed by chant.
208237
*/
209238
rulesets?: RulesetConfig[];
239+
/**
240+
* Repository security features (GHAS, secret scanning, Dependabot).
241+
* Absent means security features are not managed by chant.
242+
*/
243+
security?: RepoSecurityConfig;
210244
}
211245

212246
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)