Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions examples/git-source-mal/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions examples/git-source-mal/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
1 change: 1 addition & 0 deletions examples/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 16 additions & 1 deletion src/output/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.";
Expand Down Expand Up @@ -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 ?? [],
Expand Down
6 changes: 6 additions & 0 deletions src/output/html-reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -455,6 +457,10 @@ export function renderFindingRow(finding: SerializedFinding, idx: number, skippe
? `<span class="fix-hint">${escapeHtml(fixVersion)}</span>`
: isMalicious && finding.maliciousUnverifiable
? `<span class="badge badge-warning" title="MAL- advisory found but package resolved from a private registry - may not apply to your artifact">Unverifiable (private source)</span>`
: isMalicious && finding.maliciousGitSource && finding.maliciousGitSourcePinned
? `<span class="badge badge-git-source" title="MAL- advisory found but package resolves from a git source pinned to a commit SHA — verify the repository and org are trusted">${escapeHtml(MAL_GIT_SOURCE_PINNED_DISPLAY)}</span>`
: isMalicious && finding.maliciousGitSource
? `<span class="badge badge-git-source" title="MAL- advisory found but package resolves from a git source with a floating reference — verify the repository, org, and ref are safe">${escapeHtml(MAL_GIT_SOURCE_FLOATING_DISPLAY)}</span>`
: isMalicious
? `<span class="fix-hint none" title="Malicious code advisory — remove this package">⚠ Malicious</span>`
: `<span class="fix-hint none" title="No known fix — consider replacing this package">⚠ No fix</span>`;
Expand Down
22 changes: 21 additions & 1 deletion src/output/printers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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}`));
}
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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."
Expand Down
14 changes: 8 additions & 6 deletions src/scanner.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ export type Finding = {
files: string[];
};
maliciousUnverifiable?: boolean;
maliciousGitSource?: boolean;
maliciousGitSourcePinned?: boolean;
};

export type QueryCacheEntry = { vulnIds: string[]; cachedAt: string };
Expand Down
22 changes: 22 additions & 0 deletions src/utils/advisory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
11 changes: 10 additions & 1 deletion tests/fixture-scan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
});
});
28 changes: 28 additions & 0 deletions tests/html-reporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] }));
Expand Down
79 changes: 79 additions & 0 deletions tests/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } };
Expand Down