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 ..Default::default()
120 };
121
122 if gaps.is_empty() {
123 EvidenceState::complete(posture)
124 } else {
125 EvidenceState::partial(posture, gaps)
126 }
127}
128
129fn collect_codeowners(
131 client: &GitHubClient,
132 owner: &str,
133 repo: &str,
134 ref_sha: &str,
135) -> Vec<CodeownersEntry> {
136 for path in CODEOWNERS_PATHS {
137 if let Ok(content) = client.get_file_content(owner, repo, path, ref_sha) {
138 return parse_codeowners(&content);
139 }
140 }
141 Vec::new()
142}
143
144fn parse_codeowners(content: &str) -> Vec<CodeownersEntry> {
146 content
147 .lines()
148 .filter(|line| {
149 let trimmed = line.trim();
150 !trimmed.is_empty() && !trimmed.starts_with('#')
151 })
152 .filter_map(|line| {
153 let parts: Vec<&str> = line.split_whitespace().collect();
154 if parts.len() >= 2 {
155 Some(CodeownersEntry {
156 pattern: parts[0].to_string(),
157 owners: parts[1..].iter().map(|s| s.to_string()).collect(),
158 })
159 } else {
160 None
161 }
162 })
163 .collect()
164}
165
166fn collect_security_policy(
168 client: &GitHubClient,
169 owner: &str,
170 repo: &str,
171 ref_sha: &str,
172) -> (bool, bool) {
173 for path in SECURITY_POLICY_PATHS {
174 if let Ok(content) = client.get_file_content(owner, repo, path, ref_sha) {
175 let lower = content.to_lowercase();
176 let has_disclosure = DISCLOSURE_KEYWORDS.iter().any(|kw| lower.contains(kw));
177 return (true, has_disclosure);
178 }
179 }
180 (false, false)
181}
182
183#[derive(serde::Deserialize)]
185struct RepoResponse {
186 #[serde(default)]
187 default_branch: String,
188 #[serde(default)]
189 security_and_analysis: Option<SecurityAndAnalysis>,
190}
191
192#[derive(serde::Deserialize)]
193struct SecurityAndAnalysis {
194 #[serde(default)]
195 secret_scanning: Option<SecurityFeature>,
196 #[serde(default)]
197 secret_scanning_push_protection: Option<SecurityFeature>,
198 #[serde(default)]
199 dependabot_security_updates: Option<SecurityFeature>,
200}
201
202#[derive(serde::Deserialize)]
203struct SecurityFeature {
204 status: String,
205}
206
207#[derive(serde::Deserialize)]
209struct BranchProtectionResponse {
210 #[serde(default)]
211 enforce_admins: Option<EnforceAdmins>,
212 #[serde(default)]
213 required_pull_request_reviews: Option<RequiredPullRequestReviews>,
214}
215
216#[derive(serde::Deserialize)]
217struct EnforceAdmins {
218 enabled: bool,
219}
220
221#[derive(serde::Deserialize)]
222struct RequiredPullRequestReviews {
223 #[serde(default)]
224 dismiss_stale_reviews: bool,
225}
226
227struct RepoSettingsResult {
230 security_analysis_available: bool,
231 secret_scanning: bool,
232 push_protection: bool,
233 dependabot: bool,
234 code_scanning: bool,
235 branch_protected: bool,
236 enforce_admins: bool,
237 dismiss_stale_reviews: bool,
238}
239
240fn collect_repo_settings(
242 client: &GitHubClient,
243 owner: &str,
244 repo: &str,
245) -> anyhow::Result<RepoSettingsResult> {
246 let path = format!("/repos/{owner}/{repo}");
247 let body = client.get(&path)?;
248 let resp: RepoResponse = serde_json::from_str(&body)?;
249 let security_analysis_available = resp.security_and_analysis.is_some();
250
251 let (secret_scanning, push_protection, dependabot) = match resp.security_and_analysis.as_ref() {
252 Some(sa) => (
253 sa.secret_scanning
254 .as_ref()
255 .is_some_and(|f| f.status == "enabled"),
256 sa.secret_scanning_push_protection
257 .as_ref()
258 .is_some_and(|f| f.status == "enabled"),
259 sa.dependabot_security_updates
260 .as_ref()
261 .is_some_and(|f| f.status == "enabled"),
262 ),
263 None => (false, false, false),
264 };
265
266 let code_scanning = client
268 .get(&format!(
269 "/repos/{owner}/{repo}/code-scanning/analyses?per_page=1"
270 ))
271 .map(|body| {
272 serde_json::from_str::<Vec<serde_json::Value>>(&body)
273 .map(|v| !v.is_empty())
274 .unwrap_or(false)
275 })
276 .unwrap_or(false);
277
278 let default_branch = &resp.default_branch;
280 let protection_url = format!("/repos/{owner}/{repo}/branches/{default_branch}/protection");
281 let (branch_protected, enforce_admins, dismiss_stale_reviews) =
282 match client.get(&protection_url) {
283 Ok(bp_body) => {
284 let bp: BranchProtectionResponse =
285 serde_json::from_str(&bp_body).unwrap_or(BranchProtectionResponse {
286 enforce_admins: None,
287 required_pull_request_reviews: None,
288 });
289 let enforce = bp.enforce_admins.as_ref().is_some_and(|ea| ea.enabled);
290 let dismiss = bp
291 .required_pull_request_reviews
292 .as_ref()
293 .is_some_and(|pr| pr.dismiss_stale_reviews);
294 (true, enforce, dismiss)
295 }
296 Err(_) => (false, false, false),
297 };
298
299 Ok(RepoSettingsResult {
300 security_analysis_available,
301 secret_scanning,
302 push_protection,
303 dependabot,
304 code_scanning,
305 branch_protected,
306 enforce_admins,
307 dismiss_stale_reviews,
308 })
309}
310
311const DEPENDABOT_PATH: &str = ".github/dependabot.yml";
313const RENOVATE_PATHS: &[&str] = &[
314 "renovate.json",
315 "renovate.json5",
316 ".renovaterc",
317 ".renovaterc.json",
318];
319
320fn collect_dependency_update_tool(
322 client: &GitHubClient,
323 owner: &str,
324 repo: &str,
325 ref_sha: &str,
326) -> bool {
327 if client
329 .get_file_content(owner, repo, DEPENDABOT_PATH, ref_sha)
330 .is_ok()
331 {
332 return true;
333 }
334 for path in RENOVATE_PATHS {
336 if client.get_file_content(owner, repo, path, ref_sha).is_ok() {
337 return true;
338 }
339 }
340 false
341}
342
343struct PermissionsInfo {
345 default_workflow_permissions: String,
346 admin_count: u32,
347 direct_collaborator_count: u32,
348}
349
350fn collect_permissions_info(
352 client: &GitHubClient,
353 owner: &str,
354 repo: &str,
355) -> anyhow::Result<PermissionsInfo> {
356 let default_workflow_permissions = client
359 .get(&format!(
360 "/repos/{owner}/{repo}/actions/permissions/workflow"
361 ))
362 .ok()
363 .and_then(|body| {
364 serde_json::from_str::<serde_json::Value>(&body)
365 .ok()
366 .and_then(|v| v["default_workflow_permissions"].as_str().map(String::from))
367 })
368 .unwrap_or_default();
369
370 let (admin_count, direct_collaborator_count) = client
372 .get(&format!(
373 "/repos/{owner}/{repo}/collaborators?affiliation=direct&per_page=100"
374 ))
375 .ok()
376 .and_then(|body| serde_json::from_str::<Vec<serde_json::Value>>(&body).ok())
377 .map(|collabs| {
378 let admins = collabs
379 .iter()
380 .filter(|c| c["permissions"]["admin"].as_bool().unwrap_or(false))
381 .count() as u32;
382 let direct = collabs.len() as u32;
383 (admins, direct)
384 })
385 .unwrap_or((0, 0));
386
387 Ok(PermissionsInfo {
388 default_workflow_permissions,
389 admin_count,
390 direct_collaborator_count,
391 })
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397
398 #[test]
399 fn parse_codeowners_basic() {
400 let content = "# Global owners\n* @org/team\n/src/auth/ @alice @bob\n";
401 let entries = parse_codeowners(content);
402 assert_eq!(entries.len(), 2);
403 assert_eq!(entries[0].pattern, "*");
404 assert_eq!(entries[0].owners, vec!["@org/team"]);
405 assert_eq!(entries[1].pattern, "/src/auth/");
406 assert_eq!(entries[1].owners, vec!["@alice", "@bob"]);
407 }
408
409 #[test]
410 fn parse_codeowners_empty_and_comments() {
411 let content = "# comment\n\n # another comment\n";
412 let entries = parse_codeowners(content);
413 assert!(entries.is_empty());
414 }
415
416 #[test]
417 fn parse_codeowners_single_column_skipped() {
418 let content = "*.rs\n";
419 let entries = parse_codeowners(content);
420 assert!(entries.is_empty(), "single-column lines have no owners");
421 }
422
423 #[test]
424 fn disclosure_keywords_are_lowercase() {
425 for kw in DISCLOSURE_KEYWORDS {
426 assert_eq!(
427 *kw,
428 kw.to_lowercase(),
429 "keyword must be lowercase for case-insensitive matching"
430 );
431 }
432 }
433}