From 1a79f465da169f10c417ed6d56d4fea6032df816 Mon Sep 17 00:00:00 2001 From: Rinjae Date: Fri, 31 Jul 2026 17:31:45 +0900 Subject: [PATCH] =?UTF-8?q?"SBOM=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=20=EC=B6=94=EA=B0=80=20-=20CycloneDX=20->=20?= =?UTF-8?q?Excel(xlsx)=20=EC=A7=80=EC=9B=90=20-=20Jenkins=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=EC=97=90=20SBOM=20=EB=B9=8C=EB=93=9C=20=EB=B0=8F=20?= =?UTF-8?q?=EC=95=84=ED=8B=B0=ED=8C=A9=ED=8A=B8=20=EB=B3=B4=EA=B4=80=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 5 +- gradle/sbom-xlsx.gradle | 326 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 gradle/sbom-xlsx.gradle diff --git a/build.gradle b/build.gradle index 698e00a..b8c84e6 100644 --- a/build.gradle +++ b/build.gradle @@ -6,6 +6,7 @@ plugins { id 'eclipse' id 'idea' id 'war' + id 'org.cyclonedx.bom' version '3.2.4' } bootJar.enabled = true @@ -125,4 +126,6 @@ war { bootWar { archiveFileName = "elink-test-master.war" from('src/main/resources/weblogic.xml') { into 'WEB-INF' } -} \ No newline at end of file +} +// CycloneDX SBOM -> xlsx 변환 (gradle sbomXlsx) +apply from: "$projectDir/gradle/sbom-xlsx.gradle" diff --git a/gradle/sbom-xlsx.gradle b/gradle/sbom-xlsx.gradle new file mode 100644 index 0000000..8e506d8 --- /dev/null +++ b/gradle/sbom-xlsx.gradle @@ -0,0 +1,326 @@ +/* + * CycloneDX SBOM(bom.json) -> Excel(xlsx) 변환 태스크. + * + * gradle sbomXlsx # cyclonedxBom 실행 후 변환 + * gradle sbomXlsx -PsbomJson=path.json # 기존 bom.json 사용(cyclonedxBom 생략) + * gradle sbomXlsx -PsbomOut=out.xlsx # 출력 경로 지정 + * + * buildscript 블록이 이 스크립트에만 적용되므로 POI 의존성이 메인 빌드 + * classpath 나 WAR 산출물에는 포함되지 않는다. + * + * 시트: 요약 / WAR 기준 + * 산출 기준은 war 태스크의 classpath(= runtimeClasspath) 이므로 + * test·annotationProcessor·developmentOnly·compileOnly 의존은 모두 제외된다. + * bom.json 은 라이선스/해시/설명/직접-전이 판별을 위한 메타 소스로만 쓴다. + */ +buildscript { + repositories { + maven { + url "https://nexus.eactive.synology.me:8090/repository/maven-public/" + allowInsecureProtocol = true + } + mavenCentral() + } + dependencies { + classpath 'org.apache.poi:poi-ooxml:3.17' + } +} + +import groovy.json.JsonSlurper +import org.apache.poi.ss.usermodel.BorderStyle +import org.apache.poi.ss.usermodel.FillPatternType +import org.apache.poi.ss.usermodel.HorizontalAlignment +import org.apache.poi.ss.usermodel.IndexedColors +import org.apache.poi.ss.usermodel.VerticalAlignment +import org.apache.poi.ss.util.CellRangeAddress +import org.apache.poi.xssf.usermodel.XSSFWorkbook + +// 엑셀 셀 문자열 상한(32767)보다 여유를 둔 절단 길이 +ext.SBOM_CELL_LIMIT = 32000 + +task sbomXlsx { + group = 'sbom' + description = 'CycloneDX bom.json 을 WAR 수록 기준 xlsx 로 변환한다' + + // -PsbomJson 으로 기존 산출물을 지정하면 재생성하지 않는다 + if (!project.hasProperty('sbomJson')) { + dependsOn 'cyclonedxBom' + } + + doLast { + File src = resolveBomJson(project) + File out = project.hasProperty('sbomOut') + ? project.file(project.property('sbomOut')) + : new File(project.buildDir, "reports/sbom/${sbomFileName(project)}") + out.parentFile.mkdirs() + + def bom = new JsonSlurper().parse(src, 'UTF-8') + def deploy = collectDeployJars(project) + def warRows = joinWarRows(deploy.jars, indexComponents(bom)) + + def wb = new XSSFWorkbook() + def st = createStyles(wb) + writeSummarySheet(wb, st, bom, warRows, src, deploy.label) + writeWarSheet(wb, st, warRows) + + out.withOutputStream { os -> wb.write(os) } + wb.close() + + int unmatched = warRows.count { it.matched == 'N' } + logger.lifecycle("SBOM xlsx 생성: ${out.absolutePath} " + + "(배포 수록 ${warRows.size()}개, SBOM 미매칭 ${unmatched}개, 원본 ${src.name})") + } +} + +/** 산출 파일명: 배포 패키지명 기준 (war 있으면 war 파일명, 없으면 project 이름[-버전]) */ +String sbomFileName(Project p) { + def warTask = p.tasks.findByName('war') + if (warTask != null) { + String archive = warTask.archiveFileName.get() + return archive.replaceAll(/\.(war|jar|ear)$/, '') + '-sbom.xlsx' + } + String ver = (p.version == null || p.version.toString() in ['', 'unspecified']) ? '' : "-${p.version}" + return "${p.name}${ver}-sbom.xlsx" +} + +/** bom.json 위치 결정: -PsbomJson > cyclonedxBom 산출 경로 후보 */ +File resolveBomJson(Project p) { + if (p.hasProperty('sbomJson')) { + File f = p.file(p.property('sbomJson')) + if (!f.exists()) { + throw new GradleException("bom.json 없음: ${f.absolutePath}") + } + return f + } + def candidates = [ + new File(p.buildDir, 'reports/cyclonedx/bom.json'), + new File(p.buildDir, 'reports/bom.json'), + ] + File found = candidates.find { it.exists() } + if (found == null) { + throw new GradleException( + "bom.json 을 찾지 못했다. 확인한 경로: " + candidates*.absolutePath.join(', ') + + "\n'gradle cyclonedxBom' 실행 후 재시도하거나 -PsbomJson=<경로> 로 지정한다.") + } + return found +} + +/** + * 실제 배포물에 packaging 되는 jar 목록. + * war 프로젝트는 war 태스크 classpath(= runtimeClasspath), 그 외는 runtimeClasspath 를 + * 기준으로 하므로 test/annotationProcessor/developmentOnly/compileOnly 는 자동으로 빠진다. + * + * @return [label: 기준 설명, jars: 행 목록] + */ +Map collectDeployJars(Project p) { + def cfg = p.configurations.findByName('runtimeClasspath') + if (cfg == null) { + p.logger.warn("[${p.name}] runtimeClasspath 가 없어 배포 기준 시트를 비운다") + return [label: '(없음)', jars: []] + } + + def warTask = p.tasks.findByName('war') + def files + String label + if (warTask != null) { + files = warTask.classpath.files + label = 'WAR WEB-INF/lib (war 태스크 classpath)' + } else { + files = cfg.files + label = 'runtimeClasspath (war 태스크 없음)' + } + + def coordByFile = [:] + cfg.resolvedConfiguration.resolvedArtifacts.each { a -> + def id = a.moduleVersion.id + coordByFile[a.file] = [group: id.group, name: id.name, version: id.version] + } + def jars = files.findAll { it.name.endsWith('.jar') }.collect { f -> + def c = coordByFile[f] + [ + file : f.name, + group : c?.group ?: '', + name : c?.name ?: f.name.replaceAll(/\.jar$/, ''), + version: c?.version ?: '', + coord : c ? "${c.group}:${c.name}:${c.version}".toString() : '', + ] + }.sort { it.file } + + return [label: label, jars: jars] +} + +/** bom.json 컴포넌트를 'group:name:version' 키로 색인 (라이선스/해시/설명/직접-전이) */ +Map indexComponents(bom) { + String rootRef = bom.metadata?.component?.'bom-ref' + Set directRefs = (bom.dependencies?.find { it.ref == rootRef }?.dependsOn ?: []) as Set + + def index = [:] + bom.components?.each { c -> + def hashes = [:] + c.hashes?.each { h -> hashes[h.alg] = h.content } + + def licenses = (c.licenses ?: []).collect { l -> + l.license?.id ?: l.license?.name ?: l.expression ?: '' + }.findAll { it } + + index["${c.group ?: ''}:${c.name ?: ''}:${c.version ?: ''}".toString()] = [ + direct : directRefs.contains(c.'bom-ref') ? '직접' : '전이', + licenses : licenses.join('; '), + licenseList: licenses.isEmpty() ? ['(미상)'] : licenses, + purl : c.purl ?: '', + sha256 : hashes['SHA-256'] ?: '', + sha1 : hashes['SHA-1'] ?: '', + description: c.description ?: '', + ] + } + return index +} + +/** WAR jar 목록에 SBOM 메타를 좌표로 결합 */ +List joinWarRows(List warJars, Map index) { + def result = [] + warJars.eachWithIndex { j, i -> + def m = j.coord ? index[j.coord] : null + result << [ + no : i + 1, + file : j.file, + group : j.group, + name : j.name, + version : j.version, + direct : m?.direct ?: '', + licenses : m?.licenses ?: '', + licenseList: m?.licenseList ?: ['(미상)'], + purl : m?.purl ?: '', + sha256 : m?.sha256 ?: '', + sha1 : m?.sha1 ?: '', + matched : (m != null) ? 'Y' : 'N', + description: m?.description ?: '', + ] + } + return result +} + +Map createStyles(wb) { + def headFont = wb.createFont() + headFont.setBold(true) + headFont.setColor(IndexedColors.WHITE.getIndex()) + + def head = wb.createCellStyle() + head.setFont(headFont) + head.setFillForegroundColor(IndexedColors.DARK_BLUE.getIndex()) + head.setFillPattern(FillPatternType.SOLID_FOREGROUND) + head.setAlignment(HorizontalAlignment.CENTER) + head.setVerticalAlignment(VerticalAlignment.CENTER) + head.setBorderBottom(BorderStyle.THIN) + + def body = wb.createCellStyle() + body.setVerticalAlignment(VerticalAlignment.TOP) + + def wrap = wb.createCellStyle() + wrap.setVerticalAlignment(VerticalAlignment.TOP) + wrap.setWrapText(true) + + def labelFont = wb.createFont() + labelFont.setBold(true) + def label = wb.createCellStyle() + label.setFont(labelFont) + + return [head: head, body: body, wrap: wrap, label: label] +} + +/** 헤더 행 생성 + 폭 지정 + 틀고정 */ +def writeHeader(sheet, style, List headers, List widths) { + def row = sheet.createRow(0) + row.setHeightInPoints(20f) + headers.eachWithIndex { h, i -> + def cell = row.createCell(i) + cell.setCellValue(h) + cell.setCellStyle(style) + sheet.setColumnWidth(i, widths[i] * 256) + } + sheet.createFreezePane(0, 1) +} + +def cellOf(row, int idx, value, style) { + def cell = row.createCell(idx) + String s = (value == null) ? '' : value.toString() + if (s.length() > SBOM_CELL_LIMIT) { + s = s.substring(0, SBOM_CELL_LIMIT) + '…(생략)' + } + cell.setCellValue(s) + cell.setCellStyle(style) + return cell +} + +def writeSummarySheet(wb, st, bom, List warRows, File src, String basisLabel) { + def sheet = wb.createSheet('요약') + def comp = bom.metadata?.component ?: [:] + def tool = bom.metadata?.tools?.components?.getAt(0) + Set licenseKinds = warRows.collectMany { it.licenseList } as Set + + def items = [ + ['대상 프로젝트', "${comp.group ?: ''}:${comp.name ?: ''}:${comp.version ?: ''}"], + ['산출 기준', "${basisLabel} — test/annotationProcessor/compileOnly 제외"], + ['BOM 포맷', "${bom.bomFormat ?: ''} ${bom.specVersion ?: ''}"], + ['serialNumber', bom.serialNumber ?: ''], + ['생성 시각', bom.metadata?.timestamp ?: ''], + ['생성 도구', tool ? "${tool.name} ${tool.version}" : ''], + ['원본 파일', src.absolutePath], + ['배포 수록 jar', warRows.size()], + [' └ 직접 의존', warRows.count { it.direct == '직접' }], + [' └ 전이 의존', warRows.count { it.direct == '전이' }], + [' └ SBOM 미매칭', warRows.count { it.matched == 'N' }], + ['라이선스 종류', licenseKinds.size()], + ['라이선스 미상', warRows.count { it.licenses.isEmpty() }], + ] + + writeHeader(sheet, st.head, ['항목', '값'], [30, 90]) + items.eachWithIndex { item, i -> + def row = sheet.createRow(i + 1) + cellOf(row, 0, item[0], st.label) + cellOf(row, 1, item[1], st.body) + } + + // 라이선스 분포 (요약 하단) + def byLicense = [:].withDefault { 0 } + warRows.each { r -> r.licenseList.each { lic -> byLicense[lic] = byLicense[lic] + 1 } } + def sorted = byLicense.entrySet().sort { a, b -> (b.value <=> a.value) ?: (a.key <=> b.key) } + + int base = items.size() + 2 + def hdr = sheet.createRow(base) + cellOf(hdr, 0, '라이선스', st.head) + cellOf(hdr, 1, 'jar 수', st.head) + sorted.eachWithIndex { e, i -> + def row = sheet.createRow(base + 1 + i) + cellOf(row, 0, e.key, st.body) + cellOf(row, 1, e.value, st.body) + } +} + +/** 실제 배포물(WAR WEB-INF/lib) 기준 시트 */ +def writeWarSheet(wb, st, List warRows) { + def sheet = wb.createSheet('WAR 기준') + def headers = ['No', 'jar 파일명', 'Group', 'Name', 'Version', '구분', + 'License', 'purl', 'SHA-256', 'SHA-1', 'SBOM매칭', 'Description'] + def widths = [6, 46, 32, 34, 16, 7, 30, 60, 40, 30, 10, 60] + writeHeader(sheet, st.head, headers, widths) + + warRows.eachWithIndex { r, i -> + def row = sheet.createRow(i + 1) + cellOf(row, 0, r.no, st.body) + cellOf(row, 1, r.file, st.body) + cellOf(row, 2, r.group, st.body) + cellOf(row, 3, r.name, st.body) + cellOf(row, 4, r.version, st.body) + cellOf(row, 5, r.direct, st.body) + cellOf(row, 6, r.licenses, st.body) + cellOf(row, 7, r.purl, st.body) + cellOf(row, 8, r.sha256, st.body) + cellOf(row, 9, r.sha1, st.body) + cellOf(row, 10, r.matched, st.body) + cellOf(row, 11, r.description, st.wrap) + } + if (!warRows.isEmpty()) { + sheet.setAutoFilter(new CellRangeAddress(0, warRows.size(), 0, headers.size() - 1)) + } +}