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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Fixed
- Reachability scans in pnpm and yarn projects no longer print a 403 from the Socket API when run with a repository-scoped token. The safety check that runs before Coana launches now uses Socket's public token and only looks up the Coana package itself.
- `--dynamic-sbom-inference` no longer skips an independent Maven, Gradle or sbt build that shares a directory with a module of another build: only build files another build actually uses count as covered.

## [1.8.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.8.1) - 2026-10-12

Expand Down
5 changes: 3 additions & 2 deletions src/commands/manifest/cmd-manifest-dynamic-sbom-inference.mts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ const config: CliCommandConfig = {
(pom.xml.socket.facts.json, gradle.socket.facts.json, or
sbt.socket.facts.json, so builds sharing a directory never overwrite each
other),
skipping subproject/reactor-module directories a parent build root already
covers. Unlike \`socket manifest auto\`, this looks beyond CWD itself.
skipping build roots whose build file a parent build already reports for
one of its subprojects. Unlike \`socket manifest auto\`, this looks beyond
CWD itself.

Options
${getFlagListOutput(config.flags)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ describe('socket manifest dynamic-sbom-inference', async () => {
(pom.xml.socket.facts.json, gradle.socket.facts.json, or
sbt.socket.facts.json, so builds sharing a directory never overwrite each
other),
skipping subproject/reactor-module directories a parent build root already
covers. Unlike \`socket manifest auto\`, this looks beyond CWD itself.
skipping build roots whose build file a parent build already reports for
one of its subprojects. Unlike \`socket manifest auto\`, this looks beyond
CWD itself.

Options
--exclude-paths List of glob patterns to exclude from manifest/facts generation. Patterns are anchored micromatch globs matched relative to CWD (\`--cwd\` if set): \`tests\` matches only \`<cwd>/tests\`; use \`**/tests\` to match at any depth. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags.
Expand Down
44 changes: 41 additions & 3 deletions src/commands/manifest/discover-manifest-roots.mts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { existsSync } from 'node:fs'
import path from 'node:path'

import { realpathOrResolved } from '../../utils/fs.mts'
import { globWithGitIgnore } from '../../utils/glob.mts'
import { excludePathToScanIgnores } from '../scan/exclude-paths.mts'

import type { BuildTool } from './scripts/build-tool.mts'
import type { SocketFactsSbomProject } from './scripts/facts.mts'
import type { SocketJson } from '../../utils/socket-json.mts'

const BUILD_TOOLS: BuildTool[] = ['gradle', 'maven', 'sbt']
Expand Down Expand Up @@ -57,6 +59,43 @@ export function withoutDisabledFlags(sockJson: SocketJson): SocketJson {

export { realpathOrResolved }

// Gradle reports a project's build file even when absent, which covers
// nothing. A sibling outside the build root is independently locatable.
export async function addCoveredBuildFiles(
covered: Set<string>,
buildRoot: string,
projects: readonly SocketFactsSbomProject[],
): Promise<void> {
const realBuildRoot = await realpathOrResolved(buildRoot)
const files = await Promise.all(
projects
.flatMap(project => project.manifestFiles ?? [])
.map(async ({ file }) => {
const resolved = path.resolve(buildRoot, file)
return path.join(
await realpathOrResolved(path.dirname(resolved)),
path.basename(resolved),
)
}),
)
for (const file of files) {
if (file.startsWith(`${realBuildRoot}${path.sep}`) && existsSync(file)) {
covered.add(file)
}
}
}

// `dir` must be realpath-resolved, as findBuildToolCandidates returns it.
export function isBuildRootCovered(
covered: ReadonlySet<string>,
tool: BuildTool,
dir: string,
): boolean {
return MARKERS_BY_TOOL[tool].some(marker =>
covered.has(path.join(dir, marker)),
)
}

function sortByDepthThenPath(dirs: readonly string[], cwd: string): string[] {
return [...dirs].sort((a, b) => {
const relA = path.relative(cwd, a)
Expand All @@ -70,9 +109,8 @@ function sortByDepthThenPath(dirs: readonly string[], cwd: string): string[] {
})
}

// Depth-sorted (root-most first) so the caller, using each build root's
// `subprojectDir` facts, visits a reactor root before its own members and can
// skip subprojects a parent root already covers.
// Depth-sorted (root-most first) so the caller visits a reactor root before
// its own members and can skip those whose build files it already covers.
export async function findBuildToolCandidates({
cwd,
excludePaths,
Expand Down
11 changes: 10 additions & 1 deletion src/commands/manifest/enumerate-workspaces.mts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { BuildTool } from './scripts/build-tool.mts'
import type { SocketFactsSbomProject } from './scripts/facts.mts'

export type EnumerateWorkspacesResult = {
buildRoot: string
projects: SocketFactsSbomProject[]
}

Expand Down Expand Up @@ -82,5 +83,13 @@ export async function enumerateWorkspaces({
return
}

return { projects: result.projects }
if (!result.buildRoot) {
process.exitCode = 1
logger.fail(
`The ${ecosystem} build did not report its build root, so its projects' paths cannot be located.`,
)
return
}

return { buildRoot: result.buildRoot, projects: result.projects }
}
16 changes: 15 additions & 1 deletion src/commands/manifest/enumerate-workspaces.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const ENV_VAR = 'SOCKET_TEST_ENUMERATE_JAVA_HOME'

function okResult(): WorkspaceEnumerationResult {
return {
buildRoot: '/tmp/some-project',
code: 0,
projects: [
{
Expand Down Expand Up @@ -42,9 +43,10 @@ describe('enumerateWorkspaces', () => {
process.exitCode = undefined
})

it('returns the projects from a successful run', async () => {
it('returns the build root and projects from a successful run', async () => {
vi.mocked(enumerateWorkspacesScript).mockResolvedValue(okResult())
const result = await enumerateWorkspaces(baseArgs)
expect(result?.buildRoot).toBe(okResult().buildRoot)
expect(result?.projects).toEqual(okResult().projects)
})

Expand Down Expand Up @@ -84,6 +86,7 @@ describe('enumerateWorkspaces', () => {

it('fails when the build crashed before producing any workspace records', async () => {
vi.mocked(enumerateWorkspacesScript).mockResolvedValue({
buildRoot: undefined,
code: 1,
projects: [],
stderr: '',
Expand All @@ -96,6 +99,7 @@ describe('enumerateWorkspaces', () => {

it('fails even on a clean exit if no workspace records were produced - every real run reports at least its own root project', async () => {
vi.mocked(enumerateWorkspacesScript).mockResolvedValue({
buildRoot: undefined,
code: 0,
projects: [],
stderr: '',
Expand All @@ -105,4 +109,14 @@ describe('enumerateWorkspaces', () => {
expect(result).toBeUndefined()
expect(process.exitCode).toBe(1)
})

it('fails when the build did not report its build root', async () => {
vi.mocked(enumerateWorkspacesScript).mockResolvedValue({
...okResult(),
buildRoot: undefined,
})
const result = await enumerateWorkspaces(baseArgs)
expect(result).toBeUndefined()
expect(process.exitCode).toBe(1)
})
})
49 changes: 10 additions & 39 deletions src/commands/manifest/generate-recursive-manifests.mts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import path from 'node:path'
import { logger } from '@socketsecurity/registry/lib/logger'

import {
addCoveredBuildFiles,
findBuildToolCandidates,
isBuildRootCovered,
realpathOrResolved,
withoutDisabledFlags,
} from './discover-manifest-roots.mts'
Expand Down Expand Up @@ -109,12 +111,12 @@ async function runEcosystemCandidates({
withFiles: boolean | undefined
}): Promise<RecursiveManifestOutcome[]> {
const outcomes: RecursiveManifestOutcome[] = []
const covered = new Set<string>()
for (const [ecosystem, dirs] of candidatesByTool) {
const covered = new Set<string>()
const disabledRoots: DisabledRoot[] = []
for (let dirIndex = 0; dirIndex < dirs.length; dirIndex += 1) {
const dir = dirs[dirIndex] as string
if (covered.has(dir)) {
if (isBuildRootCovered(covered, ecosystem, dir)) {
outcomes.push({ dir, ecosystem, status: 'skippedCovered' })
continue
}
Expand Down Expand Up @@ -184,35 +186,13 @@ async function runEcosystemCandidates({
continue
}

covered.add(dir)
// `-f`/`-p` can root the reactor away from `dir`.
// eslint-disable-next-line no-await-in-loop
const buildRoot = await realpathOrResolved(path.dirname(result.factsPath))
// eslint-disable-next-line no-await-in-loop
const resolvedSubprojectDirs = await Promise.all(
result.projects.map(project =>
realpathOrResolved(path.resolve(buildRoot, project.subprojectDir)),
),
await addCoveredBuildFiles(
covered,
path.dirname(result.factsPath),
result.projects,
)
for (const subprojectDir of resolvedSubprojectDirs) {
// Only a genuinely nested member (a descendant of this reactor's own
// directory) has no independent existence worth its own standalone
// analysis. A subprojectDir that escapes this reactor's own tree (a
// sibling, e.g. Maven's `<module>../shared-lib</module>` or Gradle's
// relocated projectDir) is independently locatable and potentially
// independently consumed or published - its own un-mediated
// resolution (e.g. a dependency version this reactor's own
// dependency management happens to override) is a distinct,
// meaningful data point, not a redundant one. Never suppress its own
// build-root invocation, regardless of which reactor(s) also
// incorporate it or the order candidates happen to be discovered in.
if (
subprojectDir === buildRoot ||
subprojectDir.startsWith(`${buildRoot}${path.sep}`)
) {
covered.add(subprojectDir)
}
}
outcomes.push({
dir,
ecosystem,
Expand All @@ -224,17 +204,8 @@ async function runEcosystemCandidates({
return outcomes
}

// Generates one Socket facts file per independent gradle/sbt/maven build
// root under `cwd`. Coverage is tracked per ecosystem via the facts SBOM's
// own projects[].subprojectDir, not by pruning the whole discovered subtree,
// so an unrelated nested project a reactor doesn't declare still gets its
// own invocation - and only a properly nested subprojectDir counts as
// coverage at all; one that escapes its declaring reactor's own directory
// still gets its own independent invocation too (see the covered.add call
// below). Fail-closed per ecosystem, not globally: a root whose
// workspace layout can't be determined aborts only that ecosystem's own
// remaining walk (marking its untried candidates 'aborted'), since coverage
// is tracked per ecosystem and an unrelated one has nothing to lose from it.
// A failed root aborts only its own ecosystem's remaining walk: the build
// files it would have covered are all its own tool's.
export async function generateRecursiveManifests({
cwd,
excludePaths,
Expand Down
Loading
Loading