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