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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- JVM dependencies whose jars cannot be fetched, or are gone from disk, no longer reach reachability analysis looking resolved without code.
- One unresolvable Maven dependency no longer strips the jar paths from every other dependency of its build, which made reachability analysis call their vulnerabilities unreachable. Jars that cannot be fetched are reported with their real cause, Maven 4 included.
- Maven dependencies with missing or invalid POMs are reported in every module that uses them, `system`-scope jars reach reachability analysis, and a failing dependency no longer fails scopes that `excludeConfigs` leaves out or the build when it lost a version conflict.
- Reachability scans that hit unresolved JVM dependencies now point out that ignoring them also needs `--reach-continue-on-install-errors`.
- A Gradle dependency whose variant is ambiguous in every configuration of its project now fails the run instead of quietly dropping out of the scan.

## [1.10.0](https://github.com/SocketDev/socket-cli/releases/tag/v1.10.0) - 2026-10-22

Expand Down
8 changes: 7 additions & 1 deletion src/commands/manifest/generate-recursive-manifests.mts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import {
import { parseBuildToolOpts } from './parse-build-tool-opts.mts'
import { runManifestFacts } from './run-manifest-facts.mts'
import { resolveBuildToolBin } from './scripts/build-tool.mts'
import { DSI_SETUP_COMMAND } from './scripts/resolution-report-render.mts'
import {
DSI_SETUP_COMMAND,
REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG,
} from './scripts/resolution-report-render.mts'
import { SOCKET_JSON } from '../../constants.mts'
import { withTmpDir } from '../../utils/fs.mts'
import {
Expand Down Expand Up @@ -179,6 +182,9 @@ async function runEcosystemCandidates({
excludeConfigs,
excludePaths: excludePathsForRoot,
hints: {
continueOnInstallErrorsFlag: withFiles
? REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG
: undefined,
flags: optionFlags,
setupCommand: DSI_SETUP_COMMAND,
socketJson: displayPath(path.join(dir, SOCKET_JSON)),
Expand Down
13 changes: 10 additions & 3 deletions src/commands/manifest/generate_auto_manifest.mts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { convertSbtToMaven } from './convert_sbt_to_maven.mts'
import { handleManifestConda } from './handle-manifest-conda.mts'
import { parseBuildToolOpts } from './parse-build-tool-opts.mts'
import { resolveBuildToolBin } from './scripts/build-tool.mts'
import { REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG } from './scripts/resolution-report-render.mts'
import { hasSidecarEntries, serializeSidecar } from './scripts/sidecar.mts'
import {
ENVIRONMENT_YML,
Expand Down Expand Up @@ -91,6 +92,12 @@ export async function generateAutoManifest({
}): Promise<GenerateAutoManifestResult> {
const sockJson = readOrDefaultSocketJson(cwd)
const generatedFiles: string[] = []
const hints: BuildOptionHints = computeArtifactsSidecar
? {
...AUTO_MANIFEST_HINTS,
continueOnInstallErrorsFlag: REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG,
}
: AUTO_MANIFEST_HINTS

// Resolved paths across all JVM roots, serialized to one sidecar at the end.
const sidecarAcc: SidecarAccumulator | undefined = computeArtifactsSidecar
Expand Down Expand Up @@ -127,7 +134,7 @@ export async function generateAutoManifest({
),
includeConfigs: sockJson.defaults?.manifest?.sbt?.includeConfigs ?? '',
javaHome: sockJson.defaults?.manifest?.sbt?.javaHome ?? undefined,
hints: AUTO_MANIFEST_HINTS,
hints,
sidecarAcc,
tmpDir,
withFiles: computeArtifactsSidecar,
Expand Down Expand Up @@ -173,7 +180,7 @@ export async function generateAutoManifest({
includeConfigs:
sockJson.defaults?.manifest?.gradle?.includeConfigs ?? '',
javaHome: sockJson.defaults?.manifest?.gradle?.javaHome ?? undefined,
hints: AUTO_MANIFEST_HINTS,
hints,
sidecarAcc,
withFiles: computeArtifactsSidecar,
})
Expand Down Expand Up @@ -206,7 +213,7 @@ export async function generateAutoManifest({
mavenOpts: parseBuildToolOpts(
sockJson.defaults?.manifest?.maven?.mavenOpts ?? undefined,
),
hints: AUTO_MANIFEST_HINTS,
hints,
sidecarAcc,
verbose: Boolean(sockJson.defaults?.manifest?.maven?.verbose),
withFiles: computeArtifactsSidecar,
Expand Down
7 changes: 6 additions & 1 deletion src/commands/manifest/run-manifest-facts.mts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,12 @@ export async function runManifestFacts({
report.failures,
report.scannedConfigs,
ecosystem,
{ hints, ignoreUnresolved, unscannable: report.unscannable },
{
hints,
ignoreUnresolved,
resolvedModules: report.resolvedModules,
unscannable: report.unscannable,
},
)

if (rendered.hasBlockingFailures) {
Expand Down
7 changes: 6 additions & 1 deletion src/commands/manifest/run-manifest-facts.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@ function okResult(buildRoot: string): ManifestRunResult {
projects: [],
},
factsFileName: 'pom.xml.socket.facts.json',
report: { failures: [], scannedConfigs: [], unscannable: [] },
report: {
failures: [],
resolvedModules: new Set(),
scannedConfigs: [],
unscannable: [],
},
artifactPaths: {
pathsById: new Map(),
classpathByProject: new Map(),
Expand Down
18 changes: 17 additions & 1 deletion src/commands/manifest/scripts/assemble.mts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type SocketFactsSbomMetadata,
type SocketFactsSbomProject,
} from './facts.mts'
import { moduleKey } from './resolution-report.mts'

import type { ParsedRecords, RawCoord, RawProject } from './records.mts'
import type { ResolutionReport } from './resolution-report.mts'
Expand Down Expand Up @@ -475,5 +476,20 @@ function buildReport(parsed: ParsedRecords): ResolutionReport {
seenUnscannable.add(key)
return true
})
return { failures, scannedConfigs: parsed.scannedConfigs, unscannable }
const resolvedModules = new Set<string>()
if (failures.length) {
for (const r of parsed.roots.values()) {
for (const n of r.nodes.values()) {
resolvedModules.add(
moduleKey(r.projectKey, `${n.coord.group}:${n.coord.name}`),
)
}
}
}
return {
failures,
resolvedModules,
scannedConfigs: parsed.scannedConfigs,
unscannable,
}
}
6 changes: 4 additions & 2 deletions src/commands/manifest/scripts/resolution-report-gradle.mts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ export function classifyGradleFailure(detail: string): FailureCategory {

// Every kind blocks except variant-ambiguity: the module demonstrably exists
// and is almost always captured via another configuration, so it's benign for
// the SBOM (non-blocking, count only). A module that resolves in NO config
// surfaces as not-found / no-matching-variant, which stay blocking.
// the SBOM (non-blocking, count only). The renderer blocks it anyway when the
// module resolved in no configuration of its project.
export const GRADLE_DIALECT: ResolutionDialect = {
label: 'Gradle',
classify: classifyGradleFailure,
Expand Down Expand Up @@ -89,6 +89,8 @@ export const GRADLE_DIALECT: ResolutionDialect = {
},
{
key: 'variant-ambiguity',
header: n =>
` Ambiguous variant — ${n} could not choose a variant, and the module resolved in no other configuration:`,
blocking: false,
notice: (n, depCount, configCount) =>
`Skipped ${depCount} dependency(ies) with ambiguous variant selection in ${configCount} configuration(s) — re-run with --verbose for ${n}'s messages.`,
Expand Down
108 changes: 78 additions & 30 deletions src/commands/manifest/scripts/resolution-report-render.mts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { GRADLE_DIALECT } from './resolution-report-gradle.mts'
import { SBT_DIALECT } from './resolution-report-ivy.mts'
import { MAVEN_DIALECT } from './resolution-report-maven.mts'
import { moduleKey } from './resolution-report.mts'
import constants from '../../../constants.mts'
import { camelToKebab } from '../../../utils/strings.mts'

Expand Down Expand Up @@ -43,6 +44,9 @@ export type BuildOption = 'excludeConfigs' | 'ignoreUnresolved' | 'verbose'
// Where the invoking command lets the user set each build option, so a remedy
// names something they can actually set.
export type BuildOptionHints = {
// Reachability scans only: reachability cannot install a dependency that
// `ignoreUnresolved` lets through, so it must be told to continue too.
continueOnInstallErrorsFlag?: string | undefined
// Taken as flags; every other option is set in `socketJson`.
flags: ReadonlySet<BuildOption>
// Wizard that writes `socketJson`.
Expand All @@ -53,6 +57,9 @@ export type BuildOptionHints = {
export const DSI_SETUP_COMMAND =
'socket manifest setup --dynamic-sbom-inference'

export const REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG =
'--reach-continue-on-install-errors'

// The single-build manifest commands take every option as a flag.
export const FLAG_HINTS: BuildOptionHints = {
flags: new Set<BuildOption>([
Expand All @@ -79,19 +86,27 @@ const RESOLUTION_REPORT_ARTIFACT_LIMIT = 15
const RESOLUTION_REPORT_CONFIG_LIMIT = 20

// Drop a bare "group:name" when a versioned "group:name:v" of the same module
// is also present: the lenient resolver reports both forms for one failure.
function dedupCoords(coords: Iterable<string>): string[] {
const set = new Set(coords)
// failed in the same projects: the lenient resolver reports both forms for one
// failure.
function dedupInfos<T extends { coord: string; projects: Set<string> }>(
infos: T[],
): T[] {
const versioned = new Set<string>()
for (const c of set) {
const p = c.split(':')
for (const i of infos) {
const p = i.coord.split(':')
if (p.length >= 3) {
versioned.add(`${p[0]}:${p[1]}`)
for (const project of i.projects) {
versioned.add(moduleKey(project, i.coord))
}
}
}
return [...set]
.filter(c => c.split(':').length >= 3 || !versioned.has(c))
.sort()
return infos
.filter(
i =>
i.coord.split(':').length >= 3 ||
![...i.projects].every(p => versioned.has(moduleKey(p, i.coord))),
)
.sort((a, b) => (a.coord < b.coord ? -1 : a.coord > b.coord ? 1 : 0))
}

function firstLine(s: string): string {
Expand Down Expand Up @@ -122,6 +137,21 @@ function inProjects(projects: string | Iterable<string>): string {
return list.length ? ` (in ${fmtList(list, 3)})` : ''
}

function installErrorsHint(
Comment thread
jfblaa marked this conversation as resolved.
hints: BuildOptionHints,
ignored: boolean,
): string[] {
const flag = hints.continueOnInstallErrorsFlag
if (!flag) {
return []
}
return [
ignored
? `Reachability analysis cannot install the ignored dependencies; pass ${flag} unless you already do.`
: `If you ignore unresolved dependencies, also pass ${flag}: reachability analysis cannot install them.`,
]
}

// `excludableConfigs` is empty when excluding configurations cannot clear
// every blocking failure.
function proceedHint(
Expand Down Expand Up @@ -164,16 +194,16 @@ function proceedHint(
]
}

// Severity is per-kind; the exit-code decision lives in the caller. We do NOT
// cross-reference what resolved elsewhere: the failed selector carries no
// classifier/type, so relating a failed and a succeeded dep is unsound.
// What resolved elsewhere is consulted only by module (group:name): a failed
// selector carries no classifier/type to relate it to one artifact.
export function renderResolutionReport(
failures: ResolutionFailure[],
scannedConfigs: string[],
tool: BuildTool,
opts: {
hints?: BuildOptionHints | undefined
ignoreUnresolved?: boolean | undefined
resolvedModules?: ReadonlySet<string> | undefined
unscannable?: Unscannable[] | undefined
} = {},
): RenderedResolutionReport {
Expand All @@ -188,28 +218,41 @@ export function renderResolutionReport(
const isBlocking = (cat: FailureCategory): boolean =>
specOf.get(cat)?.blocking ?? true

// Aggregate by (coord, category): one module can fail with different causes
// across configs. Keep first-seen detail, union the configs and projects.
// A non-blocking kind is only benign when its module resolved anyway: one
// that resolved in no configuration of its project is missing from that
// project's SBOM.
const resolved = opts.resolvedModules
const isFailureBlocking = (
category: FailureCategory,
f: ResolutionFailure,
): boolean =>
isBlocking(category) ||
(!!resolved && !resolved.has(moduleKey(f.project, f.coord)))

// Aggregate by (coord, category, blocking): one module can fail with
// different causes across configs, and block in only some projects. Keep
// first-seen detail, union the configs and projects.
type CoordInfo = {
coord: string
category: FailureCategory
blocking: boolean
detail: string
configs: Set<string>
projects: Set<string>
// Some failure of this coord belongs to no configuration.
configless: boolean
}
const byKey = new Map<string, CoordInfo>()
const keyOf = (coord: string, category: FailureCategory): string =>
`${coord} ${category}`
for (const f of failures) {
const category = dialect.classify(f.detail)
const key = keyOf(f.coord, category)
const blocking = isFailureBlocking(category, f)
const key = `${f.coord} ${category} ${blocking}`
let info = byKey.get(key)
if (!info) {
info = {
coord: f.coord,
category,
blocking,
detail: f.detail,
configs: new Set(),
projects: new Set(),
Expand All @@ -227,6 +270,7 @@ export function renderResolutionReport(
}
}
const allInfos = [...byKey.values()]
const isInfoBlocking = (info: CoordInfo): boolean => info.blocking

// A whole-config throw is classified by the same cause rules as a per-dep
// failure: ambiguity stays lenient, every other cause is fail-closed.
Expand All @@ -241,7 +285,7 @@ export function renderResolutionReport(

const perDepBlockingConfigs = new Set<string>()
for (const info of allInfos) {
if (isBlocking(info.category)) {
if (isInfoBlocking(info)) {
for (const c of info.configs) {
perDepBlockingConfigs.add(c)
}
Expand All @@ -256,24 +300,24 @@ export function renderResolutionReport(
// its project incomplete, so none can be reported as succeeded.
const hasBlockingConfiglessFailure =
blockingUnscannableProjects.length > 0 ||
allInfos.some(i => i.configless && isBlocking(i.category))
allInfos.some(i => i.configless && isInfoBlocking(i))
// An un-scannable config was attempted but resolved nothing, so it didn't succeed.
const succeeded = hasBlockingConfiglessFailure
? []
: scannedConfigs
.filter(c => !blockingConfigs.has(c) && !unscannableConfigs.has(c))
.sort()

const groups = dialect.categories
.map(spec => ({
spec,
infos: dedupCoords(
allInfos.filter(i => i.category === spec.key).map(i => i.coord),
).map(c => byKey.get(keyOf(c, spec.key))!),
}))
const groups = dialect.categories.map(spec => ({
spec,
infos: dedupInfos(allInfos.filter(i => i.category === spec.key)),
}))
const blockingGroups = groups
.map(g => ({ ...g, infos: g.infos.filter(isInfoBlocking) }))
.filter(g => g.infos.length)
const nonBlockingGroups = groups
.map(g => ({ ...g, infos: g.infos.filter(i => !isInfoBlocking(i)) }))
.filter(g => g.infos.length)
const blockingGroups = groups.filter(g => g.spec.blocking)
const nonBlockingGroups = groups.filter(g => !g.spec.blocking)
const blockingCount = blockingGroups.reduce((n, g) => n + g.infos.length, 0)
const hasBlockingFailures =
blockingCount > 0 || blockingUnscannable.length > 0
Expand Down Expand Up @@ -366,7 +410,10 @@ export function renderResolutionReport(
tool,
hasBlockingConfiglessFailure ? [] : blockingFailed,
),
...installErrorsHint(hints, false),
)
} else if (hints.continueOnInstallErrorsFlag) {
out.push('', ...installErrorsHint(hints, true))
}
out.push('')
out.push(
Expand All @@ -384,8 +431,8 @@ export function renderResolutionReport(
const configCount = new Set(infos.flatMap(i => [...i.configs])).size
notices.push(spec.notice(name, infos.length, configCount))
}
// A config-level throw whose cause classifies as variant ambiguity is surfaced, not failed —
// matching the deliberately-lenient per-dep variant-ambiguity policy.
// A config-level throw whose cause classifies as variant ambiguity is surfaced, not failed: many
// configurations are marked resolvable yet too underspecified to resolve.
if (nonBlockingUnscannable.length) {
const n = new Set(nonBlockingUnscannable.map(u => u.config)).size
notices.push(
Expand Down Expand Up @@ -443,6 +490,7 @@ export function renderResolutionErrorReport(
opts: {
hints?: BuildOptionHints | undefined
ignoreUnresolved?: boolean | undefined
resolvedModules?: ReadonlySet<string> | undefined
unscannable?: Unscannable[] | undefined
} = {},
): RenderedResolutionReport {
Expand Down
Loading
Loading