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        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
103/// Parse CODEOWNERS file content into entries.
104fn 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
118/// Parse CODEOWNERS content into structured entries.
119fn 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
140/// Check for SECURITY.md and whether it describes a disclosure process.
141fn 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/// Repository settings response (subset of GET /repos/{owner}/{repo}).
158#[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/// Branch protection API response (subset).
182#[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
201/// Result of collecting repository settings, including any evidence gaps
202/// that occurred during collection (e.g. insufficient API permissions).
203struct 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
214/// Fetch repository settings from the GitHub REST API.
215fn 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    // Code scanning: check if any code-scanning analyses exist (non-empty = enabled)
241    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    // Branch protection: parse response for detailed fields
253    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}