Skip to content
Closed
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
86 changes: 72 additions & 14 deletions src/advisory/osv-advisory-source.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,55 @@
import { OsvVuln, PackageRef } from "../types.js";
import { AdvisorySource, AdvisoryResult } from "./advisory-source.js";
import { extractErrorMessage } from "../utils/network.js";
import type { DebugLogger } from "../output/debug.js";

export class OsvAdvisorySource implements AdvisorySource {
constructor(private readonly baseUrl = "https://api.osv.dev") {}
constructor(
private readonly baseUrl = "https://api.osv.dev",
private readonly debugLog?: DebugLogger,
) {}

async queryBatch(packages: PackageRef[]): Promise<AdvisoryResult[]> {
const requestUrl = `${this.baseUrl}/v1/querybatch`;
const payload = {
queries: packages.map(p => ({
package: {
ecosystem: p.ecosystem,
name: p.name,
},
version: p.version,
})),
};
const startedAt = Date.now();

try {
const response = await fetch(`${this.baseUrl}/v1/querybatch`, {
this.debugLog?.("OSV request", {
method: "POST",
url: requestUrl,
headers: {
"Content-Type": "application/json",
},
queryCount: packages.length,
sample: packages.slice(0, 3).map(p => ({
ecosystem: p.ecosystem,
name: p.name,
version: p.version,
})),
});

const response = await fetch(requestUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
queries: packages.map(p => ({
package: {
ecosystem: p.ecosystem,
name: p.name,
},
version: p.version,
})),
}),
body: JSON.stringify(payload),
});
this.debugLog?.("OSV response", {
method: "POST",
url: requestUrl,
status: response.status,
statusText: response.statusText,
durationMs: Date.now() - startedAt,
});

if (!response.ok) {
Expand All @@ -35,23 +64,52 @@ export class OsvAdvisorySource implements AdvisorySource {
vulnerabilities: r.vulns || [],
}));
} catch (error) {
this.debugLog?.("OSV request failed", {
method: "POST",
url: requestUrl,
durationMs: Date.now() - startedAt,
error: error instanceof Error
? { message: error.message, stack: error.stack }
: String(error),
});
const message = extractErrorMessage(error);
throw new Error(`OSV batch query failed for ${this.baseUrl}: ${message}`);
}
}

async getVuln(id: string): Promise<OsvVuln> {
const requestUrl = `${this.baseUrl}/v1/vulns/${encodeURIComponent(id)}`;
const startedAt = Date.now();

try {
const response = await fetch(
`${this.baseUrl}/v1/vulns/${encodeURIComponent(id)}`,
);
this.debugLog?.("OSV request", {
method: "GET",
url: requestUrl,
headers: {},
});
const response = await fetch(requestUrl);
this.debugLog?.("OSV response", {
method: "GET",
url: requestUrl,
status: response.status,
statusText: response.statusText,
durationMs: Date.now() - startedAt,
});

if (!response.ok) {
throw new Error(`OSV vuln fetch failed for ${id}: ${response.status} ${response.statusText}`);
}

return response.json() as Promise<OsvVuln>;
} catch (error) {
this.debugLog?.("OSV request failed", {
method: "GET",
url: requestUrl,
durationMs: Date.now() - startedAt,
error: error instanceof Error
? { message: error.message, stack: error.stack }
: String(error),
});
const message = extractErrorMessage(error);
throw new Error(`OSV vuln fetch failed for ${id} via ${this.baseUrl}: ${message}`);
}
Expand Down
4 changes: 4 additions & 0 deletions src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ export function parseArgs(argv: string[]): {
options.json = true;
continue;
}
if (arg === "--debug") {
options.debug = true;
continue;
}
if (arg === "--verbose") {
options.verbose = true;
continue;
Expand Down
1 change: 1 addition & 0 deletions src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export function printHelp(): void {
" --fix Apply validated direct dependency fixes and rescan",
" --osv-url <url> Use a custom OSV-compatible advisory endpoint",
" --ca-cert <path> Path to a CA certificate file for corporate SSL proxies",
" --debug Emit verbose runtime and network debug logs to stderr",
" --verbose Show detailed output with fix plan, paths, and full table",
" --prod-only Exclude dev dependencies where available",
" --fail-on <severity> Exit non-zero at or above severity (default: critical)",
Expand Down
11 changes: 10 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { normalizeSeverity } from "./osv/severity.js";
import { DEFAULT_BATCH_SIZE, DEFAULT_SEARCH_DEPTH, severityOrder } from "./constants.js";
import { chalk } from "./utils/chalk.js";
import { createSpinner } from "./output/spinner.js";
import { createDebugLogger } from "./output/debug.js";
import { buildSuggestedFixCommandPlan } from "./remediation/fix-commands.js";
import { scanProjectForPackageUsage } from "./usage/scanner.js";
import { getCliVersion } from "./utils/version-info.js";
Expand Down Expand Up @@ -38,6 +39,7 @@ import {
import { countBySeverity } from "./utils/severity.js";
import { buildReportData, writeHtmlReport } from "./output/html-reporter.js";
import { writeOutputs } from "./output/write-outputs.js";
import { selectFindingsForTable } from "./output/finding-display.js";
import {
printSummary,
printActionSummary,
Expand Down Expand Up @@ -83,6 +85,7 @@ if (parsedArgs) {
const projectPath = path.resolve(projectArg || ".");
const batchSize = Number(options.batchSize || DEFAULT_BATCH_SIZE);
const searchDepth = Math.max(0, Number(options.searchDepth || DEFAULT_SEARCH_DEPTH));
const debugLog = createDebugLogger(!!options.debug);

async function main() {
printBanner(options);
Expand Down Expand Up @@ -198,6 +201,9 @@ if (parsedArgs) {

let scanInput = loadPackages(projectPath, !!options.prodOnly, searchDepth);
let packages = scanInput.packages;
debugLog(
`Parsed ${packages.length} package${packages.length === 1 ? "" : "s"} from ${scanInput.filePath ?? scanInput.source}`,
);

logInfo(
`Parsed ${packages.length} ${pluralize(packages.length, "package")} from ${scanInput.source}${
Expand Down Expand Up @@ -329,6 +335,9 @@ if (parsedArgs) {
main().catch((error) => {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(chalk.red(`Error: ${errorMessage}`));
if (options.debug && error instanceof Error && error.stack) {
console.error(`[debug] Stack trace:\n${error.stack}`);
}
if (isSslCertificateError(error)) {
const [hint, ...rest] = sslCertificateErrorHint();
console.error(chalk.yellow(hint));
Expand Down Expand Up @@ -393,7 +402,7 @@ async function scanProject(params: {
const minSeverity = normalizeSeverity(params.options.minSeverity || "medium");
const tableFindings = params.options.all
? sorted
: sorted.filter(f => severityOrder[f.severity] >= severityOrder[minSeverity] || f.severity === "unknown");
: selectFindingsForTable(sorted, minSeverity);
const suggestedFixCommands = buildSuggestedFixCommandPlan(sorted, params.scanInput, { offline });

return {
Expand Down
28 changes: 28 additions & 0 deletions src/output/debug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export type DebugLogger = (message: string, details?: unknown) => void;

export function createDebugLogger(enabled: boolean): DebugLogger {
if (!enabled) {
return () => {};
}

return (message: string, details?: unknown) => {
if (details === undefined) {
console.error(`[debug] ${message}`);
return;
}

console.error(`[debug] ${message} ${formatDebugDetails(details)}`);
};
}

function formatDebugDetails(details: unknown): string {
if (typeof details === "string") {
return details;
}

try {
return JSON.stringify(details);
} catch {
return String(details);
}
}
44 changes: 44 additions & 0 deletions src/output/finding-display.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { severityOrder } from "../constants.js";
import { normalizeSeverity } from "../osv/severity.js";
import type { Finding, SeverityLabel } from "../types.js";

export function selectFindingsForTable(findings: Finding[], minSeverity: string): Finding[] {
const normalized = normalizeSeverity(minSeverity) as SeverityLabel;
return findings.filter(finding =>
severityOrder[finding.severity] >= severityOrder[normalized] || finding.severity === "unknown"
);
}

export function selectFindingsForCompact(
findings: Finding[],
options?: { urgentLimit?: number },
): Finding[] {
const urgentLimit = options?.urgentLimit ?? 3;
const urgent = findings
.filter(finding => finding.severity === "critical" || finding.severity === "high")
.slice(0, urgentLimit);
const unknownDirect = findings.filter(
finding => finding.severity === "unknown" && finding.relationship === "direct",
);

return mergeUniqueFindings(urgent, unknownDirect);
}

function mergeUniqueFindings(primary: Finding[], extra: Finding[]): Finding[] {
const result = [...primary];
const seen = new Set(result.map(findingIdentity));

for (const finding of extra) {
const id = findingIdentity(finding);
if (seen.has(id)) continue;
seen.add(id);
result.push(finding);
}

return result;
}

function findingIdentity(finding: Finding): string {
const vulnIds = finding.vulnerabilities.map(v => v.id).sort().join(",");
return `${finding.pkg.name}@${finding.pkg.version}|${finding.relationship}|${finding.severity}|${vulnIds}`;
}
8 changes: 4 additions & 4 deletions src/output/printers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
sortFindingsForOutput
} from "./formatters.js";
import { pluralize } from "../utils/string.js";
import { selectFindingsForCompact } from "./finding-display.js";

export function printSummary(findings: Finding[], packageCount: number, scanInput: ScanInput) {
if (findings.length === 0) {
Expand Down Expand Up @@ -346,10 +347,9 @@ export function printCompactOutput(
console.log(chalk.bold("📦 Vulnerabilities found"));
console.log("────────────────────────────────\n");

// Get top 3 critical/high issues plus all unknown (malicious) findings
const urgentFindings = findings
.filter(f => f.severity === "critical" || f.severity === "high" || f.severity === "unknown")
.slice(0, 3);
// Reuse shared display selection logic so compact mode does not silently
// drop direct unknown-severity findings.
const urgentFindings = selectFindingsForCompact(findings, { urgentLimit: 3 });

for (const finding of urgentFindings) {
const sevLabel = finding.severity.toUpperCase().padEnd(8);
Expand Down
10 changes: 9 additions & 1 deletion src/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { buildPnpmWorkspaceMap } from "./parsers/pnpm-lock.js";
import { buildNpmWorkspaceMap } from "./parsers/package-lock.js";
import { buildBunWorkspaceMap } from "./parsers/bun-lock.js";
import { pluralize } from "./utils/string.js";
import type { DebugLogger } from "./output/debug.js";

type ScanClassificationContext = {
directDependencyNames?: ReadonlySet<string> | null;
Expand All @@ -37,6 +38,7 @@ export function createAdvisorySource(options?: {
osvUrl?: string;
offline?: boolean;
offlineDb?: string;
debugLog?: DebugLogger;
}): AdvisorySourceContext {
const offline = !!options?.offline || !!options?.offlineDb;

Expand All @@ -54,7 +56,7 @@ export function createAdvisorySource(options?: {
}

return {
advisorySource: new OsvAdvisorySource(options?.osvUrl),
advisorySource: new OsvAdvisorySource(options?.osvUrl, options?.debugLog),
offline: false,
sourceLabel: options?.osvUrl
? `custom OSV endpoint (${options.osvUrl})`
Expand All @@ -74,11 +76,14 @@ export async function scanPackages(
batchSize: number,
options: ParsedOptions,
context?: ScanClassificationContext,
debugLog?: DebugLogger,
): Promise<Finding[]> {
const log: DebugLogger = debugLog ?? (() => {});
const sourceContext = createAdvisorySource({
osvUrl: options.osvUrl,
offline: options.offline,
offlineDb: options.offlineDb,
debugLog: log,
});
const offline = sourceContext.offline;
const cacheDirOverride = options.cacheDir;
Expand All @@ -103,13 +108,16 @@ export async function scanPackages(
if (!options.noCache) {
const cached = cache.queryEntries[cacheKey];
if (cached && !isEntryStale(cached, nowMs)) {
log(`Cache hit for ${pkg.name}@${pkg.version}`);
if (cached.vulnIds.length > 0) {
results.push({ pkg, vulnIds: cached.vulnIds });
}
continue;
}
}

const reason = options.noCache ? "no-cache mode" : "stale or missing entry";
log(`Cache miss for ${pkg.name}@${pkg.version} (${reason})`);
uncachedPackages.push(pkg);
}

Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ export type CliCommand = "scan" | "advisories-sync" | "install-skill" | "config"
export type ParsedOptions = {
version?: boolean;
json?: boolean;
debug?: boolean;
verbose?: boolean;
fix?: boolean;
prodOnly?: boolean;
Expand Down
2 changes: 2 additions & 0 deletions tests/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ describe("parseArgs", () => {
it("parses flags, inline values, and a project path together", () => {
const result = parseArgs([
"--json",
"--debug",
"--fix",
"--verbose",
"--prod-only",
Expand All @@ -69,6 +70,7 @@ describe("parseArgs", () => {
command: "scan",
options: {
json: true,
debug: true,
fix: true,
verbose: true,
prodOnly: true,
Expand Down
Loading