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