Skip to main content

libverify_github/
posture.rs

1//! Repository posture evidence collection for GitHub repositories.
2//!
3//! Collects repository-level security configuration signals:
4//! CODEOWNERS, SECURITY.md, secret scanning, vulnerability scanning,
5//! and branch protection settings.
6
7use libverify_core::evidence::{CodeownersEntry, EvidenceGap, EvidenceState, RepositoryPosture};
8
9use crate::client::GitHubClient;
10
11/// Candidate paths for the CODEOWNERS file (GitHub resolves in this order).
12const CODEOWNERS_PATHS: &[&str] = &["CODEOWNERS", "docs/CODEOWNERS", ".github/CODEOWNERS"];
13
14/// Candidate paths for the security policy file.
15const SECURITY_POLICY_PATHS: &[&str] = &["SECURITY.md", "docs/SECURITY.md", ".github/SECURITY.md"];
16
17/// Disclosure keywords that indicate a responsible disclosure process.
18const 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
29/// Collect repository posture evidence from the GitHub API.
30///
31/// Fetches CODEOWNERS, SECURITY.md, and repository settings to populate
32/// `RepositoryPosture`. Uses `ref_sha` for file lookups. Falls back to
33/// `EvidenceState::Missing` when the API call fails entirely, or
34/// `EvidenceState::Partial` when some data could not be collected.
35pub 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    // Run independent API calls concurrently using scoped threads.
44    // Each call hits different endpoints so there's no contention.
45    let (
46        codeowners_entries,
47        security_policy,
48        settings_result,
49        dep_tool,
50        permissions_result,
51        tag_protection,
52    ) = std::thread::scope(|s| {
53        let h_codeowners = s.spawn(|| collect_codeowners(client, owner, repo, ref_sha));
54        let h_security = s.spawn(|| collect_security_policy(client, owner, repo, ref_sha));
55        let h_settings = s.spawn(|| collect_repo_settings(client, owner, repo));
56        let h_dep_tool = s.spawn(|| collect_dependency_update_tool(client, owner, repo, ref_sha));
57        let h_permissions = s.spawn(|| collect_permissions_info(client, owner, repo));
58        let h_tag = s.spawn(|| collect_tag_protection(client, owner, repo));
59
60        (
61            h_codeowners.join().unwrap(),
62            h_security.join().unwrap(),
63            h_settings.join().unwrap(),
64            h_dep_tool.join().unwrap(),
65            h_permissions.join().unwrap(),
66            h_tag.join().unwrap(),
67        )
68    });
69
70    let (security_policy_present, security_policy_has_disclosure) = security_policy;
71
72    let (
73        security_analysis_available,
74        secret_scanning_enabled,
75        secret_push_protection_enabled,
76        vulnerability_scanning_enabled,
77        code_scanning_enabled,
78        default_branch_protected,
79        enforce_admins,
80        dismiss_stale_reviews,
81    ) = match settings_result {
82        Ok(settings) => (
83            settings.security_analysis_available,
84            settings.secret_scanning,
85            settings.push_protection,
86            settings.dependabot,
87            settings.code_scanning,
88            settings.branch_protected,
89            settings.enforce_admins,
90            settings.dismiss_stale_reviews,
91        ),
92        Err(e) => {
93            gaps.push(EvidenceGap::CollectionFailed {
94                source: "github".to_string(),
95                subject: "repository settings".to_string(),
96                detail: format!("{e:#}"),
97            });
98            (false, false, false, false, false, false, false, false)
99        }
100    };
101
102    let (default_workflow_permissions, admin_count, direct_collaborator_count) =
103        match permissions_result {
104            Ok(info) => (
105                info.default_workflow_permissions,
106                info.admin_count,
107                info.direct_collaborator_count,
108            ),
109            Err(e) => {
110                gaps.push(EvidenceGap::CollectionFailed {
111                    source: "github".to_string(),
112                    subject: "permissions info".to_string(),
113                    detail: format!("{e:#}"),
114                });
115                (String::new(), 0, 0)
116            }
117        };
118
119    let posture = RepositoryPosture {
120        codeowners_entries,
121        security_analysis_available,
122        secret_scanning_enabled,
123        secret_push_protection_enabled,
124        vulnerability_scanning_enabled,
125        code_scanning_enabled,
126        security_policy_present,
127        security_policy_has_disclosure,
128        default_branch_protected,
129        enforce_admins,
130        dismiss_stale_reviews,
131        dependency_update_tool_configured: dep_tool,
132        default_workflow_permissions,
133        admin_count,
134        direct_collaborator_count,
135        tag_protection_enabled: tag_protection,
136        ..Default::default()
137    };
138
139    if gaps.is_empty() {
140        EvidenceState::complete(posture)
141    } else {
142        EvidenceState::partial(posture, gaps)
143    }
144}
145
146/// Parse CODEOWNERS file content into entries.
147fn collect_codeowners(
148    client: &GitHubClient,
149    owner: &str,
150    repo: &str,
151    ref_sha: &str,
152) -> Vec<CodeownersEntry> {
153    for path in CODEOWNERS_PATHS {
154        if let Ok(content) = client.get_file_content(owner, repo, path, ref_sha) {
155            return parse_codeowners(&content);
156        }
157    }
158    Vec::new()
159}
160
161/// Parse CODEOWNERS content into structured entries.
162fn parse_codeowners(content: &str) -> Vec<CodeownersEntry> {
163    content
164        .lines()
165        .filter(|line| {
166            let trimmed = line.trim();
167            !trimmed.is_empty() && !trimmed.starts_with('#')
168        })
169        .filter_map(|line| {
170            let parts: Vec<&str> = line.split_whitespace().collect();
171            if parts.len() >= 2 {
172                Some(CodeownersEntry {
173                    pattern: parts[0].to_string(),
174                    owners: parts[1..].iter().map(|s| s.to_string()).collect(),
175                })
176            } else {
177                None
178            }
179        })
180        .collect()
181}
182
183/// Check for SECURITY.md and whether it describes a disclosure process.
184fn collect_security_policy(
185    client: &GitHubClient,
186    owner: &str,
187    repo: &str,
188    ref_sha: &str,
189) -> (bool, bool) {
190    for path in SECURITY_POLICY_PATHS {
191        if let Ok(content) = client.get_file_content(owner, repo, path, ref_sha) {
192            let lower = content.to_lowercase();
193            let has_disclosure = DISCLOSURE_KEYWORDS.iter().any(|kw| lower.contains(kw));
194            return (true, has_disclosure);
195        }
196    }
197    (false, false)
198}
199
200/// Repository settings response (subset of GET /repos/{owner}/{repo}).
201#[derive(serde::Deserialize)]
202struct RepoResponse {
203    #[serde(default)]
204    default_branch: String,
205    #[serde(default)]
206    security_and_analysis: Option<SecurityAndAnalysis>,
207}
208
209#[derive(serde::Deserialize)]
210struct SecurityAndAnalysis {
211    #[serde(default)]
212    secret_scanning: Option<SecurityFeature>,
213    #[serde(default)]
214    secret_scanning_push_protection: Option<SecurityFeature>,
215    #[serde(default)]
216    dependabot_security_updates: Option<SecurityFeature>,
217}
218
219#[derive(serde::Deserialize)]
220struct SecurityFeature {
221    status: String,
222}
223
224/// Branch protection API response (subset).
225#[derive(serde::Deserialize)]
226struct BranchProtectionResponse {
227    #[serde(default)]
228    enforce_admins: Option<EnforceAdmins>,
229    #[serde(default)]
230    required_pull_request_reviews: Option<RequiredPullRequestReviews>,
231}
232
233#[derive(serde::Deserialize)]
234struct EnforceAdmins {
235    enabled: bool,
236}
237
238#[derive(serde::Deserialize)]
239struct RequiredPullRequestReviews {
240    #[serde(default)]
241    dismiss_stale_reviews: bool,
242}
243
244/// Result of collecting repository settings, including any evidence gaps
245/// that occurred during collection (e.g. insufficient API permissions).
246struct RepoSettingsResult {
247    security_analysis_available: bool,
248    secret_scanning: bool,
249    push_protection: bool,
250    dependabot: bool,
251    code_scanning: bool,
252    branch_protected: bool,
253    enforce_admins: bool,
254    dismiss_stale_reviews: bool,
255}
256
257/// Fetch repository settings from the GitHub REST API.
258fn collect_repo_settings(
259    client: &GitHubClient,
260    owner: &str,
261    repo: &str,
262) -> anyhow::Result<RepoSettingsResult> {
263    let path = format!("/repos/{owner}/{repo}");
264    let body = client.get(&path)?;
265    let resp: RepoResponse = serde_json::from_str(&body)?;
266    let security_analysis_available = resp.security_and_analysis.is_some();
267
268    let (secret_scanning, push_protection, dependabot) = match resp.security_and_analysis.as_ref() {
269        Some(sa) => (
270            sa.secret_scanning
271                .as_ref()
272                .is_some_and(|f| f.status == "enabled"),
273            sa.secret_scanning_push_protection
274                .as_ref()
275                .is_some_and(|f| f.status == "enabled"),
276            sa.dependabot_security_updates
277                .as_ref()
278                .is_some_and(|f| f.status == "enabled"),
279        ),
280        None => (false, false, false),
281    };
282
283    // Code scanning: check if any code-scanning analyses exist (non-empty = enabled)
284    let code_scanning = client
285        .get(&format!(
286            "/repos/{owner}/{repo}/code-scanning/analyses?per_page=1"
287        ))
288        .map(|body| {
289            serde_json::from_str::<Vec<serde_json::Value>>(&body)
290                .map(|v| !v.is_empty())
291                .unwrap_or(false)
292        })
293        .unwrap_or(false);
294
295    // Branch protection: parse response for detailed fields
296    let default_branch = &resp.default_branch;
297    let protection_url = format!("/repos/{owner}/{repo}/branches/{default_branch}/protection");
298    let (branch_protected, enforce_admins, dismiss_stale_reviews) =
299        match client.get(&protection_url) {
300            Ok(bp_body) => {
301                let bp: BranchProtectionResponse =
302                    serde_json::from_str(&bp_body).unwrap_or(BranchProtectionResponse {
303                        enforce_admins: None,
304                        required_pull_request_reviews: None,
305                    });
306                let enforce = bp.enforce_admins.as_ref().is_some_and(|ea| ea.enabled);
307                let dismiss = bp
308                    .required_pull_request_reviews
309                    .as_ref()
310                    .is_some_and(|pr| pr.dismiss_stale_reviews);
311                (true, enforce, dismiss)
312            }
313            Err(_) => (false, false, false),
314        };
315
316    Ok(RepoSettingsResult {
317        security_analysis_available,
318        secret_scanning,
319        push_protection,
320        dependabot,
321        code_scanning,
322        branch_protected,
323        enforce_admins,
324        dismiss_stale_reviews,
325    })
326}
327
328/// Dependency update tool config file paths to check.
329const DEPENDABOT_PATH: &str = ".github/dependabot.yml";
330const RENOVATE_PATHS: &[&str] = &[
331    "renovate.json",
332    "renovate.json5",
333    ".renovaterc",
334    ".renovaterc.json",
335];
336
337/// Check whether a dependency update tool is configured.
338fn collect_dependency_update_tool(
339    client: &GitHubClient,
340    owner: &str,
341    repo: &str,
342    ref_sha: &str,
343) -> bool {
344    // Check Dependabot
345    if client
346        .get_file_content(owner, repo, DEPENDABOT_PATH, ref_sha)
347        .is_ok()
348    {
349        return true;
350    }
351    // Check Renovate
352    for path in RENOVATE_PATHS {
353        if client.get_file_content(owner, repo, path, ref_sha).is_ok() {
354            return true;
355        }
356    }
357    false
358}
359
360/// Permissions info collected from the GitHub API.
361struct PermissionsInfo {
362    default_workflow_permissions: String,
363    admin_count: u32,
364    direct_collaborator_count: u32,
365}
366
367/// Collect workflow permissions and collaborator info.
368fn collect_permissions_info(
369    client: &GitHubClient,
370    owner: &str,
371    repo: &str,
372) -> anyhow::Result<PermissionsInfo> {
373    // GET /repos/{owner}/{repo} includes default_branch_permissions in some API versions
374    // For workflow permissions, we use the Actions permissions endpoint
375    let default_workflow_permissions = client
376        .get(&format!(
377            "/repos/{owner}/{repo}/actions/permissions/workflow"
378        ))
379        .ok()
380        .and_then(|body| {
381            serde_json::from_str::<serde_json::Value>(&body)
382                .ok()
383                .and_then(|v| v["default_workflow_permissions"].as_str().map(String::from))
384        })
385        .unwrap_or_default();
386
387    // GET /repos/{owner}/{repo}/collaborators?affiliation=direct
388    let (admin_count, direct_collaborator_count) = client
389        .get(&format!(
390            "/repos/{owner}/{repo}/collaborators?affiliation=direct&per_page=100"
391        ))
392        .ok()
393        .and_then(|body| serde_json::from_str::<Vec<serde_json::Value>>(&body).ok())
394        .map(|collabs| {
395            let admins = collabs
396                .iter()
397                .filter(|c| c["permissions"]["admin"].as_bool().unwrap_or(false))
398                .count() as u32;
399            let direct = collabs.len() as u32;
400            (admins, direct)
401        })
402        .unwrap_or((0, 0));
403
404    Ok(PermissionsInfo {
405        default_workflow_permissions,
406        admin_count,
407        direct_collaborator_count,
408    })
409}
410
411/// Check whether tag protection rules exist.
412fn collect_tag_protection(client: &GitHubClient, owner: &str, repo: &str) -> bool {
413    // Tag protection rules API requires admin access; returns 404 if no rules or no permission
414    client
415        .get(&format!("/repos/{owner}/{repo}/tags/protection"))
416        .ok()
417        .and_then(|body| serde_json::from_str::<Vec<serde_json::Value>>(&body).ok())
418        .is_some_and(|rules| !rules.is_empty())
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[test]
426    fn parse_codeowners_basic() {
427        let content = "# Global owners\n* @org/team\n/src/auth/ @alice @bob\n";
428        let entries = parse_codeowners(content);
429        assert_eq!(entries.len(), 2);
430        assert_eq!(entries[0].pattern, "*");
431        assert_eq!(entries[0].owners, vec!["@org/team"]);
432        assert_eq!(entries[1].pattern, "/src/auth/");
433        assert_eq!(entries[1].owners, vec!["@alice", "@bob"]);
434    }
435
436    #[test]
437    fn parse_codeowners_empty_and_comments() {
438        let content = "# comment\n\n  # another comment\n";
439        let entries = parse_codeowners(content);
440        assert!(entries.is_empty());
441    }
442
443    #[test]
444    fn parse_codeowners_single_column_skipped() {
445        let content = "*.rs\n";
446        let entries = parse_codeowners(content);
447        assert!(entries.is_empty(), "single-column lines have no owners");
448    }
449
450    #[test]
451    fn disclosure_keywords_are_lowercase() {
452        for kw in DISCLOSURE_KEYWORDS {
453            assert_eq!(
454                *kw,
455                kw.to_lowercase(),
456                "keyword must be lowercase for case-insensitive matching"
457            );
458        }
459    }
460}