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 } };