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