diff --git a/examples/git-source-mal/package-lock.json b/examples/git-source-mal/package-lock.json new file mode 100644 index 0000000..b76b0cc --- /dev/null +++ b/examples/git-source-mal/package-lock.json @@ -0,0 +1,21 @@ +{ + "name": "cve-lite-example-git-source-mal", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cve-lite-example-git-source-mal", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "node-ipc": "9.2.3" + } + }, + "node_modules/node-ipc": { + "version": "9.2.3", + "resolved": "https://codeload.github.com/RIAEvangelist/node-ipc/tar.gz/9af9b3c49515b85598cd88de3e8cc20c7a98efbb", + "integrity": "sha512-placeholder==" + } + } +} diff --git a/examples/git-source-mal/package.json b/examples/git-source-mal/package.json new file mode 100644 index 0000000..212cc5b --- /dev/null +++ b/examples/git-source-mal/package.json @@ -0,0 +1,10 @@ +{ + "name": "cve-lite-example-git-source-mal", + "version": "1.0.0", + "private": true, + "description": "node-ipc resolved from a pinned GitHub commit — MAL- advisory should show git source warning, not 'Remove immediately'.", + "license": "ISC", + "dependencies": { + "node-ipc": "9.2.3" + } +} diff --git a/examples/readme.md b/examples/readme.md index 7466810..2f091b1 100644 --- a/examples/readme.md +++ b/examples/readme.md @@ -36,6 +36,7 @@ Small curated projects committed to the repository. Clone the repo and scan imme | `pnpm-legacy-mal-private-registry` | pnpm legacy (v6) | `node-ipc@9.2.3` resolved from a private registry — demonstrates `Unverifiable (private source)` detection for pnpm v6/v7/v8 lockfiles. | | `yarn-classic-mal-private-registry` | Yarn Classic (v1) | `node-ipc@9.2.3` resolved from a private registry — demonstrates `Unverifiable (private source)` detection for Yarn Classic lockfiles. | | `bun-mal-private-registry` | Bun | `node-ipc@9.2.3` resolved from a private registry — demonstrates `Unverifiable (private source)` detection for Bun lockfiles. | +| `git-source-mal` | npm | `node-ipc@9.2.3` resolved from a git source URL pinned to a commit SHA — demonstrates `Git source (SHA-pinned)` badge for MAL- advisories where the package originates from a git repository rather than the npm registry. | | `lima-site` | npm | Dev-dependency scanning in a documentation site. | ## In-repo snapshot: Astro diff --git a/src/constants.ts b/src/constants.ts index 1b6205b..ff7bb66 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -23,6 +23,21 @@ export const MAL_PRIVATE_REGISTRY_COMPACT_MESSAGE = export const MAL_PRIVATE_REGISTRY_LEGEND_MESSAGE = "verify artifact source manually"; +export const MAL_GIT_SOURCE_PINNED_MESSAGE = + "Advisory targets registry packages. This package resolves from a git source pinned to a commit SHA — verify the repository and org are trusted."; + +export const MAL_GIT_SOURCE_FLOATING_MESSAGE = + "Advisory targets registry packages. This package resolves from a git source with a floating reference — verify the repository, org, and ref are safe."; + +export const MAL_GIT_SOURCE_COMPACT_MESSAGE = + "Git source — verify repository and org are trusted."; + +export const MAL_GIT_SOURCE_LEGEND_MESSAGE = + "verify repository and org are trusted"; + +export const MAL_GIT_SOURCE_PINNED_DISPLAY = "⚠ Git source (SHA-pinned)"; +export const MAL_GIT_SOURCE_FLOATING_DISPLAY = "⚠ Git source (floating ref)"; + export const EXCLUDED_DIRS = new Set([ ".git", "node_modules", diff --git a/src/output/formatters.ts b/src/output/formatters.ts index 79348ff..d8b2ae9 100644 --- a/src/output/formatters.ts +++ b/src/output/formatters.ts @@ -2,7 +2,12 @@ import type { Finding } from "../types.js"; import type { SuggestedFixCommandPlan } from "../remediation/fix-commands.js"; import { findSuggestedCommandForFinding } from "../remediation/fix-commands.js"; import { chalk } from "../utils/chalk.js"; -import { severityOrder, MAL_PRIVATE_REGISTRY_MESSAGE } from "../constants.js"; +import { + severityOrder, + MAL_PRIVATE_REGISTRY_MESSAGE, + MAL_GIT_SOURCE_PINNED_MESSAGE, + MAL_GIT_SOURCE_FLOATING_MESSAGE, +} from "../constants.js"; import { loadCache } from "../osv/cache.js"; import { inferSeverity } from "../osv/severity.js"; import { getPrimaryParent } from "../utils/finding.js"; @@ -122,6 +127,14 @@ export function getRecommendedAction(finding: Finding): string { } export function summarizeRisk(finding: Finding): string { + if (finding.maliciousUnverifiable) { + return MAL_PRIVATE_REGISTRY_MESSAGE; + } + if (finding.maliciousGitSource) { + return finding.maliciousGitSourcePinned + ? MAL_GIT_SOURCE_PINNED_MESSAGE + : MAL_GIT_SOURCE_FLOATING_MESSAGE; + } let risk = ""; if (finding.severity === "critical" && finding.relationship === "direct") { risk = "Critical direct dependency. Prioritize this first because the project controls it directly."; @@ -251,6 +264,8 @@ export function serializeFinding(finding: Finding, plan?: SuggestedFixCommandPla dependencyPaths: finding.dependencyPaths, usage: finding.usage ?? null, maliciousUnverifiable: finding.maliciousUnverifiable ?? false, + maliciousGitSource: finding.maliciousGitSource ?? false, + maliciousGitSourcePinned: finding.maliciousGitSourcePinned ?? false, vulnerabilities: finding.vulnerabilities.map(v => ({ id: v.id, aliases: v.aliases ?? [], diff --git a/src/output/html-reporter.ts b/src/output/html-reporter.ts index c89daa3..1b58a3b 100644 --- a/src/output/html-reporter.ts +++ b/src/output/html-reporter.ts @@ -6,6 +6,7 @@ import { LOGO_BASE64 } from "./logo-base64.js"; import { OWASP_LOGO_BASE64 } from "./owasp-logo-base64.js"; import { isMajorVersionBump } from "../utils/version.js"; import { pluralize } from "../utils/string.js"; +import { MAL_GIT_SOURCE_PINNED_DISPLAY, MAL_GIT_SOURCE_FLOATING_DISPLAY } from "../constants.js"; import type { Finding } from "../types.js"; import type { SuggestedFixCommandPlan } from "../remediation/fix-commands.js"; @@ -127,6 +128,7 @@ button.header-link:hover{color:#58a6ff;border-color:#58a6ff} .sev-badge.low{background:#388bfd22;color:#388bfd;border:1px solid #388bfd66} .sev-badge.unknown{background:#8b949e22;color:#8b949e;border:1px solid #8b949e66} .badge-warning{display:inline-block;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.4px;padding:2px 8px;border-radius:4px;background:#e3b34122;color:#e3b341;border:1px solid #e3b34166} +.badge-git-source{background:#fb8c0022;color:#fb8c00;border:1px solid #fb8c0055;border-radius:4px;font-size:11px;padding:2px 6px} .rel-badge{font-size:11px;padding:2px 7px;border-radius:4px} .rel-badge.direct{color:#3fb950;background:#3fb95022} .rel-badge.transitive{color:#e3b341;background:#e3b34122} @@ -455,6 +457,10 @@ export function renderFindingRow(finding: SerializedFinding, idx: number, skippe ? `${escapeHtml(fixVersion)}` : isMalicious && finding.maliciousUnverifiable ? `Unverifiable (private source)` + : isMalicious && finding.maliciousGitSource && finding.maliciousGitSourcePinned + ? `${escapeHtml(MAL_GIT_SOURCE_PINNED_DISPLAY)}` + : isMalicious && finding.maliciousGitSource + ? `${escapeHtml(MAL_GIT_SOURCE_FLOATING_DISPLAY)}` : isMalicious ? `⚠ Malicious` : `⚠ No fix`; diff --git a/src/output/printers.ts b/src/output/printers.ts index e999207..e51ff7e 100644 --- a/src/output/printers.ts +++ b/src/output/printers.ts @@ -12,7 +12,16 @@ import { } from "./formatters.js"; import { pluralize } from "../utils/string.js"; import { selectFindingsForCompact } from "./finding-display.js"; -import { MAL_PRIVATE_REGISTRY_COMPACT_MESSAGE, MAL_PRIVATE_REGISTRY_LEGEND_MESSAGE } from "../constants.js"; +import { + MAL_PRIVATE_REGISTRY_COMPACT_MESSAGE, + MAL_PRIVATE_REGISTRY_LEGEND_MESSAGE, + MAL_GIT_SOURCE_PINNED_MESSAGE, + MAL_GIT_SOURCE_FLOATING_MESSAGE, + MAL_GIT_SOURCE_COMPACT_MESSAGE, + MAL_GIT_SOURCE_LEGEND_MESSAGE, + MAL_GIT_SOURCE_PINNED_DISPLAY, + MAL_GIT_SOURCE_FLOATING_DISPLAY, +} from "../constants.js"; export function printSummary(findings: Finding[], packageCount: number, scanInput: ScanInput) { if (findings.length === 0) { @@ -193,6 +202,8 @@ export function printTable(findings: Finding[], threshold: SeverityLabel | null, fixedDisplay = fixVersion; } else if (f.maliciousUnverifiable) { fixedDisplay = chalk.yellow("⚠ Unverifiable (private source)"); + } else if (f.maliciousGitSource) { + fixedDisplay = chalk.yellow(f.maliciousGitSourcePinned ? MAL_GIT_SOURCE_PINNED_DISPLAY : MAL_GIT_SOURCE_FLOATING_DISPLAY); } else if (f.vulnerabilities.some(v => v.id.startsWith("MAL-"))) { fixedDisplay = chalk.yellow("⚠ Malicious"); } else { @@ -255,6 +266,10 @@ export function printTable(findings: Finding[], threshold: SeverityLabel | null, : "Upgrade or remove the parent package that pulls it in."; if (f.maliciousUnverifiable) { console.log(chalk.yellow(` · ${f.pkg.name}@${f.pkg.version} - Unverifiable (private source) - ${action}`)); + } else if (f.maliciousGitSource) { + const msg = f.maliciousGitSourcePinned ? MAL_GIT_SOURCE_PINNED_MESSAGE : MAL_GIT_SOURCE_FLOATING_MESSAGE; + const url = f.pkg.resolvedUrl ? ` (${f.pkg.resolvedUrl})` : ""; + console.log(chalk.yellow(` · ${f.pkg.name}@${f.pkg.version}${url} - ${msg}`)); } else { console.log(chalk.red(` · ${f.pkg.name}@${f.pkg.version} - ${action}`)); } @@ -398,6 +413,9 @@ export function printCompactOutput( if (isMalicious) { if (finding.maliciousUnverifiable) { console.log(` ${chalk.yellow(`⚠ Unverifiable (private source) - ${MAL_PRIVATE_REGISTRY_COMPACT_MESSAGE}`)}`); + } else if (finding.maliciousGitSource) { + const url = finding.pkg.resolvedUrl ? ` Source: ${finding.pkg.resolvedUrl}` : ""; + console.log(` ${chalk.yellow(`⚠ ${MAL_GIT_SOURCE_COMPACT_MESSAGE}${url}`)}`); } else { const action = finding.relationship === "direct" ? "Remove this package from your dependencies immediately." @@ -516,6 +534,8 @@ export function printCompactOutput( for (const f of maliciousCompact) { if (f.maliciousUnverifiable) { console.log(chalk.yellow(` · ${f.pkg.name}@${f.pkg.version} - Unverifiable (private source) - ${MAL_PRIVATE_REGISTRY_LEGEND_MESSAGE}`)); + } else if (f.maliciousGitSource) { + console.log(chalk.yellow(` · ${f.pkg.name}@${f.pkg.version} - Git source - ${MAL_GIT_SOURCE_LEGEND_MESSAGE}`)); } else { const action = f.relationship === "direct" ? "Remove it from your dependencies immediately." diff --git a/src/scanner.ts b/src/scanner.ts index 058952e..f5eca4d 100644 --- a/src/scanner.ts +++ b/src/scanner.ts @@ -1,6 +1,6 @@ import type { Finding, NpmTransitiveGraph, OsvVuln, PackageRef, ParsedOptions, ScanInput } from "./types.js"; import { chunk, unique, runWithConcurrency } from "./utils/array.js"; -import { isPrivateRegistrySource } from "./utils/advisory.js"; +import { isPrivateRegistrySource, isGitSource, hasCommitShaPinning } from "./utils/advisory.js"; import { compareVersions, isPreReleaseVersion, looksLikeVersion } from "./utils/version.js"; import { loadCache, saveCache, isEntryStale } from "./osv/cache.js"; import { maxSeverity } from "./osv/severity.js"; @@ -282,11 +282,13 @@ export async function scanPackages( }); for (const finding of findings) { - if ( - finding.vulnerabilities.some(v => v.id.startsWith("MAL-")) && - isPrivateRegistrySource(finding.pkg) - ) { - finding.maliciousUnverifiable = true; + if (finding.vulnerabilities.some(v => v.id.startsWith("MAL-"))) { + if (isGitSource(finding.pkg)) { + finding.maliciousGitSource = true; + finding.maliciousGitSourcePinned = hasCommitShaPinning(finding.pkg); + } else if (isPrivateRegistrySource(finding.pkg)) { + finding.maliciousUnverifiable = true; + } } } diff --git a/src/types.ts b/src/types.ts index 3ab4a2d..2376755 100644 --- a/src/types.ts +++ b/src/types.ts @@ -137,6 +137,8 @@ export type Finding = { files: string[]; }; maliciousUnverifiable?: boolean; + maliciousGitSource?: boolean; + maliciousGitSourcePinned?: boolean; }; export type QueryCacheEntry = { vulnIds: string[]; cachedAt: string }; diff --git a/src/utils/advisory.ts b/src/utils/advisory.ts index 4e25ed0..09b1ff2 100644 --- a/src/utils/advisory.ts +++ b/src/utils/advisory.ts @@ -8,3 +8,25 @@ export function isPrivateRegistrySource(pkg: PackageRef): boolean { !pkg.resolvedUrl.startsWith(NPM_PUBLIC_REGISTRY) ); } + +const GIT_SOURCE_PREFIXES = [ + "https://codeload.github.com/", + "https://github.com/", + "https://gitlab.com/", + "https://bitbucket.org/", + "git+https://", + "git+ssh://", + "git://", +]; + +export function isGitSource(pkg: PackageRef): boolean { + return ( + pkg.resolvedUrl !== undefined && + pkg.resolvedUrl !== "" && + GIT_SOURCE_PREFIXES.some(prefix => pkg.resolvedUrl!.startsWith(prefix)) + ); +} + +export function hasCommitShaPinning(pkg: PackageRef): boolean { + return pkg.resolvedUrl !== undefined && /[0-9a-f]{40}/i.test(pkg.resolvedUrl); +} diff --git a/tests/fixture-scan.test.ts b/tests/fixture-scan.test.ts index 5eff24a..a99ff5f 100644 --- a/tests/fixture-scan.test.ts +++ b/tests/fixture-scan.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { loadPackages } from "../src/parsers/index.js"; import { buildSuggestedFixCommandPlan } from "../src/remediation/fix-commands.js"; -import { isPrivateRegistrySource } from "../src/utils/advisory.js"; +import { isPrivateRegistrySource, isGitSource, hasCommitShaPinning } from "../src/utils/advisory.js"; import type { Finding, PackageRef, ScanInput } from "../src/types.js"; const examplesDir = path.join(process.cwd(), "examples"); @@ -223,4 +223,13 @@ describe("fixture remediation scans", () => { expect(nodeIpc?.resolvedUrl).toBe("https://npm.internal.example.com/node-ipc/-/node-ipc-9.2.3.tgz"); expect(isPrivateRegistrySource(nodeIpc!)).toBe(true); }); + + it("git-source-mal fixture - node-ipc is detected as git source with SHA pinning", () => { + const scanInput = loadFixture("git-source-mal"); + const nodeIpc = scanInput.packages.find(p => p.name === "node-ipc"); + expect(nodeIpc).toBeDefined(); + expect(isGitSource(nodeIpc!)).toBe(true); + expect(hasCommitShaPinning(nodeIpc!)).toBe(true); + expect(nodeIpc?.resolvedUrl).toContain("codeload.github.com"); + }); }); diff --git a/tests/html-reporter.test.ts b/tests/html-reporter.test.ts index b976086..12b18ad 100644 --- a/tests/html-reporter.test.ts +++ b/tests/html-reporter.test.ts @@ -380,6 +380,34 @@ describe("renderHtmlReport", () => { expect(html).not.toContain("⚠ Malicious"); }); + it("renders git source SHA-pinned badge for maliciousGitSource findings", () => { + const finding = makeFinding({ + firstFixedVersion: null, + pkg: { name: "node-ipc", version: "9.2.3", ecosystem: "npm", resolvedUrl: "https://codeload.github.com/org/repo/tar.gz/9af9b3c49515b85598cd88de3e8cc20c7a98efbb" }, + vulnerabilities: [makeVuln({ id: "MAL-2022-1000", summary: "Malicious" })], + maliciousGitSource: true, + maliciousGitSourcePinned: true, + }); + const html = renderHtmlReport(buildReportData({ ...BASE_PARAMS, findings: [finding] })); + expect(html).toContain("Git source (SHA-pinned)"); + expect(html).not.toContain("⚠ Malicious"); + expect(html).not.toContain("Unverifiable (private source)"); + }); + + it("renders git source floating ref badge for unpinned git source findings", () => { + const finding = makeFinding({ + firstFixedVersion: null, + pkg: { name: "node-ipc", version: "9.2.3", ecosystem: "npm", resolvedUrl: "https://github.com/org/repo/archive/main.tar.gz" }, + vulnerabilities: [makeVuln({ id: "MAL-2022-1000", summary: "Malicious" })], + maliciousGitSource: true, + maliciousGitSourcePinned: false, + }); + const html = renderHtmlReport(buildReportData({ ...BASE_PARAMS, findings: [finding] })); + expect(html).toContain("Git source (floating ref)"); + expect(html).not.toContain("⚠ Malicious"); + expect(html).not.toContain("Unverifiable (private source)"); + }); + it("shows generic no-fix tooltip when finding has non-MAL advisory and no fix version", () => { const finding = makeFinding({ firstFixedVersion: null }); const html = renderHtmlReport(buildReportData({ ...BASE_PARAMS, findings: [finding] })); diff --git a/tests/output.test.ts b/tests/output.test.ts index b29418d..83e3d9d 100644 --- a/tests/output.test.ts +++ b/tests/output.test.ts @@ -1945,6 +1945,85 @@ describe("createSpinner", () => { }); }); +import { isGitSource, hasCommitShaPinning } from "../src/utils/advisory.js"; + +describe("isGitSource", () => { + it("returns true for GitHub codeload URL", () => { + const pkg = { name: "pkg", version: "1.0.0", ecosystem: "npm", resolvedUrl: "https://codeload.github.com/org/repo/tar.gz/abc123" }; + expect(isGitSource(pkg)).toBe(true); + }); + + it("returns true for github.com URL", () => { + const pkg = { name: "pkg", version: "1.0.0", ecosystem: "npm", resolvedUrl: "https://github.com/org/repo/archive/abc123.tar.gz" }; + expect(isGitSource(pkg)).toBe(true); + }); + + it("returns true for gitlab.com URL", () => { + const pkg = { name: "pkg", version: "1.0.0", ecosystem: "npm", resolvedUrl: "https://gitlab.com/org/repo/-/archive/abc123/repo.tar.gz" }; + expect(isGitSource(pkg)).toBe(true); + }); + + it("returns true for git+https protocol", () => { + const pkg = { name: "pkg", version: "1.0.0", ecosystem: "npm", resolvedUrl: "git+https://github.com/org/repo.git" }; + expect(isGitSource(pkg)).toBe(true); + }); + + it("returns false for npm registry URL", () => { + const pkg = { name: "pkg", version: "1.0.0", ecosystem: "npm", resolvedUrl: "https://registry.npmjs.org/pkg/-/pkg-1.0.0.tgz" }; + expect(isGitSource(pkg)).toBe(false); + }); + + it("returns false for private npm registry URL", () => { + const pkg = { name: "pkg", version: "1.0.0", ecosystem: "npm", resolvedUrl: "https://npm.internal.example.com/pkg/-/pkg-1.0.0.tgz" }; + expect(isGitSource(pkg)).toBe(false); + }); + + it("returns false when resolvedUrl is undefined", () => { + const pkg = { name: "pkg", version: "1.0.0", ecosystem: "npm" }; + expect(isGitSource(pkg)).toBe(false); + }); +}); + +describe("hasCommitShaPinning", () => { + it("returns true when URL contains a 40-char hex SHA", () => { + const pkg = { name: "pkg", version: "1.0.0", ecosystem: "npm", resolvedUrl: "https://codeload.github.com/org/repo/tar.gz/9af9b3c49515b85598cd88de3e8cc20c7a98efbb" }; + expect(hasCommitShaPinning(pkg)).toBe(true); + }); + + it("returns false when URL has no SHA (short ref or tag)", () => { + const pkg = { name: "pkg", version: "1.0.0", ecosystem: "npm", resolvedUrl: "https://github.com/org/repo/archive/main.tar.gz" }; + expect(hasCommitShaPinning(pkg)).toBe(false); + }); + + it("returns false when resolvedUrl is undefined", () => { + const pkg = { name: "pkg", version: "1.0.0", ecosystem: "npm" }; + expect(hasCommitShaPinning(pkg)).toBe(false); + }); +}); + +describe("serializeFinding - git source MAL", () => { + it("includes maliciousGitSource:true when finding has maliciousGitSource set", () => { + const finding = createFinding({ + pkg: { name: "node-ipc", version: "9.2.3", ecosystem: "npm", resolvedUrl: "https://codeload.github.com/org/repo/tar.gz/9af9b3c49515b85598cd88de3e8cc20c7a98efbb" }, + vulnerabilities: [{ id: "MAL-2022-1000", summary: "Malicious package", aliases: [], severity: [] }], + }); + finding.maliciousGitSource = true; + finding.maliciousGitSourcePinned = true; + const result = serializeFinding(finding); + expect(result.maliciousGitSource).toBe(true); + expect(result.maliciousGitSourcePinned).toBe(true); + }); + + it("includes maliciousGitSource:false when not set", () => { + const finding = createFinding({ + pkg: { name: "node-ipc", version: "9.2.3", ecosystem: "npm" }, + }); + const result = serializeFinding(finding); + expect(result.maliciousGitSource).toBe(false); + expect(result.maliciousGitSourcePinned).toBe(false); + }); +}); + describe("formatRelLabel", () => { it("returns 'direct' for a prod direct finding", () => { const finding = { relationship: "direct", pkg: { name: "axios", version: "0.21.1", ecosystem: "npm", dev: false } };