Skip to content
2 changes: 1 addition & 1 deletion src/advisory/advisory-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ export interface AdvisoryResult {
}

export interface AdvisorySource {
queryBatch(packages: PackageRef[]): Promise<AdvisoryResult[]>;
queryBatch(packages: PackageRef[], meta?: { batchId?: string }): Promise<AdvisoryResult[]>;
getVuln(id: string): Promise<OsvVuln>;
}
2 changes: 1 addition & 1 deletion src/advisory/local-advisory-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { LocalAdvisoryDatabase } from "./local-db.js";
export class LocalAdvisorySource implements AdvisorySource {
constructor(private readonly db: LocalAdvisoryDatabase) {}

queryBatch(packages: PackageRef[]): Promise<AdvisoryResult[]> {
queryBatch(packages: PackageRef[], _meta?: { batchId?: string }): Promise<AdvisoryResult[]> {
const results = packages.map(pkg => ({
package: pkg.name,
version: pkg.version,
Expand Down
103 changes: 88 additions & 15 deletions src/advisory/osv-advisory-source.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,62 @@
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[], meta?: { batchId?: string }): 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 = this.debugLog ? Date.now() : 0;

async queryBatch(packages: PackageRef[]): Promise<AdvisoryResult[]> {
try {
const response = await fetch(`${this.baseUrl}/v1/querybatch`, {
if (this.debugLog) {
this.debugLog("OSV request", {
batchId: meta?.batchId ?? null,
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),
});
if (this.debugLog) {
this.debugLog("OSV response", {
batchId: meta?.batchId ?? null,
method: "POST",
url: requestUrl,
status: response.status,
statusText: response.statusText,
durationMs: Date.now() - startedAt,
});
}

if (!response.ok) {
throw new Error(`OSV batch query failed: ${response.status} ${response.statusText}`);
Expand All @@ -35,23 +70,61 @@ export class OsvAdvisorySource implements AdvisorySource {
vulnerabilities: r.vulns || [],
}));
} catch (error) {
if (this.debugLog) {
this.debugLog("OSV request failed", {
batchId: meta?.batchId ?? null,
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 = this.debugLog ? Date.now() : 0;

try {
const response = await fetch(
`${this.baseUrl}/v1/vulns/${encodeURIComponent(id)}`,
);
if (this.debugLog) {
this.debugLog("OSV request", {
method: "GET",
url: requestUrl,
headers: {},
});
}
const response = await fetch(requestUrl);
if (this.debugLog) {
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) {
if (this.debugLog) {
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 Write verbose runtime/network diagnostics to a timestamped log file",
" --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
48 changes: 47 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ 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, type DebugLogger } from "./output/debug.js";
import { configureNpmRegistryDebug } from "./remediation/npm-registry.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 @@ -84,9 +86,18 @@ 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 debugSession = createDebugLogger(!!options.debug);
const debugLog = debugSession.log;
const scanStartedAt = Date.now();

async function main() {
printBanner(options);
debugSession.announcePath();

debugLog("CLI started", {
version: cliVersion,
args: process.argv.slice(2),
});

if (command === "config") {
const { configSubcommand } = parsedArgs!;
Expand Down Expand Up @@ -120,6 +131,10 @@ if (parsedArgs) {
}
process.env.NODE_EXTRA_CA_CERTS = resolvedCaCert;
}
debugLog("Config loaded", {
caCert: resolvedCaCert ?? null,
nodeExtraCaCerts: process.env.NODE_EXTRA_CA_CERTS ?? null,
});

if (command === "advisories-sync") {
const spinner = createSpinner("Preparing advisory sync...", options);
Expand Down Expand Up @@ -165,8 +180,14 @@ if (parsedArgs) {
osvUrl: options.osvUrl,
offline: options.offline,
offlineDb: options.offlineDb,
debugLog,
});
advisorySourceLine = advisorySource.sourceLabel;
debugLog("Advisory source", {
mode: advisorySource.offline ? "offline" : "online",
url: options.osvUrl ?? (advisorySource.offline ? null : "https://api.osv.dev"),
label: advisorySourceLine,
});
if (advisorySource.offline) {
const metadata = advisorySource.advisoryDbMetadata;
advisoryDbFreshnessLine = formatAdvisoryDbFreshness(metadata?.lastSyncAt ?? null);
Expand Down Expand Up @@ -199,6 +220,19 @@ if (parsedArgs) {

let scanInput = loadPackages(projectPath, !!options.prodOnly, searchDepth);
let packages = scanInput.packages;
if (scanInput.filePath) {
debugLog("Lockfile selected", {
source: scanInput.source,
path: scanInput.filePath,
});
}
debugLog("Packages parsed", {
count: packages.length,
source: scanInput.source,
});
if (options.debug) {
configureNpmRegistryDebug(debugLog);
}

logInfo(
`Parsed ${packages.length} ${pluralize(packages.length, "package")} from ${scanInput.source}${
Expand All @@ -225,6 +259,7 @@ if (parsedArgs) {
batchSize,
options,
projectPath,
debugLog,
});
const findingsBeforeFix = scanState.sorted.length;
let fixResult: FixExecutionResult | null = null;
Expand All @@ -235,6 +270,7 @@ if (parsedArgs) {
projectPath,
totalFindings: scanState.sorted.length,
options,
debugLog,
});

if (fixResult.appliedFixCount > 0) {
Expand All @@ -252,6 +288,7 @@ if (parsedArgs) {
batchSize,
options,
projectPath,
debugLog,
});
}
}
Expand Down Expand Up @@ -321,6 +358,11 @@ if (parsedArgs) {
console.log(`${chalk.gray("Report:")} ${chalk.cyan(reportPath)}`);
}

debugLog("Scan finished", {
totalDurationMs: Date.now() - scanStartedAt,
findings: scanState.sorted.length,
});

const failLevel = normalizeSeverity(options.failOn);
const shouldFail = scanState.sorted.some(f => severityOrder[f.severity] >= severityOrder[failLevel]);
process.exit(shouldFail ? 1 : 0);
Expand All @@ -330,6 +372,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) {
debugLog("Unhandled error", { message: error.message, stack: error.stack });
}
if (isSslCertificateError(error)) {
const [hint, ...rest] = sslCertificateErrorHint();
console.error(chalk.yellow(hint));
Expand Down Expand Up @@ -361,13 +406,14 @@ async function scanProject(params: {
batchSize: number;
options: ParsedOptions;
projectPath: string;
debugLog: DebugLogger;
}) {
const directDependencyNames = readDirectDependencyNames(params.projectPath, !!params.options.prodOnly);
const findings = await scanPackages(params.scanInput.packages, params.batchSize, params.options, {
directDependencyNames,
scanSource: params.scanInput.source,
scanFilePath: params.scanInput.filePath,
});
}, params.debugLog);

if (params.options.usage) {
logInfo(`Scanning project source for usage hints...`, params.options);
Expand Down
Loading