From ea3ea67309e02f61d259db3b889ee67f96300c2a Mon Sep 17 00:00:00 2001 From: cgombauld Date: Sun, 30 Nov 2025 20:03:03 +0100 Subject: [PATCH] fix(report): handle Export PDF fail when scorecard results fail with 404 --- src/analysis/extractScannerData.ts | 26 ++++++++++++---------- src/analysis/fetch.ts | 26 ++++++++++++++++++++++ src/api/report.ts | 15 +++++++------ test/api/report.spec.ts | 35 ++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 18 deletions(-) diff --git a/src/analysis/extractScannerData.ts b/src/analysis/extractScannerData.ts index f57c3c7..cb70c1b 100644 --- a/src/analysis/extractScannerData.ts +++ b/src/analysis/extractScannerData.ts @@ -4,12 +4,12 @@ import fs from "node:fs"; // Import Third-party Dependencies import { getScoreColor, getVCSRepositoryPathAndPlatform } from "@nodesecure/utils"; import { getManifest, getFlags } from "@nodesecure/flags/web"; -import * as scorecard from "@nodesecure/ossf-scorecard-sdk"; import { Extractors, type Payload, type Dependency, type DependencyVersion, type DependencyLinks } from "@nodesecure/scanner"; import type { RC } from "@nodesecure/rc"; // Import Internal Dependencies import * as localStorage from "../localStorage.ts"; +import { fetchScorecardScore } from "./fetch.ts"; // CONSTANTS const kFlagsList = Object.values(getManifest()); @@ -54,10 +54,10 @@ export interface BuildScannerStatsOptions { reportConfig?: RC["report"]; } -export async function buildStatsFromScannerDependencies( +export function buildStatsFromScannerDependencies( payloadFiles: string[] | Payload["dependencies"] = [], options: BuildScannerStatsOptions = Object.create(null) -): Promise { +): ReportStat { const { reportConfig } = options; const config = reportConfig ?? localStorage.getConfig().report!; @@ -180,22 +180,26 @@ export async function buildStatsFromScannerDependencies( return acc; }, {}); - const givenPackages = Object.values(stats.packages).filter((pkg) => pkg.isGiven); + stats.packages_count.all = Object.keys(stats.packages).length; + stats.packages_count.internal = stats.packages_count.all - stats.packages_count.external; + stats.scorecards = {}; + + return stats; +} +export async function buildGivenPackagesScorecards(stats: ReportStat): Promise { + const givenPackages = Object.values(stats.packages).filter((pkg) => pkg.isGiven); + const scorecards: ReportStat["scorecards"] = {}; await Promise.all(givenPackages.map(async(pkg) => { const { fullName } = pkg; - const { score } = await scorecard.result(fullName, { resolveOnVersionControl: false }); + const score = await fetchScorecardScore(fullName); const [repo, platform] = getVCSRepositoryPathAndPlatform(pkg.links?.repository) ?? []; - stats.scorecards[fullName] = { + scorecards[fullName] = { score, color: getScoreColor(score), visualizerUrl: repo ? `${kScorecardVisualizerUrl}/${platform}/${repo}` : "#" }; })); - stats.packages_count.all = Object.keys(stats.packages).length; - stats.packages_count.internal = stats.packages_count.all - stats.packages_count.external; - - return stats; + return scorecards; } - diff --git a/src/analysis/fetch.ts b/src/analysis/fetch.ts index 46e024e..51bb4fb 100644 --- a/src/analysis/fetch.ts +++ b/src/analysis/fetch.ts @@ -3,6 +3,8 @@ import path from "node:path"; // Import Third-party Dependencies import kleur from "kleur"; +import * as scorecard from "@nodesecure/ossf-scorecard-sdk"; +import { isHTTPError } from "@openally/httpie"; // Import Internal Dependencies import { buildStatsFromScannerDependencies } from "./extractScannerData.ts"; @@ -11,6 +13,9 @@ import * as localStorage from "../localStorage.ts"; import * as utils from "../utils/index.ts"; import * as CONSTANTS from "../constants.ts"; +// CONSTANTS +const kNotFoundStatusCode = 404; + export async function fetchPackagesAndRepositoriesData( verbose = true ) { @@ -98,3 +103,24 @@ async function fetchRepositoriesStats( jsonFiles.filter((value) => value !== null) ); } + +const scoresCache = new Map(); + +export async function fetchScorecardScore(fullName: string) { + if (scoresCache.has(fullName)) { + return scoresCache.get(fullName); + } + try { + const { score } = await scorecard.result(fullName, { resolveOnVersionControl: false }); + scoresCache.set(fullName, score); + + return score; + } + catch (e) { + if (isHTTPError(e) && e.statusCode === kNotFoundStatusCode) { + scoresCache.set(fullName, 0); + } + + return 0; + } +} diff --git a/src/api/report.ts b/src/api/report.ts index da74855..03704d4 100644 --- a/src/api/report.ts +++ b/src/api/report.ts @@ -8,7 +8,7 @@ import { type Payload } from "@nodesecure/scanner"; import { type RC } from "@nodesecure/rc"; // Import Internal Dependencies -import { buildStatsFromScannerDependencies } from "../analysis/extractScannerData.ts"; +import { buildStatsFromScannerDependencies, buildGivenPackagesScorecards } from "../analysis/extractScannerData.ts"; import { HTML, PDF } from "../reporting/index.ts"; export interface ReportLocationOptions { @@ -63,12 +63,13 @@ export async function report( throw new Error("At least one reporter must be enabled (pdf or html)"); } - const [pkgStats, finalReportLocation] = await Promise.all([ - buildStatsFromScannerDependencies(scannerDependencies, { - reportConfig - }), - reportLocation(reportOutputLocation, { includesPDF, savePDFOnDisk, saveHTMLOnDisk }) - ]); + const pkgStats = buildStatsFromScannerDependencies(scannerDependencies, { + reportConfig + }); + + pkgStats.scorecards = await buildGivenPackagesScorecards(pkgStats); + + const finalReportLocation = await reportLocation(reportOutputLocation, { includesPDF, savePDFOnDisk, saveHTMLOnDisk }); let reportHTMLPath: string | undefined; try { diff --git a/test/api/report.spec.ts b/test/api/report.spec.ts index e708019..68111ba 100644 --- a/test/api/report.spec.ts +++ b/test/api/report.spec.ts @@ -81,6 +81,41 @@ describe("(API) report", { concurrency: 1 }, () => { } }); + test(`it should successfully generate a PDF and should not save +PDF or HTML for packages that don't have a scorecard`, async() => { + const reportOutputLocation = await fs.mkdtemp( + path.join(os.tmpdir(), "test-runner-report-pdf-") + ); + + const payload = await from("@pyroscope/nodejs"); + + const generatedPDF = await report( + payload.dependencies, + structuredClone({ + ...kReportPayload, + npm: { + organizationPrefix: "@pyroscope", + packages: ["nodejs"] + } + }), + { reportOutputLocation } + ); + try { + assert.ok(Buffer.isBuffer(generatedPDF)); + assert.ok(isPDF(generatedPDF)); + + const files = (await fs.readdir(reportOutputLocation, { withFileTypes: true })) + .flatMap((dirent) => (dirent.isFile() ? [dirent.name] : [])); + assert.deepEqual( + files, + [] + ); + } + finally { + await fs.rm(reportOutputLocation, { force: true, recursive: true }); + } + }); + test("should save HTML when saveHTMLOnDisk is truthy", async() => { const reportOutputLocation = await fs.mkdtemp( path.join(os.tmpdir(), "test-runner-report-pdf-")