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