/* * 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)) } }