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