1pub 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
23pub struct ScanResult {
25 pub workflows_scanned: usize,
26 pub findings: Vec<rules::Finding>,
28 pub suppressed: Vec<rules::Suppressed>,
30 pub online_rules_skipped: bool,
32}
33
34#[derive(Default)]
36pub struct ScanOptions<'a> {
37 pub facts: Option<&'a dyn GithubFacts>,
38 pub cooldown_days: Option<u32>,
40}
41
42pub struct LockOutcome {
44 pub written: usize,
46 pub skipped: Vec<(String, String)>,
48}
49
50pub fn scan(root: &Path) -> std::io::Result<ScanResult> {
53 scan_with_facts(root, None)
54}
55
56pub 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
70pub 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 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
165pub fn lock(root: &Path, facts: &dyn GithubFacts) -> std::io::Result<LockOutcome> {
167 let workflows = workflow::find_workflows(root)?;
168 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}