Skip to main content

just_shield/
lib.rs

1//! just-shield 검사 엔진.
2//!
3//! CLI(`main.rs`)는 이 라이브러리를 호출하는 얇은 껍데기다 (ADR-0004, 엔진/포장 분리).
4//! 모든 판정은 사실 기반이어야 한다 (ADR-0002) — 추측으로 빌드를 깨뜨리지 않는다.
5
6pub mod advisory;
7pub mod config;
8pub mod fix;
9pub mod github_facts;
10pub mod lockfile;
11pub mod report;
12pub mod rules;
13pub mod suppress;
14pub mod trust;
15pub mod typosquat;
16pub mod uses_ref;
17pub mod workflow;
18
19use github_facts::GithubFacts;
20use std::collections::BTreeMap;
21use std::path::Path;
22
23/// 한 저장소에 대한 스캔 결과.
24pub struct ScanResult {
25    pub workflows_scanned: usize,
26    /// 활성 발견 — 종료 코드와 집계는 이것만 본다.
27    pub findings: Vec<rules::Finding>,
28    /// 무시 주석으로 수용된 발견 — 사유와 함께 보존된다.
29    pub suppressed: Vec<rules::Suppressed>,
30    /// 오프라인 실행이라 온라인 규칙(R5·R10·LOCK 대조)을 건너뛰었는가 — 리포트에 안내.
31    pub online_rules_skipped: bool,
32}
33
34/// 스캔 동작 옵션.
35#[derive(Default)]
36pub struct ScanOptions<'a> {
37    pub facts: Option<&'a dyn GithubFacts>,
38    /// 쿨다운 기준 일수 — None이면 설정 파일, 그것도 없으면 7일.
39    pub cooldown_days: Option<u32>,
40}
41
42/// `lock` 실행 결과.
43pub struct LockOutcome {
44    /// 박제된 항목 수.
45    pub written: usize,
46    /// 해석하지 못해 건너뛴 참조와 사유.
47    pub skipped: Vec<(String, String)>,
48}
49
50/// 저장소 루트를 받아 `.github/workflows`의 모든 워크플로를 검사한다.
51/// 완전 오프라인 — 파일만 읽는다.
52pub fn scan(root: &Path) -> std::io::Result<ScanResult> {
53    scan_with_facts(root, None)
54}
55
56/// 외부 조회(`facts`)가 주어지면 온라인 규칙(R5·R10·LOCK 대조)을 수행한다.
57pub fn scan_with_facts(
58    root: &Path,
59    facts: Option<&dyn GithubFacts>,
60) -> std::io::Result<ScanResult> {
61    scan_with_options(
62        root,
63        &ScanOptions {
64            facts,
65            ..Default::default()
66        },
67    )
68}
69
70/// 모든 옵션을 받는 스캔 진입점.
71pub fn scan_with_options(root: &Path, options: &ScanOptions) -> std::io::Result<ScanResult> {
72    let facts = options.facts;
73    let loaded = config::load(root)?;
74    let cooldown_days = options.cooldown_days.or(loaded.cooldown_days).unwrap_or(7);
75    let now = std::time::SystemTime::now()
76        .duration_since(std::time::UNIX_EPOCH)
77        .map(|d| d.as_secs() as i64)
78        .unwrap_or(0);
79    let ctx = trust::TrustContext::new(trust::detect_repo_owner(root), loaded.trusted_owners);
80    let advisories = advisory::AdvisoryDb::bundled();
81    let lockfile = lockfile::load(root)?;
82    let workflows = workflow::find_workflows(root)?;
83    let mut findings = Vec::new();
84    let mut suppressed = Vec::new();
85    for wf in &workflows {
86        let content = std::fs::read_to_string(wf)?;
87        let rel = wf.strip_prefix(root).unwrap_or(wf);
88        let entries = workflow::extract_uses_entries(&content);
89        let doc = workflow::parse_workflow(&content);
90
91        let images = workflow::extract_image_refs(&content);
92
93        let mut file_findings = Vec::new();
94        file_findings.extend(rules::check_r1(rel, &entries, &ctx));
95        file_findings.extend(rules::check_r2(rel, &entries, &ctx, facts));
96        file_findings.extend(rules::check_r3(rel, &doc));
97        file_findings.extend(rules::check_r4(rel, &entries, &images));
98        file_findings.extend(rules::check_r6(rel, &doc, &ctx));
99        file_findings.extend(rules::check_r7(rel, &doc));
100        file_findings.extend(rules::check_r8(rel, &doc));
101        file_findings.extend(rules::check_r9(rel, &entries, &advisories));
102        if let Some(lf) = &lockfile {
103            file_findings.extend(rules::check_lock(rel, &entries, lf, facts, &ctx));
104        }
105        if let Some(facts) = facts {
106            file_findings.extend(rules::check_r5(rel, &entries, facts, &ctx));
107            file_findings.extend(rules::check_r10(
108                rel,
109                &entries,
110                facts,
111                &ctx,
112                cooldown_days,
113                now,
114            ));
115        }
116
117        // 탈출구 ①: 무시 주석 적용. 사유 없는 주석은 적용되지 않고 그 사실이 보고된다.
118        let directives = suppress::parse(&content);
119        for d in &directives {
120            if d.reason.is_none() {
121                file_findings.push(rules::Finding {
122                    rule: "IGNORE",
123                    severity: rules::Severity::Info,
124                    file: rel.display().to_string(),
125                    line: d.comment_line,
126                    uses: String::new(),
127                    evidence: "무시 주석에 사유가 없습니다 — `--` 뒤에 사유를 적지 않으면 무시가 적용되지 않습니다"
128                        .into(),
129                    fix_hint: "`# just-shield: ignore R1 -- <왜 수용하는지>` 형식으로 사유를 적으세요"
130                        .into(),
131                });
132            }
133        }
134        for f in file_findings {
135            let matched = directives.iter().find(|d| {
136                d.reason.is_some()
137                    && d.target_line == Some(f.line)
138                    && d.rules.iter().any(|r| r == f.rule)
139            });
140            match matched {
141                Some(d) => suppressed.push(rules::Suppressed {
142                    finding: f,
143                    reason: d.reason.clone().expect("reason은 위에서 확인됨"),
144                }),
145                None => findings.push(f),
146            }
147        }
148    }
149    findings.sort_by(|a, b| (&a.file, a.line, a.rule).cmp(&(&b.file, b.line, b.rule)));
150    suppressed.sort_by(|a, b| {
151        (&a.finding.file, a.finding.line, a.finding.rule).cmp(&(
152            &b.finding.file,
153            b.finding.line,
154            b.finding.rule,
155        ))
156    });
157    Ok(ScanResult {
158        workflows_scanned: workflows.len(),
159        findings,
160        suppressed,
161        online_rules_skipped: facts.is_none(),
162    })
163}
164
165/// 워크플로의 모든 가변 참조를 해석해 shield.lock으로 박제한다 (ADR-0003).
166pub fn lock(root: &Path, facts: &dyn GithubFacts) -> std::io::Result<LockOutcome> {
167    let workflows = workflow::find_workflows(root)?;
168    // BTreeSet 효과: 정렬 + 중복 제거 → 같은 입력이면 항상 같은 락파일.
169    let mut wanted: BTreeMap<(String, String), ()> = BTreeMap::new();
170    for wf in &workflows {
171        let content = std::fs::read_to_string(wf)?;
172        for e in workflow::extract_uses_entries(&content) {
173            if let uses_ref::UsesRef::Repository {
174                owner_repo,
175                git_ref: Some(uses_ref::RefKind::Mutable(r)),
176            } = uses_ref::parse(&e.value)
177            {
178                wanted.insert((uses_ref::repo_root(&owner_repo).to_string(), r), ());
179            }
180        }
181    }
182
183    let mut lf = lockfile::Lockfile::default();
184    let mut skipped = Vec::new();
185    for (repo, git_ref) in wanted.into_keys() {
186        match facts.resolve_ref(&repo, &git_ref) {
187            Ok(Some(sha)) => {
188                lf.entries
189                    .insert(lockfile::Lockfile::key(&repo, &git_ref), sha);
190            }
191            Ok(None) => skipped.push((
192                format!("{repo}@{git_ref}"),
193                "참조를 찾을 수 없음".to_string(),
194            )),
195            Err(e) => skipped.push((format!("{repo}@{git_ref}"), e.to_string())),
196        }
197    }
198    let written = lf.entries.len();
199    lockfile::save(root, &lf)?;
200    Ok(LockOutcome { written, skipped })
201}