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