1pub 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
26pub struct ScanResult {
28 pub workflows_scanned: usize,
29 pub findings: Vec<rules::Finding>,
31 pub suppressed: Vec<rules::Suppressed>,
33 pub online_rules_skipped: bool,
35}
36
37#[derive(Default)]
39pub struct ScanOptions<'a> {
40 pub facts: Option<&'a dyn GithubFacts>,
41 pub cooldown_days: Option<u32>,
43}
44
45pub struct LockOutcome {
47 pub written: usize,
49 pub skipped: Vec<(String, String)>,
51}
52
53pub fn scan(root: &Path) -> std::io::Result<ScanResult> {
56 scan_with_facts(root, None)
57}
58
59pub 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
73pub 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 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
168pub fn lock(root: &Path, facts: &dyn GithubFacts) -> std::io::Result<LockOutcome> {
170 let workflows = workflow::find_workflows(root)?;
171 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}