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