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