1use libverify_core::evidence::{CodeownersEntry, EvidenceGap, EvidenceState, RepositoryPosture};
8
9use crate::client::GitHubClient;
10
11const CODEOWNERS_PATHS: &[&str] = &["CODEOWNERS", "docs/CODEOWNERS", ".github/CODEOWNERS"];
13
14const SECURITY_POLICY_PATHS: &[&str] = &["SECURITY.md", "docs/SECURITY.md", ".github/SECURITY.md"];
16
17const DISCLOSURE_KEYWORDS: &[&str] = &[
19 "responsible disclosure",
20 "coordinated disclosure",
21 "vulnerability report",
22 "report a vulnerability",
23 "security@",
24 "hackerone",
25 "bugcrowd",
26 "bug bounty",
27];
28
29pub fn collect_repository_posture(
36 client: &GitHubClient,
37 owner: &str,
38 repo: &str,
39 ref_sha: &str,
40) -> EvidenceState<RepositoryPosture> {
41 let mut gaps = Vec::new();
42
43 let codeowners_entries = collect_codeowners(client, owner, repo, ref_sha);
45
46 let (security_policy_present, security_policy_has_disclosure) =
48 collect_security_policy(client, owner, repo, ref_sha);
49
50 let (
52 security_analysis_available,
53 secret_scanning_enabled,
54 secret_push_protection_enabled,
55 vulnerability_scanning_enabled,
56 code_scanning_enabled,
57 default_branch_protected,
58 enforce_admins,
59 dismiss_stale_reviews,
60 ) = match collect_repo_settings(client, owner, repo) {
61 Ok(settings) => (
62 settings.security_analysis_available,
63 settings.secret_scanning,
64 settings.push_protection,
65 settings.dependabot,
66 settings.code_scanning,
67 settings.branch_protected,
68 settings.enforce_admins,
69 settings.dismiss_stale_reviews,
70 ),
71 Err(e) => {
72 gaps.push(EvidenceGap::CollectionFailed {
73 source: "github".to_string(),
74 subject: "repository settings".to_string(),
75 detail: format!("{e:#}"),
76 });
77 (false, false, false, false, false, false, false, false)
78 }
79 };
80
81 let dependency_update_tool_configured =
83 collect_dependency_update_tool(client, owner, repo, ref_sha);
84
85 let (default_workflow_permissions, admin_count, direct_collaborator_count) =
87 match collect_permissions_info(client, owner, repo) {
88 Ok(info) => (
89 info.default_workflow_permissions,
90 info.admin_count,
91 info.direct_collaborator_count,
92 ),
93 Err(e) => {
94 gaps.push(EvidenceGap::CollectionFailed {
95 source: "github".to_string(),
96 subject: "permissions info".to_string(),
97 detail: format!("{e:#}"),
98 });
99 (String::new(), 0, 0)
100 }
101 };
102
103 let posture = RepositoryPosture {
104 codeowners_entries,
105 security_analysis_available,
106 secret_scanning_enabled,
107 secret_push_protection_enabled,
108 vulnerability_scanning_enabled,
109 code_scanning_enabled,
110 security_policy_present,
111 security_policy_has_disclosure,
112 default_branch_protected,
113 enforce_admins,
114 dismiss_stale_reviews,
115 dependency_update_tool_configured,
116 default_workflow_permissions,
117 admin_count,
118 direct_collaborator_count,
119 tag_protection_enabled: collect_tag_protection(client, owner, repo),
120 ..Default::default()
121 };
122
123 if gaps.is_empty() {
124 EvidenceState::complete(posture)
125 } else {
126 EvidenceState::partial(posture, gaps)
127 }
128}
129
130fn collect_codeowners(
132 client: &GitHubClient,
133 owner: &str,
134 repo: &str,
135 ref_sha: &str,
136) -> Vec<CodeownersEntry> {
137 for path in CODEOWNERS_PATHS {
138 if let Ok(content) = client.get_file_content(owner, repo, path, ref_sha) {
139 return parse_codeowners(&content);
140 }
141 }
142 Vec::new()
143}
144
145fn parse_codeowners(content: &str) -> Vec<CodeownersEntry> {
147 content
148 .lines()
149 .filter(|line| {
150 let trimmed = line.trim();
151 !trimmed.is_empty() && !trimmed.starts_with('#')
152 })
153 .filter_map(|line| {
154 let parts: Vec<&str> = line.split_whitespace().collect();
155 if parts.len() >= 2 {
156 Some(CodeownersEntry {
157 pattern: parts[0].to_string(),
158 owners: parts[1..].iter().map(|s| s.to_string()).collect(),
159 })
160 } else {
161 None
162 }
163 })
164 .collect()
165}
166
167fn collect_security_policy(
169 client: &GitHubClient,
170 owner: &str,
171 repo: &str,
172 ref_sha: &str,
173) -> (bool, bool) {
174 for path in SECURITY_POLICY_PATHS {
175 if let Ok(content) = client.get_file_content(owner, repo, path, ref_sha) {
176 let lower = content.to_lowercase();
177 let has_disclosure = DISCLOSURE_KEYWORDS.iter().any(|kw| lower.contains(kw));
178 return (true, has_disclosure);
179 }
180 }
181 (false, false)
182}
183
184#[derive(serde::Deserialize)]
186struct RepoResponse {
187 #[serde(default)]
188 default_branch: String,
189 #[serde(default)]
190 security_and_analysis: Option<SecurityAndAnalysis>,
191}
192
193#[derive(serde::Deserialize)]
194struct SecurityAndAnalysis {
195 #[serde(default)]
196 secret_scanning: Option<SecurityFeature>,
197 #[serde(default)]
198 secret_scanning_push_protection: Option<SecurityFeature>,
199 #[serde(default)]
200 dependabot_security_updates: Option<SecurityFeature>,
201}
202
203#[derive(serde::Deserialize)]
204struct SecurityFeature {
205 status: String,
206}
207
208#[derive(serde::Deserialize)]
210struct BranchProtectionResponse {
211 #[serde(default)]
212 enforce_admins: Option<EnforceAdmins>,
213 #[serde(default)]
214 required_pull_request_reviews: Option<RequiredPullRequestReviews>,
215}
216
217#[derive(serde::Deserialize)]
218struct EnforceAdmins {
219 enabled: bool,
220}
221
222#[derive(serde::Deserialize)]
223struct RequiredPullRequestReviews {
224 #[serde(default)]
225 dismiss_stale_reviews: bool,
226}
227
228struct RepoSettingsResult {
231 security_analysis_available: bool,
232 secret_scanning: bool,
233 push_protection: bool,
234 dependabot: bool,
235 code_scanning: bool,
236 branch_protected: bool,
237 enforce_admins: bool,
238 dismiss_stale_reviews: bool,
239}
240
241fn collect_repo_settings(
243 client: &GitHubClient,
244 owner: &str,
245 repo: &str,
246) -> anyhow::Result<RepoSettingsResult> {
247 let path = format!("/repos/{owner}/{repo}");
248 let body = client.get(&path)?;
249 let resp: RepoResponse = serde_json::from_str(&body)?;
250 let security_analysis_available = resp.security_and_analysis.is_some();
251
252 let (secret_scanning, push_protection, dependabot) = match resp.security_and_analysis.as_ref() {
253 Some(sa) => (
254 sa.secret_scanning
255 .as_ref()
256 .is_some_and(|f| f.status == "enabled"),
257 sa.secret_scanning_push_protection
258 .as_ref()
259 .is_some_and(|f| f.status == "enabled"),
260 sa.dependabot_security_updates
261 .as_ref()
262 .is_some_and(|f| f.status == "enabled"),
263 ),
264 None => (false, false, false),
265 };
266
267 let code_scanning = client
269 .get(&format!(
270 "/repos/{owner}/{repo}/code-scanning/analyses?per_page=1"
271 ))
272 .map(|body| {
273 serde_json::from_str::<Vec<serde_json::Value>>(&body)
274 .map(|v| !v.is_empty())
275 .unwrap_or(false)
276 })
277 .unwrap_or(false);
278
279 let default_branch = &resp.default_branch;
281 let protection_url = format!("/repos/{owner}/{repo}/branches/{default_branch}/protection");
282 let (branch_protected, enforce_admins, dismiss_stale_reviews) =
283 match client.get(&protection_url) {
284 Ok(bp_body) => {
285 let bp: BranchProtectionResponse =
286 serde_json::from_str(&bp_body).unwrap_or(BranchProtectionResponse {
287 enforce_admins: None,
288 required_pull_request_reviews: None,
289 });
290 let enforce = bp.enforce_admins.as_ref().is_some_and(|ea| ea.enabled);
291 let dismiss = bp
292 .required_pull_request_reviews
293 .as_ref()
294 .is_some_and(|pr| pr.dismiss_stale_reviews);
295 (true, enforce, dismiss)
296 }
297 Err(_) => (false, false, false),
298 };
299
300 Ok(RepoSettingsResult {
301 security_analysis_available,
302 secret_scanning,
303 push_protection,
304 dependabot,
305 code_scanning,
306 branch_protected,
307 enforce_admins,
308 dismiss_stale_reviews,
309 })
310}
311
312const DEPENDABOT_PATH: &str = ".github/dependabot.yml";
314const RENOVATE_PATHS: &[&str] = &[
315 "renovate.json",
316 "renovate.json5",
317 ".renovaterc",
318 ".renovaterc.json",
319];
320
321fn collect_dependency_update_tool(
323 client: &GitHubClient,
324 owner: &str,
325 repo: &str,
326 ref_sha: &str,
327) -> bool {
328 if client
330 .get_file_content(owner, repo, DEPENDABOT_PATH, ref_sha)
331 .is_ok()
332 {
333 return true;
334 }
335 for path in RENOVATE_PATHS {
337 if client.get_file_content(owner, repo, path, ref_sha).is_ok() {
338 return true;
339 }
340 }
341 false
342}
343
344struct PermissionsInfo {
346 default_workflow_permissions: String,
347 admin_count: u32,
348 direct_collaborator_count: u32,
349}
350
351fn collect_permissions_info(
353 client: &GitHubClient,
354 owner: &str,
355 repo: &str,
356) -> anyhow::Result<PermissionsInfo> {
357 let default_workflow_permissions = client
360 .get(&format!(
361 "/repos/{owner}/{repo}/actions/permissions/workflow"
362 ))
363 .ok()
364 .and_then(|body| {
365 serde_json::from_str::<serde_json::Value>(&body)
366 .ok()
367 .and_then(|v| v["default_workflow_permissions"].as_str().map(String::from))
368 })
369 .unwrap_or_default();
370
371 let (admin_count, direct_collaborator_count) = client
373 .get(&format!(
374 "/repos/{owner}/{repo}/collaborators?affiliation=direct&per_page=100"
375 ))
376 .ok()
377 .and_then(|body| serde_json::from_str::<Vec<serde_json::Value>>(&body).ok())
378 .map(|collabs| {
379 let admins = collabs
380 .iter()
381 .filter(|c| c["permissions"]["admin"].as_bool().unwrap_or(false))
382 .count() as u32;
383 let direct = collabs.len() as u32;
384 (admins, direct)
385 })
386 .unwrap_or((0, 0));
387
388 Ok(PermissionsInfo {
389 default_workflow_permissions,
390 admin_count,
391 direct_collaborator_count,
392 })
393}
394
395fn collect_tag_protection(client: &GitHubClient, owner: &str, repo: &str) -> bool {
397 client
399 .get(&format!("/repos/{owner}/{repo}/tags/protection"))
400 .ok()
401 .and_then(|body| serde_json::from_str::<Vec<serde_json::Value>>(&body).ok())
402 .is_some_and(|rules| !rules.is_empty())
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 #[test]
410 fn parse_codeowners_basic() {
411 let content = "# Global owners\n* @org/team\n/src/auth/ @alice @bob\n";
412 let entries = parse_codeowners(content);
413 assert_eq!(entries.len(), 2);
414 assert_eq!(entries[0].pattern, "*");
415 assert_eq!(entries[0].owners, vec!["@org/team"]);
416 assert_eq!(entries[1].pattern, "/src/auth/");
417 assert_eq!(entries[1].owners, vec!["@alice", "@bob"]);
418 }
419
420 #[test]
421 fn parse_codeowners_empty_and_comments() {
422 let content = "# comment\n\n # another comment\n";
423 let entries = parse_codeowners(content);
424 assert!(entries.is_empty());
425 }
426
427 #[test]
428 fn parse_codeowners_single_column_skipped() {
429 let content = "*.rs\n";
430 let entries = parse_codeowners(content);
431 assert!(entries.is_empty(), "single-column lines have no owners");
432 }
433
434 #[test]
435 fn disclosure_keywords_are_lowercase() {
436 for kw in DISCLOSURE_KEYWORDS {
437 assert_eq!(
438 *kw,
439 kw.to_lowercase(),
440 "keyword must be lowercase for case-insensitive matching"
441 );
442 }
443 }
444}