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