Skip to content
Open
52 changes: 51 additions & 1 deletion src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import yargs from 'yargs'
import { hideBin } from 'yargs/helpers'

import { getProjectLicense, getLicenseDetails } from './license/index.js'
import { runRemediation } from './remediate.js'

import client, { selectTrustifyDABackend, generateSbom } from './index.js'

Expand Down Expand Up @@ -467,16 +468,65 @@ const sbom = {
}
}

const remediate = {
command: 'remediate <path>',
desc: 'Scan and apply vulnerability remediations to manifest files',
builder: yargs => yargs.positional(
'path',
{
desc: 'Path to manifest file or directory',
type: 'string',
normalize: true,
}
).options({
'dry-run': {
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
alias: 'd',
type: 'boolean',
desc: 'Preview changes without modifying files',
},
providers: {
desc: 'Comma-separated list of vulnerability providers (env: TRUSTIFY_DA_PROVIDERS)',
type: 'string',
},
sources: {
desc: 'Comma-separated list of vulnerability sources (env: TRUSTIFY_DA_SOURCES)',
type: 'string',
},
'group-by': {
type: 'string',
choices: ['dependency', 'bundle'],
default: 'dependency',
desc: 'Report grouping strategy',
},
}),
handler: async args => {
try {
const result = await runRemediation(args.path, {
dryRun: args['dry-run'],
providers: args.providers,
sources: args.sources,
groupBy: args['group-by'],
})
console.log(result.output)
process.exit(result.exitCode)
} catch (err) {
console.error(err.message)
process.exit(1)
}
}
}

// parse and invoke the command
yargs(hideBin(process.argv))
.usage(`Usage: ${process.argv[0].includes("node") ? path.parse(process.argv[1]).base : path.parse(process.argv[0]).base} {component|stack|stack-batch|image|validate-token|license|sbom}`)
.usage(`Usage: ${process.argv[0].includes("node") ? path.parse(process.argv[1]).base : path.parse(process.argv[0]).base} {component|stack|stack-batch|image|validate-token|license|sbom|remediate}`)
.command(stack)
.command(stackBatch)
.command(component)
.command(image)
.command(validateToken)
.command(license)
.command(sbom)
.command(remediate)
.scriptName('')
.version(false)
.demandCommand(1)
Expand Down
169 changes: 169 additions & 0 deletions src/remediate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import fs from 'node:fs'
import path from 'node:path'

import analysis from './analysis.js'
import { availableProviders, match } from './provider.js'
import { extractRemediations } from './remediation.js'
import { generateReport } from './remediation_report.js'
import { updateMavenVersions } from './updaters/maven_updater.js'
import { updateTomlVersions } from './updaters/toml_updater.js'

import { selectTrustifyDABackend } from './index.js'

// Mirrors DEFAULT_WORKSPACE_DISCOVERY_IGNORE in workspace.js
const SKIP_DIRS = new Set(['node_modules', '.git'])

const MANIFEST_TYPES = [
{
test: (basename) => basename === 'pom.xml',
updater: updateMavenVersions,
label: 'maven',
},
{
test: (basename) => basename.endsWith('.versions.toml') || basename === 'libs.versions.toml',
updater: updateTomlVersions,
label: 'toml',
},
]

/**
* Discovers supported manifest files recursively within a directory.
* @param {string} dirPath - absolute path to the directory
* @returns {string[]} array of absolute paths to supported manifest files
*/
function discoverManifests(dirPath) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other common directories that should likely be skipped: target/, build/, .gradle/, .mvn/, vendor/.
A pom.xml inside target/ is a generated artifact, not a source manifest.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[sdlc-workflow/verify-pr] Classified as suggestion — proposes additional directory exclusions (target/, build/, .gradle/, .mvn/, vendor/). Valid improvement but no documented convention or established pattern requires these specific exclusions. No sub-task created.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I havent looked at existing implementations too much, so maybe theres no overlap, but it feels like we're slowly amassing a number of functions that do somewhat similar things (workspace/manifest discovery), is there anything we could re-use instead of having this function or are all the implementations different enough that they warrant their own functions?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked all existing discovery functions — they're architecturally different: detectWorkspaceManifests() uses tool-assisted resolution (mvn help:evaluate, cargo metadata, Gradle init scripts, etc.) scoped to a single ecosystem per workspace root. discoverManifests() is a lightweight recursive walk that finds files the remediation system knows how to patch (currently just pom.xml and .versions.toml), across ecosystems simultaneously.

The one bit of shared logic was the skip-directory list (node_modules, .git) — extracted that into a SKIP_DIRS constant referencing the same convention from workspace.js.

const manifests = []

function walk(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) {
continue
}
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
walk(fullPath)
} else if (getManifestType(entry.name)) {
manifests.push(fullPath)
}
}
}

walk(dirPath)
return manifests
}

/**
* Returns the manifest type descriptor for a given filename, or null if unsupported.
* @param {string} basename - the file name to check
* @returns {{test: function, updater: function, label: string}|null}
*/
function getManifestType(basename) {
return MANIFEST_TYPES.find(t => t.test(basename)) || null
}

/**
* Orchestrates the full remediation pipeline for a single manifest or directory:
* discover manifests → scan via DA backend → extract remediations → apply or preview.
*
* @param {string} targetPath - path to a manifest file or directory
* @param {object} [options]
* @param {boolean} [options.dryRun=false] - preview changes without modifying files (applies by default)
* @param {string} [options.providers] - comma-separated provider list
* @param {string} [options.sources] - comma-separated source list
* @param {'dependency'|'bundle'} [options.groupBy='dependency'] - report grouping strategy
* @returns {Promise<{exitCode: number, output: string}>}
*/
export async function runRemediation(targetPath, options = {}) {
const { dryRun = false, providers, sources, groupBy = 'dependency' } = options

const resolvedPath = path.resolve(targetPath)

let manifestPaths
let stat
try {
stat = fs.statSync(resolvedPath)
} catch {
throw new Error(`Path not found: ${resolvedPath}`)
}
if (stat.isDirectory()) {
manifestPaths = discoverManifests(resolvedPath)
if (manifestPaths.length === 0) {
return { exitCode: 0, output: 'No supported manifest files found.' }
}
} else {
const basename = path.basename(resolvedPath)
if (!getManifestType(basename)) {
throw new Error(`Unsupported manifest type: ${basename}`)
}
manifestPaths = [resolvedPath]
}

const opts = {}
if (providers) {
opts.TRUSTIFY_DA_PROVIDERS = providers
}
if (sources) {
opts.TRUSTIFY_DA_SOURCES = sources
}

const url = selectTrustifyDABackend(opts)
const allRemediations = []
const appliedFiles = []

for (const manifestPath of manifestPaths) {
const basename = path.basename(manifestPath)
const manifestType = getManifestType(basename)
if (!manifestType) {
continue
}

let provider
try {
provider = match(manifestPath, availableProviders, opts)
} catch {
continue
}

const analysisReport = await analysis.requestStack(provider, manifestPath, url, false, opts)
const remediations = extractRemediations(analysisReport, {
providerPriority: providers ? providers.split(',').map(p => p.trim()).filter(Boolean) : undefined,
})
Comment thread
sourcery-ai[bot] marked this conversation as resolved.

if (remediations.length === 0) {
continue
}

allRemediations.push(...remediations)

if (!dryRun) {
const content = fs.readFileSync(manifestPath, 'utf-8')
const versionChanges = remediations.map(r => ({
groupId: r.groupId,
artifactId: r.artifactId,
newVersion: r.fixedInVersion,
}))

const result = manifestType.updater(content, versionChanges)
if (result.applied.length > 0) {
fs.writeFileSync(manifestPath, result.content, 'utf-8')
appliedFiles.push(manifestPath)
}
}
}

if (allRemediations.length === 0) {
return { exitCode: 0, output: 'No remediations found.' }
}

const report = generateReport(allRemediations, { groupBy })

if (dryRun) {
return { exitCode: 2, output: report }
}

const summary = appliedFiles.length > 0
? `Updated ${appliedFiles.length} file(s):\n${appliedFiles.map(f => ` ${f}`).join('\n')}\n\n${report}`
: report
return { exitCode: 0, output: summary }
}
Loading
Loading