Skip to main content

just_shield/
report.rs

1//! 터미널 리포트 출력과 종료 코드 정책.
2//!
3//! 기본: 🔴만 빌드 실패. `--strict`: 🟡도 실패로 승격. 🔵는 어떤 모드에서도 실패하지 않는다.
4
5use 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
16/// 사람용 터미널 리포트를 렌더링한다.
17pub 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
75/// 기계용 JSON 리포트. 스키마는 README에 문서화되어 있으며 스냅숏 테스트로 고정된다.
76/// 경로 구분자는 플랫폼과 무관하게 `/`로 정규화한다 — 파싱 스크립트가 OS를 타지 않도록.
77pub 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
146/// SARIF 규칙 메타데이터 — id와 짧은 설명. `ruleIndex`는 이 배열의 위치다.
147/// 결과에 등장하지 않는 규칙도 항상 전부 싣는다 — 출력이 입력에 따라 흔들리지 않도록.
148const 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
190/// SARIF 2.1.0 리포트 — GitHub 코드 스캐닝 업로드용.
191/// 무시된 발견은 결과에서 빠지지 않고 `suppressions`로 표시된다 (침묵 ≠ 은폐).
192pub 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
279/// JSON 문자열 이스케이프 — 따옴표·역슬래시·제어 문자.
280fn 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
296/// 종료 코드: 🔴 있으면 1, `--strict`면 🟡도 1, 그 외 0. (사용법 오류는 main에서 2.)
297pub 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}