Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# macOS
.DS_Store
.claude

# pkgmap export output
pkgmap-export.json
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
1 change: 1 addition & 0 deletions GEMINI.md
7 changes: 7 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.PHONY: test lint fmt fmt-check verify

test: ; npm test
lint: ; npm run lint
fmt: ; npm run format
fmt-check: ; npm run format:check
verify: ; npm run format:check && npm run lint && npm test
19 changes: 11 additions & 8 deletions package-lock.json

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

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"eslint": "^10.4.0",
"prettier": "^3.0.0"
"eslint": "^10.5.0",
"prettier": "^3.8.4"
}
}
45 changes: 6 additions & 39 deletions src/audit.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import chalk from 'chalk'
import Table from 'cli-table3'

import { renderBanner, MANAGER_ICONS } from './display/table.js'
import { ALL_SCANNERS, printIssueSummary } from './index.js'
import { scanAll, printIssueSummary } from './index.js'
import { getCliOptionValue, hasCliFlag, optsOf } from './utils.js'

const OSV_QUERY_BATCH_URL = 'https://api.osv.dev/v1/querybatch'
const BATCH_SIZE = 100
Expand Down Expand Up @@ -148,20 +149,6 @@ function renderAuditResults(results) {
console.log()
}

function getCliOptionValue(flags) {
for (let index = 0; index < process.argv.length; index += 1) {
const token = process.argv[index]
if (!flags.includes(token)) continue
return process.argv[index + 1]
}

return undefined
}

function hasCliFlag(flags) {
return process.argv.some((token) => flags.includes(token))
}

function chunk(items, size) {
const batches = []
for (let index = 0; index < items.length; index += size) {
Expand Down Expand Up @@ -217,7 +204,7 @@ async function auditPackages(manager, packages) {
}

export async function runAudit(options) {
const resolvedOptions = typeof options?.opts === 'function' ? options.opts() : options
const resolvedOptions = optsOf(options)
const parentOptions = options?.parent?.opts?.() || {}
const filterManager = (
resolvedOptions?.manager ||
Expand All @@ -231,34 +218,14 @@ export async function runAudit(options) {
resolvedOptions?.json || parentOptions.json || hasCliFlag(['--json', '-j'])
)

let scanners = Object.entries(ALL_SCANNERS)
if (filterManager) {
const selected = ALL_SCANNERS[filterManager]
if (!selected) {
console.error(chalk.red(`✗ Unknown manager: "${filterManager}"`))
console.error(` Available: ${Object.keys(ALL_SCANNERS).join(', ')}`)
process.exit(1)
}
scanners = [[filterManager, selected]]
}

const spinner = doJson ? { stop() {} } : ora('Checking package audit status...').start()
const scanIssues = []

try {
const settled = await Promise.allSettled(scanners.map(([_name, scanFn]) => scanFn()))
const { results: scannedRaw, issues } = await scanAll(filterManager)
scanIssues.push(...issues)

const scanned = settled
.map((entry, index) => {
if (entry.status === 'fulfilled') return entry.value
scanIssues.push({
manager: scanners[index][0],
message: entry.reason?.message || 'scan failed unexpectedly',
level: 'error',
})
return null
})
.filter(Boolean)
const scanned = scannedRaw
.map((result) => ({
...result,
packages: packageFilter
Expand Down
67 changes: 41 additions & 26 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import macportsScanner from './scanners/macports.js'
import opamScanner from './scanners/opam.js'
import vcpkgScanner from './scanners/vcpkg.js'
import { renderAll } from './display/table.js'
import { optsOf } from './utils.js'

export function normalizeWarning(args) {
return args
Expand Down Expand Up @@ -127,8 +128,44 @@ export const ALL_SCANNERS = {
vcpkg: vcpkgScanner,
}

// Resolve the scanner list for an optional manager filter; exit on unknown name.
export function resolveScanners(filterManager) {
if (!filterManager) return Object.entries(ALL_SCANNERS)

const key = filterManager.toLowerCase()
const selected = ALL_SCANNERS[key]
if (!selected) {
console.error(chalk.red(`✗ Unknown manager: "${filterManager}"`))
console.error(` Available: ${Object.keys(ALL_SCANNERS).join(', ')}`)
process.exit(1)
}
return [[key, selected]]
}

// Run every selected scanner in parallel; split into results and error issues.
export async function scanAll(filterManager) {
const scanners = resolveScanners(filterManager)
const settled = await Promise.allSettled(scanners.map(([, scanFn]) => scanFn()))

const results = []
const issues = []
settled.forEach((entry, index) => {
if (entry.status === 'fulfilled') {
if (entry.value) results.push(entry.value)
} else {
issues.push({
manager: scanners[index][0],
message: entry.reason?.message || 'scan failed unexpectedly',
level: 'error',
})
}
})

return { results, issues }
}

export async function run(options) {
const resolvedOptions = typeof options?.opts === 'function' ? options.opts() : options
const resolvedOptions = optsOf(options)
const {
manager: filterManager,
search: searchTerm,
Expand All @@ -137,18 +174,6 @@ export async function run(options) {
json: doJson,
} = resolvedOptions

let scanners = Object.entries(ALL_SCANNERS)

if (filterManager) {
const selected = ALL_SCANNERS[filterManager.toLowerCase()]
if (!selected) {
console.error(chalk.red(`✗ Unknown manager: "${filterManager}"`))
console.error(` Available: ${Object.keys(ALL_SCANNERS).join(', ')}`)
process.exit(1)
}
scanners = [[filterManager.toLowerCase(), selected]]
}

const spinner = ora('Scanning package managers...').start()

const scanIssues = []
Expand All @@ -164,24 +189,14 @@ export async function run(options) {
})
}

const settled = await Promise.allSettled(scanners.map(([_name, scanFn]) => scanFn()))
const { results: scanned, issues } = await scanAll(filterManager)

console.warn = originalWarn
spinner.stop()

settled.forEach((entry, index) => {
if (entry.status === 'rejected') {
scanIssues.push({
manager: scanners[index][0],
message: entry.reason?.message || 'scan failed unexpectedly',
level: 'error',
})
}
})
scanIssues.push(...issues)

let results = settled
.map((s) => (s.status === 'fulfilled' ? s.value : null))
.filter((r) => r && r.packages.length > 0)
let results = scanned.filter((r) => r && r.packages.length > 0)

if (results.length === 0) {
console.log(chalk.yellow('No package managers found or all scans failed.'))
Expand Down
7 changes: 3 additions & 4 deletions src/ports.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { execSync } from 'child_process'

import { renderBanner } from './display/table.js'
import { renderPorts } from './display/ports.js'
import { isAvailable } from './utils.js'
import { isAvailable, optsOf } from './utils.js'

function parsePsRow(raw) {
const trimmed = raw.trim()
Expand Down Expand Up @@ -164,8 +164,7 @@ function parseMacPorts(raw) {
const pid = Number(parts[1])
const protocol = (parts[7] || 'tcp').toLowerCase()
const name = parts.slice(8).join(' ')
const match =
name.match(/(.+)->/) || name.match(/(.+)\(LISTEN\)/) || name.match(/(.+)\(LISTEN\)$/)
const match = name.match(/(.+)->/) || name.match(/(.+)\(LISTEN\)/)
const endpoint = (match?.[1] || name).trim()
const local = splitAddressPort(endpoint)

Expand Down Expand Up @@ -279,7 +278,7 @@ function getActivePorts() {
}

export async function runPorts(options) {
const resolvedOptions = typeof options?.opts === 'function' ? options.opts() : options
const resolvedOptions = optsOf(options)
const doJson = Boolean(
resolvedOptions?.json ||
options?.parent?.opts?.().json ||
Expand Down
54 changes: 18 additions & 36 deletions src/scanners/apk.js
Original file line number Diff line number Diff line change
@@ -1,41 +1,23 @@
import { execSync } from 'child_process'
import { isAvailable } from '../utils.js'
import { runScanner } from '../utils.js'

export default async function scan() {
if (process.platform === 'win32') return null
if (!isAvailable('apk')) return null

try {
const raw = execSync('apk info -v', {
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 10000,
}).toString()

const packages = raw
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const match = line.match(/^(.+)-([0-9][A-Za-z0-9._-]*)$/)
if (!match) {
return { name: line, version: 'unknown', type: 'system' }
}

return {
name: match[1].trim(),
version: match[2].trim() || 'unknown',
type: 'system',
}
})
.filter((pkg) => pkg.name)

return { manager: 'apk', packages }
} catch (err) {
if (err.message?.includes('EACCES') || err.message?.includes('permission')) {
console.warn('⚠ apk: permission denied. Try running with sudo.')
} else if (err.signal === 'SIGTERM' || err.code === 'ETIMEDOUT') {
console.warn('⚠ apk: scan timed out, skipping.')
}
return null
}
return runScanner({
manager: 'apk',
bin: 'apk',
command: 'apk info -v',
parse: (raw) =>
raw
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const match = line.match(/^(.+)-([0-9][A-Za-z0-9._-]*)$/)
return match
? { name: match[1].trim(), version: match[2].trim() || 'unknown', type: 'system' }
: { name: line, version: 'unknown', type: 'system' }
})
.filter((pkg) => pkg.name),
})
}
42 changes: 15 additions & 27 deletions src/scanners/apt.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,20 @@
import { execSync } from 'child_process'
import { isAvailable } from '../utils.js'
import { runScanner } from '../utils.js'

export default async function scan() {
if (process.platform === 'win32') return null
if (!isAvailable('dpkg-query')) return null

try {
const raw = execSync('dpkg-query -W -f="${Package}\t${Version}\n"', {
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 10000,
}).toString()

const packages = raw
.split('\n')
.filter(Boolean)
.map((line) => {
const [name, version] = line.split('\t')
return { name: name?.trim(), version: version?.trim() || 'unknown', type: 'system' }
})
.filter((pkg) => pkg.name)

return { manager: 'apt', packages }
} catch (err) {
if (err.message?.includes('EACCES') || err.message?.includes('permission')) {
console.warn('⚠ apt: permission denied. Try running with sudo.')
} else if (err.signal === 'SIGTERM' || err.code === 'ETIMEDOUT') {
console.warn('⚠ apt: scan timed out, skipping.')
}
return null
}
return runScanner({
manager: 'apt',
bin: 'dpkg-query',
command: 'dpkg-query -W -f="${Package}\t${Version}\n"',
parse: (raw) =>
raw
.split('\n')
.filter(Boolean)
.map((line) => {
const [name, version] = line.split('\t')
return { name: name?.trim(), version: version?.trim() || 'unknown', type: 'system' }
})
.filter((pkg) => pkg.name),
})
}
Loading