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    // --- CODEOWNERS ---
44    let codeowners_entries = collect_codeowners(client, owner, repo, ref_sha);
45
46    // --- SECURITY.md ---
47    let (security_policy_present, security_policy_has_disclosure) =
48        collect_security_policy(client, owner, repo, ref_sha);
49
50    // --- Repository settings (secret scanning, vulnerability scanning, branch protection) ---
51    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
84/// Parse CODEOWNERS file content into entries.
85fn 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
99/// Parse CODEOWNERS content into structured entries.
100fn 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
121/// Check for SECURITY.md and whether it describes a disclosure process.
122fn 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/// Repository settings response (subset of GET /repos/{owner}/{repo}).
139#[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
162/// Fetch repository settings from the GitHub REST API.
163fn 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    // Code scanning: check if any code-scanning analyses exist (non-empty = enabled)
188    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    // Branch protection
200    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}