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 secret_scanning_enabled,
53 secret_push_protection_enabled,
54 vulnerability_scanning_enabled,
55 code_scanning_enabled,
56 default_branch_protected,
57 enforce_admins,
58 dismiss_stale_reviews,
59 ) = collect_repo_settings(client, owner, repo).unwrap_or_else(|e| {
60 gaps.push(EvidenceGap::CollectionFailed {
61 source: "github".to_string(),
62 subject: "repository settings".to_string(),
63 detail: format!("{e:#}"),
64 });
65 (false, false, false, false, false, false, false)
66 });
67
68 let posture = RepositoryPosture {
69 codeowners_entries,
70 secret_scanning_enabled,
71 secret_push_protection_enabled,
72 vulnerability_scanning_enabled,
73 code_scanning_enabled,
74 security_policy_present,
75 security_policy_has_disclosure,
76 default_branch_protected,
77 enforce_admins,
78 dismiss_stale_reviews,
79 ..Default::default()
80 };
81
82 if gaps.is_empty() {
83 EvidenceState::complete(posture)
84 } else {
85 EvidenceState::partial(posture, gaps)
86 }
87}
88
89fn collect_codeowners(
91 client: &GitHubClient,
92 owner: &str,
93 repo: &str,
94 ref_sha: &str,
95) -> Vec<CodeownersEntry> {
96 for path in CODEOWNERS_PATHS {
97 if let Ok(content) = client.get_file_content(owner, repo, path, ref_sha) {
98 return parse_codeowners(&content);
99 }
100 }
101 Vec::new()
102}
103
104fn parse_codeowners(content: &str) -> Vec<CodeownersEntry> {
106 content
107 .lines()
108 .filter(|line| {
109 let trimmed = line.trim();
110 !trimmed.is_empty() && !trimmed.starts_with('#')
111 })
112 .filter_map(|line| {
113 let parts: Vec<&str> = line.split_whitespace().collect();
114 if parts.len() >= 2 {
115 Some(CodeownersEntry {
116 pattern: parts[0].to_string(),
117 owners: parts[1..].iter().map(|s| s.to_string()).collect(),
118 })
119 } else {
120 None
121 }
122 })
123 .collect()
124}
125
126fn collect_security_policy(
128 client: &GitHubClient,
129 owner: &str,
130 repo: &str,
131 ref_sha: &str,
132) -> (bool, bool) {
133 for path in SECURITY_POLICY_PATHS {
134 if let Ok(content) = client.get_file_content(owner, repo, path, ref_sha) {
135 let lower = content.to_lowercase();
136 let has_disclosure = DISCLOSURE_KEYWORDS.iter().any(|kw| lower.contains(kw));
137 return (true, has_disclosure);
138 }
139 }
140 (false, false)
141}
142
143#[derive(serde::Deserialize)]
145struct RepoResponse {
146 #[serde(default)]
147 default_branch: String,
148 #[serde(default)]
149 security_and_analysis: Option<SecurityAndAnalysis>,
150}
151
152#[derive(serde::Deserialize)]
153struct SecurityAndAnalysis {
154 #[serde(default)]
155 secret_scanning: Option<SecurityFeature>,
156 #[serde(default)]
157 secret_scanning_push_protection: Option<SecurityFeature>,
158 #[serde(default)]
159 dependabot_security_updates: Option<SecurityFeature>,
160}
161
162#[derive(serde::Deserialize)]
163struct SecurityFeature {
164 status: String,
165}
166
167#[derive(serde::Deserialize)]
169struct BranchProtectionResponse {
170 #[serde(default)]
171 enforce_admins: Option<EnforceAdmins>,
172 #[serde(default)]
173 required_pull_request_reviews: Option<RequiredPullRequestReviews>,
174}
175
176#[derive(serde::Deserialize)]
177struct EnforceAdmins {
178 enabled: bool,
179}
180
181#[derive(serde::Deserialize)]
182struct RequiredPullRequestReviews {
183 #[serde(default)]
184 dismiss_stale_reviews: bool,
185}
186
187fn collect_repo_settings(
189 client: &GitHubClient,
190 owner: &str,
191 repo: &str,
192) -> anyhow::Result<(bool, bool, bool, bool, bool, bool, bool)> {
193 let path = format!("/repos/{owner}/{repo}");
194 let body = client.get(&path)?;
195 let resp: RepoResponse = serde_json::from_str(&body)?;
196
197 let (secret_scanning, push_protection, dependabot) = match resp.security_and_analysis.as_ref() {
198 Some(sa) => (
199 sa.secret_scanning
200 .as_ref()
201 .is_some_and(|f| f.status == "enabled"),
202 sa.secret_scanning_push_protection
203 .as_ref()
204 .is_some_and(|f| f.status == "enabled"),
205 sa.dependabot_security_updates
206 .as_ref()
207 .is_some_and(|f| f.status == "enabled"),
208 ),
209 None => (false, false, false),
210 };
211
212 let code_scanning = client
214 .get(&format!(
215 "/repos/{owner}/{repo}/code-scanning/analyses?per_page=1"
216 ))
217 .map(|body| {
218 serde_json::from_str::<Vec<serde_json::Value>>(&body)
219 .map(|v| !v.is_empty())
220 .unwrap_or(false)
221 })
222 .unwrap_or(false);
223
224 let default_branch = &resp.default_branch;
226 let protection_url = format!("/repos/{owner}/{repo}/branches/{default_branch}/protection");
227 let (branch_protected, enforce_admins, dismiss_stale_reviews) =
228 match client.get(&protection_url) {
229 Ok(bp_body) => {
230 let bp: BranchProtectionResponse =
231 serde_json::from_str(&bp_body).unwrap_or(BranchProtectionResponse {
232 enforce_admins: None,
233 required_pull_request_reviews: None,
234 });
235 let enforce = bp
236 .enforce_admins
237 .as_ref()
238 .is_some_and(|ea| ea.enabled);
239 let dismiss = bp
240 .required_pull_request_reviews
241 .as_ref()
242 .is_some_and(|pr| pr.dismiss_stale_reviews);
243 (true, enforce, dismiss)
244 }
245 Err(_) => (false, false, false),
246 };
247
248 Ok((
249 secret_scanning,
250 push_protection,
251 dependabot,
252 code_scanning,
253 branch_protected,
254 enforce_admins,
255 dismiss_stale_reviews,
256 ))
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 #[test]
264 fn parse_codeowners_basic() {
265 let content = "# Global owners\n* @org/team\n/src/auth/ @alice @bob\n";
266 let entries = parse_codeowners(content);
267 assert_eq!(entries.len(), 2);
268 assert_eq!(entries[0].pattern, "*");
269 assert_eq!(entries[0].owners, vec!["@org/team"]);
270 assert_eq!(entries[1].pattern, "/src/auth/");
271 assert_eq!(entries[1].owners, vec!["@alice", "@bob"]);
272 }
273
274 #[test]
275 fn parse_codeowners_empty_and_comments() {
276 let content = "# comment\n\n # another comment\n";
277 let entries = parse_codeowners(content);
278 assert!(entries.is_empty());
279 }
280
281 #[test]
282 fn parse_codeowners_single_column_skipped() {
283 let content = "*.rs\n";
284 let entries = parse_codeowners(content);
285 assert!(entries.is_empty(), "single-column lines have no owners");
286 }
287
288 #[test]
289 fn disclosure_keywords_are_lowercase() {
290 for kw in DISCLOSURE_KEYWORDS {
291 assert_eq!(
292 *kw,
293 kw.to_lowercase(),
294 "keyword must be lowercase for case-insensitive matching"
295 );
296 }
297 }
298}