Skip to main content

just_shield/
trust.rs

1//! 신뢰 분류 — CONTEXT.md의 퍼스트파티/공식/서드파티 경계.
2//!
3//! 판별 불가(원격 없음, GitHub 아님)면 서드파티로 취급한다 — 분류 실패는
4//! 경고 누락이 아니라 과잉 경고 쪽으로 넘어진다 (fail-closed).
5
6use std::path::Path;
7
8/// 액션 소유자에 대한 신뢰 등급.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Trust {
11    /// 이 저장소와 같은 소유자 — 자기 코드의 연장, 섭취 검증 대상 아님.
12    FirstParty,
13    /// GitHub이 직접 관리하는 액션 — 서드파티지만 완화된 등급으로 보고.
14    Official,
15    /// 그 외 전부 — 평판과 무관하게 엄격 적용 (TeamPCP의 교훈).
16    ThirdParty,
17}
18
19const OFFICIAL_OWNERS: &[&str] = &["actions", "github"];
20
21/// 신뢰 판정에 필요한 문맥 — 저장소 소유자 + 설정으로 선언된 신뢰 org.
22pub struct TrustContext {
23    repo_owner: Option<String>,
24    trusted_owners: Vec<String>,
25}
26
27impl TrustContext {
28    pub fn new(repo_owner: Option<String>, trusted_owners: Vec<String>) -> Self {
29        Self {
30            repo_owner,
31            trusted_owners,
32        }
33    }
34
35    /// `owner/repo(/sub)` 참조의 소유자를 분류한다.
36    pub fn classify(&self, owner_repo: &str) -> Trust {
37        let owner = owner_repo.split('/').next().unwrap_or("");
38        if let Some(mine) = &self.repo_owner
39            && owner.eq_ignore_ascii_case(mine)
40        {
41            return Trust::FirstParty;
42        }
43        if self
44            .trusted_owners
45            .iter()
46            .any(|t| owner.eq_ignore_ascii_case(t))
47        {
48            return Trust::FirstParty;
49        }
50        if OFFICIAL_OWNERS
51            .iter()
52            .any(|o| owner.eq_ignore_ascii_case(o))
53        {
54            return Trust::Official;
55        }
56        Trust::ThirdParty
57    }
58}
59
60/// `.git/config`의 origin URL에서 GitHub 소유자를 읽는다. 실패하면 None.
61pub fn detect_repo_owner(root: &Path) -> Option<String> {
62    let config = std::fs::read_to_string(root.join(".git").join("config")).ok()?;
63    owner_from_git_config(&config)
64}
65
66fn owner_from_git_config(config: &str) -> Option<String> {
67    let mut in_origin = false;
68    for line in config.lines() {
69        let t = line.trim();
70        if t.starts_with('[') {
71            in_origin = t == r#"[remote "origin"]"#;
72            continue;
73        }
74        if !in_origin {
75            continue;
76        }
77        if let Some(url) = t
78            .strip_prefix("url")
79            .map(str::trim_start)
80            .and_then(|r| r.strip_prefix('='))
81        {
82            return owner_from_url(url.trim());
83        }
84    }
85    None
86}
87
88/// `https://github.com/owner/repo.git` 또는 `git@github.com:owner/repo.git`에서 owner 추출.
89fn owner_from_url(url: &str) -> Option<String> {
90    let rest = url.split("github.com").nth(1)?;
91    let rest = rest.strip_prefix(':').or_else(|| rest.strip_prefix('/'))?;
92    let owner = rest.split('/').next()?;
93    if owner.is_empty() {
94        None
95    } else {
96        Some(owner.to_string())
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::{Trust, TrustContext, owner_from_git_config};
103
104    fn ctx(repo_owner: Option<&str>, trusted: &[&str]) -> TrustContext {
105        TrustContext::new(
106            repo_owner.map(str::to_string),
107            trusted.iter().map(|s| s.to_string()).collect(),
108        )
109    }
110
111    #[test]
112    fn same_owner_is_first_party_case_insensitive() {
113        assert_eq!(
114            ctx(Some("myorg"), &[]).classify("MyOrg/tool"),
115            Trust::FirstParty
116        );
117    }
118
119    #[test]
120    fn configured_trusted_org_is_first_party() {
121        let c = ctx(Some("myorg"), &["partner-org"]);
122        assert_eq!(c.classify("Partner-Org/tool"), Trust::FirstParty);
123        assert_eq!(c.classify("stranger/tool"), Trust::ThirdParty);
124    }
125
126    #[test]
127    fn github_owned_actions_are_official() {
128        assert_eq!(
129            ctx(Some("myorg"), &[]).classify("actions/checkout"),
130            Trust::Official
131        );
132        assert_eq!(
133            ctx(None, &[]).classify("github/codeql-action"),
134            Trust::Official
135        );
136    }
137
138    #[test]
139    fn everyone_else_is_third_party_even_security_vendors() {
140        assert_eq!(
141            ctx(Some("myorg"), &[]).classify("aquasecurity/trivy-action"),
142            Trust::ThirdParty
143        );
144        // 소유자 판별 불가 → 안전한 쪽: 서드파티
145        assert_eq!(ctx(None, &[]).classify("myorg/tool"), Trust::ThirdParty);
146    }
147
148    #[test]
149    fn extracts_owner_from_https_and_ssh_urls() {
150        let https = "[remote \"origin\"]\n\turl = https://github.com/kihyun1998/just-shield.git\n";
151        assert_eq!(owner_from_git_config(https).as_deref(), Some("kihyun1998"));
152        let ssh = "[core]\n\tbare = false\n[remote \"origin\"]\n\turl = git@github.com:someorg/repo.git\n";
153        assert_eq!(owner_from_git_config(ssh).as_deref(), Some("someorg"));
154    }
155
156    #[test]
157    fn non_github_or_missing_origin_yields_none() {
158        let gitlab = "[remote \"origin\"]\n\turl = https://gitlab.com/o/r.git\n";
159        assert_eq!(owner_from_git_config(gitlab), None);
160        assert_eq!(owner_from_git_config("[core]\n\tbare = false\n"), None);
161    }
162}