From 22d44a921e501adb0c58d5b6ca4ef783eeb38344 Mon Sep 17 00:00:00 2001 From: miccy <9729864+miccy@users.noreply.github.com> Date: Mon, 4 May 2026 02:35:54 +0000 Subject: [PATCH] perf: optimize package.json dependency loading and unify injection detection - Added caching for package.json dependencies in `loadPackageJsonDeps` to avoid redundant I/O and parsing. - Unified injection detection logic by removing duplicate implementation in `scan.ts` and using the shared detector from `injection.ts`. - Added unit tests for `detectInjection` to ensure correctness and prevent regressions. - Updated CHANGELOG.md. --- CHANGELOG.md | 7 +++ packages/scanner/src/detectors/injection.ts | 16 +++-- packages/scanner/src/scan.ts | 48 +------------- packages/scanner/tests/injection.test.ts | 70 +++++++++++++++++++++ 4 files changed, 91 insertions(+), 50 deletions(-) create mode 100644 packages/scanner/tests/injection.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 135edf2..7c002fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Performance + +- **Optimized `package.json` Loading** — Added caching for `package.json` dependency parsing in the scanner to avoid redundant file I/O and JSON parsing during a single scan run. +- **Unified Injection Detection** — Refactored and unified the injection detection logic between `packages/scanner/src/scan.ts` and `packages/scanner/src/detectors/injection.ts`, ensuring consistent detection and improved performance. + ## [1.5.2] - 2026-04-21 ### Changed diff --git a/packages/scanner/src/detectors/injection.ts b/packages/scanner/src/detectors/injection.ts index 56878b2..83829c8 100644 --- a/packages/scanner/src/detectors/injection.ts +++ b/packages/scanner/src/detectors/injection.ts @@ -2,12 +2,19 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import type { Finding } from '../types.js' +const pkgCache = new Map>() + /** * Load declared packages from package.json */ -function loadPackageJsonDeps(targetDir: string): Set { +export function loadPackageJsonDeps(targetDir: string): Set { + const pkgPath = resolve(targetDir, 'package.json') + if (pkgCache.has(pkgPath)) { + return pkgCache.get(pkgPath)! + } + try { - const pkg = JSON.parse(readFileSync(resolve(targetDir, 'package.json'), 'utf-8')) as any + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as any const declared = new Set() for (const deps of [ pkg.dependencies ?? {}, @@ -18,6 +25,7 @@ function loadPackageJsonDeps(targetDir: string): Set { declared.add(name) } } + pkgCache.set(pkgPath, declared) return declared } catch { return new Set() @@ -42,9 +50,9 @@ export function detectInjection( type: 'injection', severity: 'high', package: pkg.name, - message: `Package "${pkg.name}" found in lockfile but not declared in package.json`, + message: `Package "${pkg.name}" found in lockfile but NOT declared in package.json`, location: `lockfile: ${pkg.name}@${pkg.version ?? 'unknown'}`, - recommendation: `Remove from package.json or investigate origin.`, + recommendation: `Investigate dependency path: npm ls ${pkg.name}.`, }) } } diff --git a/packages/scanner/src/scan.ts b/packages/scanner/src/scan.ts index b85b6c5..3f338a5 100644 --- a/packages/scanner/src/scan.ts +++ b/packages/scanner/src/scan.ts @@ -1,6 +1,7 @@ -import { existsSync, readFileSync } from 'node:fs' +import { existsSync } from 'node:fs' import { basename, resolve } from 'node:path' +import { detectInjection } from './detectors/injection.js' import { parseYarnLockfile } from './parsers/yarn.js' import type { Finding, ScanResult } from './types.js' @@ -107,51 +108,6 @@ function parseNpmLock(path: string): any[] { return packages } -// --------------------------------------------------------------------------- -// Injection detector -// --------------------------------------------------------------------------- - -function detectInjection( - packages: Array<{ name: string; version: string }>, - target: string -): Finding[] { - let pkgJson: any = {} - try { - pkgJson = JSON.parse(readFileSync(resolve(target, 'package.json'), 'utf8')) - } catch { - return [] - } - - const declared = new Set() - const depSource = [ - pkgJson.dependencies ?? {}, - pkgJson.devDependencies ?? {}, - pkgJson.peerDependencies ?? {}, - ] - - for (const deps of depSource) { - for (const name of Object.keys(deps)) { - declared.add(name) - } - } - - const findings: Finding[] = [] - for (const pkg of packages) { - const bare = pkg.name.startsWith('@') ? (pkg.name.split('/')[1] ?? pkg.name) : pkg.name - if (!declared.has(pkg.name) && !declared.has(bare)) { - findings.push({ - type: 'injection', - severity: 'high', - package: pkg.name, - message: `Package "${pkg.name}" found in lockfile but NOT declared in package.json`, - location: `lockfile: ${pkg.name}@${pkg.version}`, - recommendation: `Investigate dependency path: npm ls ${pkg.name}.`, - }) - } - } - return findings -} - // --------------------------------------------------------------------------- // Main scan // --------------------------------------------------------------------------- diff --git a/packages/scanner/tests/injection.test.ts b/packages/scanner/tests/injection.test.ts new file mode 100644 index 0000000..8737c7d --- /dev/null +++ b/packages/scanner/tests/injection.test.ts @@ -0,0 +1,70 @@ +import { expect, test, describe, beforeAll, afterAll } from "bun:test"; +import { detectInjection } from "../src/detectors/injection.js"; +import { writeFileSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; + +const testDir = join(import.meta.dirname, "temp_test_injection"); + +describe("detectInjection", () => { + beforeAll(() => { + try { + mkdirSync(testDir, { recursive: true }); + } catch (e) {} + + const pkgJson = { + dependencies: { + "react": "18.2.0", + "lodash": "4.17.21" + }, + devDependencies: { + "typescript": "5.0.0" + } + }; + writeFileSync(join(testDir, "package.json"), JSON.stringify(pkgJson)); + }); + + afterAll(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + test("should detect injected packages", () => { + const lockPkgs = [ + { name: "react", version: "18.2.0" }, + { name: "malicious-pkg", version: "1.0.0" } + ]; + + const findings = detectInjection(lockPkgs, testDir); + expect(findings).toHaveLength(1); + expect(findings[0].package).toBe("malicious-pkg"); + expect(findings[0].type).toBe("injection"); + }); + + test("should not detect declared packages", () => { + const lockPkgs = [ + { name: "react", version: "18.2.0" }, + { name: "lodash", version: "4.17.21" }, + { name: "typescript", version: "5.0.0" } + ]; + + const findings = detectInjection(lockPkgs, testDir); + expect(findings).toHaveLength(0); + }); + + test("should handle bare names for scoped packages", () => { + const pkgJson = { + dependencies: { + "node": "18.0.0" + } + }; + const scopedTestDir = join(testDir, "scoped"); + try { mkdirSync(scopedTestDir, { recursive: true }); } catch(e) {} + writeFileSync(join(scopedTestDir, "package.json"), JSON.stringify(pkgJson)); + + const lockPkgs = [ + { name: "@types/node", version: "18.0.0" } + ]; + + const findings = detectInjection(lockPkgs, scopedTestDir); + expect(findings).toHaveLength(0); + }); +});