Skip to content

Commit c7214a7

Browse files
authored
fix(manifest): block on a non-blocking JVM failure whose module resolved nowhere (#1647)
* fix(manifest): block on a non-blocking JVM failure whose module resolved nowhere Variant ambiguity is lenient because the module is almost always captured through another configuration. The report now checks that: an ambiguous module that resolved in no configuration of its project is missing from the SBOM, so it blocks. Reachability scans also name --reach-continue-on-install-errors next to ignoreUnresolved, since reachability cannot install a dependency that was let through. * fix(manifest): judge a lenient JVM failure per project Failures were merged by coordinate across projects before checking whether the module resolved, so one project that resolved it kept the failure lenient for another project whose SBOM lacked it. Blocking is now decided per failure, and failures aggregate by coordinate, category and that decision.
1 parent 18ed2ae commit c7214a7

10 files changed

Lines changed: 244 additions & 39 deletions

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
1515
- JVM dependencies whose jars cannot be fetched, or are gone from disk, no longer reach reachability analysis looking resolved without code.
1616
- 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.
1717
- 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.
18+
- Reachability scans that hit unresolved JVM dependencies now point out that ignoring them also needs `--reach-continue-on-install-errors`.
19+
- 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.
1820

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

‎src/commands/manifest/generate-recursive-manifests.mts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import {
1212
import { parseBuildToolOpts } from './parse-build-tool-opts.mts'
1313
import { runManifestFacts } from './run-manifest-facts.mts'
1414
import { resolveBuildToolBin } from './scripts/build-tool.mts'
15-
import { DSI_SETUP_COMMAND } from './scripts/resolution-report-render.mts'
15+
import {
16+
DSI_SETUP_COMMAND,
17+
REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG,
18+
} from './scripts/resolution-report-render.mts'
1619
import { SOCKET_JSON } from '../../constants.mts'
1720
import { withTmpDir } from '../../utils/fs.mts'
1821
import {
@@ -179,6 +182,9 @@ async function runEcosystemCandidates({
179182
excludeConfigs,
180183
excludePaths: excludePathsForRoot,
181184
hints: {
185+
continueOnInstallErrorsFlag: withFiles
186+
? REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG
187+
: undefined,
182188
flags: optionFlags,
183189
setupCommand: DSI_SETUP_COMMAND,
184190
socketJson: displayPath(path.join(dir, SOCKET_JSON)),

‎src/commands/manifest/generate_auto_manifest.mts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { convertSbtToMaven } from './convert_sbt_to_maven.mts'
1111
import { handleManifestConda } from './handle-manifest-conda.mts'
1212
import { parseBuildToolOpts } from './parse-build-tool-opts.mts'
1313
import { resolveBuildToolBin } from './scripts/build-tool.mts'
14+
import { REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG } from './scripts/resolution-report-render.mts'
1415
import { hasSidecarEntries, serializeSidecar } from './scripts/sidecar.mts'
1516
import {
1617
ENVIRONMENT_YML,
@@ -91,6 +92,12 @@ export async function generateAutoManifest({
9192
}): Promise<GenerateAutoManifestResult> {
9293
const sockJson = readOrDefaultSocketJson(cwd)
9394
const generatedFiles: string[] = []
95+
const hints: BuildOptionHints = computeArtifactsSidecar
96+
? {
97+
...AUTO_MANIFEST_HINTS,
98+
continueOnInstallErrorsFlag: REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG,
99+
}
100+
: AUTO_MANIFEST_HINTS
94101

95102
// Resolved paths across all JVM roots, serialized to one sidecar at the end.
96103
const sidecarAcc: SidecarAccumulator | undefined = computeArtifactsSidecar
@@ -127,7 +134,7 @@ export async function generateAutoManifest({
127134
),
128135
includeConfigs: sockJson.defaults?.manifest?.sbt?.includeConfigs ?? '',
129136
javaHome: sockJson.defaults?.manifest?.sbt?.javaHome ?? undefined,
130-
hints: AUTO_MANIFEST_HINTS,
137+
hints,
131138
sidecarAcc,
132139
tmpDir,
133140
withFiles: computeArtifactsSidecar,
@@ -173,7 +180,7 @@ export async function generateAutoManifest({
173180
includeConfigs:
174181
sockJson.defaults?.manifest?.gradle?.includeConfigs ?? '',
175182
javaHome: sockJson.defaults?.manifest?.gradle?.javaHome ?? undefined,
176-
hints: AUTO_MANIFEST_HINTS,
183+
hints,
177184
sidecarAcc,
178185
withFiles: computeArtifactsSidecar,
179186
})
@@ -206,7 +213,7 @@ export async function generateAutoManifest({
206213
mavenOpts: parseBuildToolOpts(
207214
sockJson.defaults?.manifest?.maven?.mavenOpts ?? undefined,
208215
),
209-
hints: AUTO_MANIFEST_HINTS,
216+
hints,
210217
sidecarAcc,
211218
verbose: Boolean(sockJson.defaults?.manifest?.maven?.verbose),
212219
withFiles: computeArtifactsSidecar,

‎src/commands/manifest/run-manifest-facts.mts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,12 @@ export async function runManifestFacts({
179179
report.failures,
180180
report.scannedConfigs,
181181
ecosystem,
182-
{ hints, ignoreUnresolved, unscannable: report.unscannable },
182+
{
183+
hints,
184+
ignoreUnresolved,
185+
resolvedModules: report.resolvedModules,
186+
unscannable: report.unscannable,
187+
},
183188
)
184189

185190
if (rendered.hasBlockingFailures) {

‎src/commands/manifest/run-manifest-facts.test.mts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,12 @@ function okResult(buildRoot: string): ManifestRunResult {
2626
projects: [],
2727
},
2828
factsFileName: 'pom.xml.socket.facts.json',
29-
report: { failures: [], scannedConfigs: [], unscannable: [] },
29+
report: {
30+
failures: [],
31+
resolvedModules: new Set(),
32+
scannedConfigs: [],
33+
unscannable: [],
34+
},
3035
artifactPaths: {
3136
pathsById: new Map(),
3237
classpathByProject: new Map(),

‎src/commands/manifest/scripts/assemble.mts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
type SocketFactsSbomMetadata,
99
type SocketFactsSbomProject,
1010
} from './facts.mts'
11+
import { moduleKey } from './resolution-report.mts'
1112

1213
import type { ParsedRecords, RawCoord, RawProject } from './records.mts'
1314
import type { ResolutionReport } from './resolution-report.mts'
@@ -475,5 +476,20 @@ function buildReport(parsed: ParsedRecords): ResolutionReport {
475476
seenUnscannable.add(key)
476477
return true
477478
})
478-
return { failures, scannedConfigs: parsed.scannedConfigs, unscannable }
479+
const resolvedModules = new Set<string>()
480+
if (failures.length) {
481+
for (const r of parsed.roots.values()) {
482+
for (const n of r.nodes.values()) {
483+
resolvedModules.add(
484+
moduleKey(r.projectKey, `${n.coord.group}:${n.coord.name}`),
485+
)
486+
}
487+
}
488+
}
489+
return {
490+
failures,
491+
resolvedModules,
492+
scannedConfigs: parsed.scannedConfigs,
493+
unscannable,
494+
}
479495
}

‎src/commands/manifest/scripts/resolution-report-gradle.mts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ export function classifyGradleFailure(detail: string): FailureCategory {
4545

4646
// Every kind blocks except variant-ambiguity: the module demonstrably exists
4747
// and is almost always captured via another configuration, so it's benign for
48-
// the SBOM (non-blocking, count only). A module that resolves in NO config
49-
// surfaces as not-found / no-matching-variant, which stay blocking.
48+
// the SBOM (non-blocking, count only). The renderer blocks it anyway when the
49+
// module resolved in no configuration of its project.
5050
export const GRADLE_DIALECT: ResolutionDialect = {
5151
label: 'Gradle',
5252
classify: classifyGradleFailure,
@@ -89,6 +89,8 @@ export const GRADLE_DIALECT: ResolutionDialect = {
8989
},
9090
{
9191
key: 'variant-ambiguity',
92+
header: n =>
93+
` Ambiguous variant — ${n} could not choose a variant, and the module resolved in no other configuration:`,
9294
blocking: false,
9395
notice: (n, depCount, configCount) =>
9496
`Skipped ${depCount} dependency(ies) with ambiguous variant selection in ${configCount} configuration(s) — re-run with --verbose for ${n}'s messages.`,

‎src/commands/manifest/scripts/resolution-report-render.mts‎

Lines changed: 78 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { GRADLE_DIALECT } from './resolution-report-gradle.mts'
22
import { SBT_DIALECT } from './resolution-report-ivy.mts'
33
import { MAVEN_DIALECT } from './resolution-report-maven.mts'
4+
import { moduleKey } from './resolution-report.mts'
45
import constants from '../../../constants.mts'
56
import { camelToKebab } from '../../../utils/strings.mts'
67

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

60+
export const REACH_CONTINUE_ON_INSTALL_ERRORS_FLAG =
61+
'--reach-continue-on-install-errors'
62+
5663
// The single-build manifest commands take every option as a flag.
5764
export const FLAG_HINTS: BuildOptionHints = {
5865
flags: new Set<BuildOption>([
@@ -79,19 +86,27 @@ const RESOLUTION_REPORT_ARTIFACT_LIMIT = 15
7986
const RESOLUTION_REPORT_CONFIG_LIMIT = 20
8087

8188
// Drop a bare "group:name" when a versioned "group:name:v" of the same module
82-
// is also present: the lenient resolver reports both forms for one failure.
83-
function dedupCoords(coords: Iterable<string>): string[] {
84-
const set = new Set(coords)
89+
// failed in the same projects: the lenient resolver reports both forms for one
90+
// failure.
91+
function dedupInfos<T extends { coord: string; projects: Set<string> }>(
92+
infos: T[],
93+
): T[] {
8594
const versioned = new Set<string>()
86-
for (const c of set) {
87-
const p = c.split(':')
95+
for (const i of infos) {
96+
const p = i.coord.split(':')
8897
if (p.length >= 3) {
89-
versioned.add(`${p[0]}:${p[1]}`)
98+
for (const project of i.projects) {
99+
versioned.add(moduleKey(project, i.coord))
100+
}
90101
}
91102
}
92-
return [...set]
93-
.filter(c => c.split(':').length >= 3 || !versioned.has(c))
94-
.sort()
103+
return infos
104+
.filter(
105+
i =>
106+
i.coord.split(':').length >= 3 ||
107+
![...i.projects].every(p => versioned.has(moduleKey(p, i.coord))),
108+
)
109+
.sort((a, b) => (a.coord < b.coord ? -1 : a.coord > b.coord ? 1 : 0))
95110
}
96111

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

140+
function installErrorsHint(
141+
hints: BuildOptionHints,
142+
ignored: boolean,
143+
): string[] {
144+
const flag = hints.continueOnInstallErrorsFlag
145+
if (!flag) {
146+
return []
147+
}
148+
return [
149+
ignored
150+
? `Reachability analysis cannot install the ignored dependencies; pass ${flag} unless you already do.`
151+
: `If you ignore unresolved dependencies, also pass ${flag}: reachability analysis cannot install them.`,
152+
]
153+
}
154+
125155
// `excludableConfigs` is empty when excluding configurations cannot clear
126156
// every blocking failure.
127157
function proceedHint(
@@ -164,16 +194,16 @@ function proceedHint(
164194
]
165195
}
166196

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

191-
// Aggregate by (coord, category): one module can fail with different causes
192-
// across configs. Keep first-seen detail, union the configs and projects.
221+
// A non-blocking kind is only benign when its module resolved anyway: one
222+
// that resolved in no configuration of its project is missing from that
223+
// project's SBOM.
224+
const resolved = opts.resolvedModules
225+
const isFailureBlocking = (
226+
category: FailureCategory,
227+
f: ResolutionFailure,
228+
): boolean =>
229+
isBlocking(category) ||
230+
(!!resolved && !resolved.has(moduleKey(f.project, f.coord)))
231+
232+
// Aggregate by (coord, category, blocking): one module can fail with
233+
// different causes across configs, and block in only some projects. Keep
234+
// first-seen detail, union the configs and projects.
193235
type CoordInfo = {
194236
coord: string
195237
category: FailureCategory
238+
blocking: boolean
196239
detail: string
197240
configs: Set<string>
198241
projects: Set<string>
199242
// Some failure of this coord belongs to no configuration.
200243
configless: boolean
201244
}
202245
const byKey = new Map<string, CoordInfo>()
203-
const keyOf = (coord: string, category: FailureCategory): string =>
204-
`${coord} ${category}`
205246
for (const f of failures) {
206247
const category = dialect.classify(f.detail)
207-
const key = keyOf(f.coord, category)
248+
const blocking = isFailureBlocking(category, f)
249+
const key = `${f.coord} ${category} ${blocking}`
208250
let info = byKey.get(key)
209251
if (!info) {
210252
info = {
211253
coord: f.coord,
212254
category,
255+
blocking,
213256
detail: f.detail,
214257
configs: new Set(),
215258
projects: new Set(),
@@ -227,6 +270,7 @@ export function renderResolutionReport(
227270
}
228271
}
229272
const allInfos = [...byKey.values()]
273+
const isInfoBlocking = (info: CoordInfo): boolean => info.blocking
230274

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

242286
const perDepBlockingConfigs = new Set<string>()
243287
for (const info of allInfos) {
244-
if (isBlocking(info.category)) {
288+
if (isInfoBlocking(info)) {
245289
for (const c of info.configs) {
246290
perDepBlockingConfigs.add(c)
247291
}
@@ -256,24 +300,24 @@ export function renderResolutionReport(
256300
// its project incomplete, so none can be reported as succeeded.
257301
const hasBlockingConfiglessFailure =
258302
blockingUnscannableProjects.length > 0 ||
259-
allInfos.some(i => i.configless && isBlocking(i.category))
303+
allInfos.some(i => i.configless && isInfoBlocking(i))
260304
// An un-scannable config was attempted but resolved nothing, so it didn't succeed.
261305
const succeeded = hasBlockingConfiglessFailure
262306
? []
263307
: scannedConfigs
264308
.filter(c => !blockingConfigs.has(c) && !unscannableConfigs.has(c))
265309
.sort()
266310

267-
const groups = dialect.categories
268-
.map(spec => ({
269-
spec,
270-
infos: dedupCoords(
271-
allInfos.filter(i => i.category === spec.key).map(i => i.coord),
272-
).map(c => byKey.get(keyOf(c, spec.key))!),
273-
}))
311+
const groups = dialect.categories.map(spec => ({
312+
spec,
313+
infos: dedupInfos(allInfos.filter(i => i.category === spec.key)),
314+
}))
315+
const blockingGroups = groups
316+
.map(g => ({ ...g, infos: g.infos.filter(isInfoBlocking) }))
317+
.filter(g => g.infos.length)
318+
const nonBlockingGroups = groups
319+
.map(g => ({ ...g, infos: g.infos.filter(i => !isInfoBlocking(i)) }))
274320
.filter(g => g.infos.length)
275-
const blockingGroups = groups.filter(g => g.spec.blocking)
276-
const nonBlockingGroups = groups.filter(g => !g.spec.blocking)
277321
const blockingCount = blockingGroups.reduce((n, g) => n + g.infos.length, 0)
278322
const hasBlockingFailures =
279323
blockingCount > 0 || blockingUnscannable.length > 0
@@ -366,7 +410,10 @@ export function renderResolutionReport(
366410
tool,
367411
hasBlockingConfiglessFailure ? [] : blockingFailed,
368412
),
413+
...installErrorsHint(hints, false),
369414
)
415+
} else if (hints.continueOnInstallErrorsFlag) {
416+
out.push('', ...installErrorsHint(hints, true))
370417
}
371418
out.push('')
372419
out.push(
@@ -384,8 +431,8 @@ export function renderResolutionReport(
384431
const configCount = new Set(infos.flatMap(i => [...i.configs])).size
385432
notices.push(spec.notice(name, infos.length, configCount))
386433
}
387-
// A config-level throw whose cause classifies as variant ambiguity is surfaced, not failed —
388-
// matching the deliberately-lenient per-dep variant-ambiguity policy.
434+
// A config-level throw whose cause classifies as variant ambiguity is surfaced, not failed: many
435+
// configurations are marked resolvable yet too underspecified to resolve.
389436
if (nonBlockingUnscannable.length) {
390437
const n = new Set(nonBlockingUnscannable.map(u => u.config)).size
391438
notices.push(
@@ -443,6 +490,7 @@ export function renderResolutionErrorReport(
443490
opts: {
444491
hints?: BuildOptionHints | undefined
445492
ignoreUnresolved?: boolean | undefined
493+
resolvedModules?: ReadonlySet<string> | undefined
446494
unscannable?: Unscannable[] | undefined
447495
} = {},
448496
): RenderedResolutionReport {

0 commit comments

Comments
 (0)