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