1use crate::ScanResult;
6use crate::rules::Severity;
7
8fn marker(s: Severity) -> &'static str {
9 match s {
10 Severity::High => "🔴",
11 Severity::Medium => "🟡",
12 Severity::Info => "🔵",
13 }
14}
15
16pub fn render(result: &ScanResult, strict: bool) -> String {
18 let mut s = String::new();
19 s.push_str(&format!(
20 "just-shield scan — 워크플로 {}개 검사\n\n",
21 result.workflows_scanned
22 ));
23 if result.findings.is_empty() {
24 s.push_str("✅ 위반 없음 — 모든 액션 참조가 안전하게 핀 고정되어 있습니다\n");
25 if result.online_rules_skipped {
26 s.push_str(
27 "참고: 온라인 검사(R5 임포스터 커밋 · R10 쿨다운 · LOCK 태그 대조)는 --online 옵션에서 수행됩니다\n",
28 );
29 }
30 return s;
31 }
32 for f in &result.findings {
33 s.push_str(&format!(
34 "{} {} {}:{}\n",
35 marker(f.severity),
36 f.rule,
37 f.file,
38 f.line
39 ));
40 if !f.uses.is_empty() {
41 s.push_str(&format!(" uses: {}\n", f.uses));
42 }
43 s.push_str(&format!(" 근거: {}\n", f.evidence));
44 s.push_str(&format!(" 해결: {}\n\n", f.fix_hint));
45 }
46 if !result.suppressed.is_empty() {
47 s.push_str("무시됨 (사유 필수 주석으로 수용):\n");
48 for sp in &result.suppressed {
49 let f = &sp.finding;
50 s.push_str(&format!("⚪ {} {}:{}\n", f.rule, f.file, f.line));
51 if !f.uses.is_empty() {
52 s.push_str(&format!(" uses: {}\n", f.uses));
53 }
54 s.push_str(&format!(" 사유: {}\n\n", sp.reason));
55 }
56 }
57 let (high, medium, info) = tier_counts(result);
58 let status = if exit_code(result, strict) == 0 {
59 "통과"
60 } else {
61 "빌드 실패"
62 };
63 let suppressed = result.suppressed.len();
64 s.push_str(&format!(
65 "요약: 🔴 {high}건 · 🟡 {medium}건 · 🔵 {info}건 · ⚪ 무시 {suppressed}건 — {status}\n"
66 ));
67 if result.online_rules_skipped {
68 s.push_str(
69 "참고: 온라인 검사(R5 임포스터 커밋 · R10 쿨다운 · LOCK 태그 대조)는 --online 옵션에서 수행됩니다\n",
70 );
71 }
72 s
73}
74
75pub fn render_json(result: &ScanResult, strict: bool) -> String {
78 let (high, medium, info) = tier_counts(result);
79 let mut s = String::new();
80 s.push_str("{\n");
81 s.push_str(" \"version\": 1,\n");
82 s.push_str(&format!(
83 " \"workflows_scanned\": {},\n",
84 result.workflows_scanned
85 ));
86 s.push_str(&format!(
87 " \"summary\": {{ \"high\": {high}, \"medium\": {medium}, \"info\": {info}, \"suppressed\": {} }},\n",
88 result.suppressed.len()
89 ));
90 s.push_str(&format!(
91 " \"exit_code\": {},\n",
92 exit_code(result, strict)
93 ));
94 s.push_str(" \"findings\": [");
95 for (i, f) in result.findings.iter().enumerate() {
96 if i > 0 {
97 s.push(',');
98 }
99 s.push_str("\n {\n");
100 s.push_str(&format!(" \"rule\": \"{}\",\n", esc(f.rule)));
101 s.push_str(&format!(
102 " \"severity\": \"{}\",\n",
103 severity_name(f.severity)
104 ));
105 s.push_str(&format!(
106 " \"file\": \"{}\",\n",
107 esc(&f.file.replace('\\', "/"))
108 ));
109 s.push_str(&format!(" \"line\": {},\n", f.line));
110 s.push_str(&format!(" \"uses\": \"{}\",\n", esc(&f.uses)));
111 s.push_str(&format!(" \"evidence\": \"{}\",\n", esc(&f.evidence)));
112 s.push_str(&format!(" \"fix_hint\": \"{}\"\n", esc(&f.fix_hint)));
113 s.push_str(" }");
114 }
115 if result.findings.is_empty() {
116 s.push_str("],\n");
117 } else {
118 s.push_str("\n ],\n");
119 }
120 s.push_str(" \"suppressed\": [");
121 for (i, sp) in result.suppressed.iter().enumerate() {
122 if i > 0 {
123 s.push(',');
124 }
125 let f = &sp.finding;
126 s.push_str("\n {\n");
127 s.push_str(&format!(" \"rule\": \"{}\",\n", esc(f.rule)));
128 s.push_str(&format!(
129 " \"file\": \"{}\",\n",
130 esc(&f.file.replace('\\', "/"))
131 ));
132 s.push_str(&format!(" \"line\": {},\n", f.line));
133 s.push_str(&format!(" \"uses\": \"{}\",\n", esc(&f.uses)));
134 s.push_str(&format!(" \"reason\": \"{}\"\n", esc(&sp.reason)));
135 s.push_str(" }");
136 }
137 if result.suppressed.is_empty() {
138 s.push_str("]\n");
139 } else {
140 s.push_str("\n ]\n");
141 }
142 s.push_str("}\n");
143 s
144}
145
146const RULE_METADATA: &[(&str, &str)] = &[
149 (
150 "R1",
151 "서드파티 액션의 가변 참조(태그/브랜치) — 태그 하이재킹에 노출",
152 ),
153 ("R2", "유명 액션과 한 글자 차이 — 타이포스쿼팅 의심"),
154 ("R3", "curl | sh류 미검증 파이프 설치"),
155 ("R4", "다이제스트 없는 컨테이너 이미지 참조"),
156 (
157 "R5",
158 "핀된 SHA가 저장소 정식 히스토리에서 도달 불가 — 임포스터 커밋",
159 ),
160 ("R6", "시크릿을 쓰는 잡에서 서드파티 액션 실행"),
161 ("R7", "permissions 미선언 또는 write-all"),
162 (
163 "R8",
164 "위험 트리거(pull_request_target 등)와 외부 PR 체크아웃 조합",
165 ),
166 ("R9", "공개 권고에 악성으로 등재된 버전/커밋 사용"),
167 ("R10", "발행 후 쿨다운(검증 기간) 미경과 참조"),
168 ("LOCK", "shield.lock 박제본 대비 태그 이동"),
169 (
170 "EGRESS",
171 "잠근 잡이 egress.lock에 없는 목적지를 조회 — 유출 신호",
172 ),
173];
174
175fn sarif_level(s: Severity) -> &'static str {
176 match s {
177 Severity::High => "error",
178 Severity::Medium => "warning",
179 Severity::Info => "note",
180 }
181}
182
183fn rule_index(rule: &str) -> usize {
184 RULE_METADATA
185 .iter()
186 .position(|(id, _)| *id == rule)
187 .unwrap_or(0)
188}
189
190pub fn render_sarif(result: &ScanResult) -> String {
193 let mut s = String::new();
194 s.push_str("{\n");
195 s.push_str(" \"$schema\": \"https://json.schemastore.org/sarif-2.1.0.json\",\n");
196 s.push_str(" \"version\": \"2.1.0\",\n");
197 s.push_str(" \"runs\": [\n {\n");
198 s.push_str(" \"tool\": {\n \"driver\": {\n");
199 s.push_str(" \"name\": \"just-shield\",\n");
200 s.push_str(&format!(
201 " \"version\": \"{}\",\n",
202 env!("CARGO_PKG_VERSION")
203 ));
204 s.push_str(" \"informationUri\": \"https://github.com/kihyun1998/just-shield\",\n");
205 s.push_str(" \"rules\": [");
206 for (i, (id, desc)) in RULE_METADATA.iter().enumerate() {
207 if i > 0 {
208 s.push(',');
209 }
210 s.push_str(&format!(
211 "\n {{ \"id\": \"{}\", \"shortDescription\": {{ \"text\": \"{}\" }} }}",
212 esc(id),
213 esc(desc)
214 ));
215 }
216 s.push_str("\n ]\n }\n },\n");
217 s.push_str(" \"results\": [");
218 let mut first = true;
219 let mut push_result = |s: &mut String, f: &crate::rules::Finding, reason: Option<&str>| {
220 if !first {
221 s.push(',');
222 }
223 first = false;
224 let message = if f.uses.is_empty() {
225 format!("{} — 해결: {}", f.evidence, f.fix_hint)
226 } else {
227 format!("uses: {} — {} — 해결: {}", f.uses, f.evidence, f.fix_hint)
228 };
229 s.push_str("\n {\n");
230 s.push_str(&format!(" \"ruleId\": \"{}\",\n", esc(f.rule)));
231 s.push_str(&format!(
232 " \"ruleIndex\": {},\n",
233 rule_index(f.rule)
234 ));
235 s.push_str(&format!(
236 " \"level\": \"{}\",\n",
237 sarif_level(f.severity)
238 ));
239 s.push_str(&format!(
240 " \"message\": {{ \"text\": \"{}\" }},\n",
241 esc(&message)
242 ));
243 s.push_str(&format!(
244 " \"locations\": [{{ \"physicalLocation\": {{ \"artifactLocation\": {{ \"uri\": \"{}\" }}, \"region\": {{ \"startLine\": {} }} }} }}]",
245 esc(&f.file.replace('\\', "/")),
246 f.line.max(1)
247 ));
248 if let Some(reason) = reason {
249 s.push_str(&format!(
250 ",\n \"suppressions\": [{{ \"kind\": \"inSource\", \"justification\": \"{}\" }}]",
251 esc(reason)
252 ));
253 }
254 s.push_str("\n }");
255 };
256 for f in &result.findings {
257 push_result(&mut s, f, None);
258 }
259 for sp in &result.suppressed {
260 push_result(&mut s, &sp.finding, Some(&sp.reason));
261 }
262 if first {
263 s.push_str("]\n");
264 } else {
265 s.push_str("\n ]\n");
266 }
267 s.push_str(" }\n ]\n}\n");
268 s
269}
270
271fn severity_name(s: Severity) -> &'static str {
272 match s {
273 Severity::High => "high",
274 Severity::Medium => "medium",
275 Severity::Info => "info",
276 }
277}
278
279fn esc(s: &str) -> String {
281 let mut out = String::with_capacity(s.len());
282 for c in s.chars() {
283 match c {
284 '"' => out.push_str("\\\""),
285 '\\' => out.push_str("\\\\"),
286 '\n' => out.push_str("\\n"),
287 '\r' => out.push_str("\\r"),
288 '\t' => out.push_str("\\t"),
289 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
290 c => out.push(c),
291 }
292 }
293 out
294}
295
296pub fn exit_code(result: &ScanResult, strict: bool) -> u8 {
298 let (high, medium, _) = tier_counts(result);
299 if high > 0 || (strict && medium > 0) {
300 1
301 } else {
302 0
303 }
304}
305
306fn tier_counts(result: &ScanResult) -> (usize, usize, usize) {
307 let count = |sev| result.findings.iter().filter(|f| f.severity == sev).count();
308 (
309 count(Severity::High),
310 count(Severity::Medium),
311 count(Severity::Info),
312 )
313}