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
14 changes: 14 additions & 0 deletions .config/rollup.dist.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,20 @@ async function copyManifestScripts() {
path.join(destDir, 'socket-workspaces.plugin.scala'),
),
])
// The dotnet tool's publish output (scripts/dotnet-tool/build-tool.sh),
// like the Maven jar absent from a fresh checkout.
const dotnetToolDir = path.join(srcDir, 'dotnet-tool', 'publish')
if (existsSync(path.join(dotnetToolDir, 'socket-facts-dotnet.dll'))) {
await fs.cp(dotnetToolDir, path.join(destDir, 'dotnet-tool'), {
recursive: true,
})
} else if (constants.ENV[INLINED_SOCKET_CLI_PUBLISHED_BUILD]) {
throw new Error(
'dotnet manifest tool not found at ' +
dotnetToolDir +
' for a published build. Build it first: pnpm run build:dotnet-tool',
)
}
const jarPath = path.join(
srcDir,
'maven-extension',
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/publish-npm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,12 @@ jobs:
fi
bash src/commands/manifest/scripts/maven-extension/build-jar.sh

# Same for the dotnet manifest tool: built here, bundled into
# dist/manifest-scripts, never committed. The runner image's preinstalled
# .NET SDK builds it (actions/setup-dotnet is not on the org allowlist).
- name: Build dotnet manifest tool
run: bash src/commands/manifest/scripts/dotnet-tool/build-tool.sh

# PACK ONCE, PUBLISH THOSE BYTES. Each variant is built, packed to a
# tarball, and smoke-tested from that tarball; the three tarballs are
# uploaded as an artifact and the publish job stages those exact files.
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added
- `socket manifest dotnet` [beta] generates Socket facts files for .NET solutions and projects from your own `dotnet restore`, covering PackageReference (central package management and lock files included) and packages.config projects.
- `socket manifest auto`, `socket manifest setup` and `--auto-manifest` now detect .NET solutions and projects, and skip them with a warning where no .NET SDK is installed.

### Changed
- Modules outside the scan root can no longer be excluded with `--exclude-paths`; `**/x` used to match a `../x` module by accident. Single-root runs whose Maven or Gradle options relocate the build root (`-f`, `-p`) now match patterns relative to the current directory, as documented.

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"build:dist": "pnpm build:dist:src && pnpm build:dist:types",
"build:dist:src": "run-p -c clean:dist clean:external && dotenvx -q run -f .env.local -- rollup -c .config/rollup.dist.config.mjs",
"build:dist:types": "pnpm clean:dist:types && tsgo --project tsconfig.dts.json",
"build:dotnet-tool": "bash src/commands/manifest/scripts/dotnet-tool/build-tool.sh",
"build:maven-extension": "bash src/commands/manifest/scripts/maven-extension/build-jar.sh",
"build:sea": "node src/sea/build-sea.mts",
"build:sea:internal:bootstrap": "rollup -c .config/rollup.sea.config.mjs",
Expand Down
1 change: 1 addition & 0 deletions src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ describe('socket fix --dynamic-sbom-inference', () => {
}
const uploadManifestFiles = vi.fn()
const generated = {
artifactsFlags: ['--maven-use-only-socket-facts'],
paths: [FACTS],
sidecarFile: '/tmp/socket-fix-facts/sidecar.json',
remove: vi.fn(),
Expand Down
4 changes: 2 additions & 2 deletions src/commands/fix/coana-fix.mts
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ async function coanaFixWithFacts(
if (!silence) {
spinner?.stop()
logger.info(
'Generating Socket facts for Gradle, sbt and Maven builds ...',
'Generating Socket facts for .NET, Gradle, sbt and Maven builds ...',
)
}
factsSlot.generated = await generateSocketFactsForFix({
Expand All @@ -412,7 +412,7 @@ async function coanaFixWithFacts(
// Discovery only needs which artifacts the facts files resolve. Applying
// fixes reads each project's tree; a Coana predating facts trees can only
// tell from the sidecar's classpaths which projects resolve an artifact.
const discoveryFlags = factsSlot ? ['--maven-use-only-socket-facts'] : []
const discoveryFlags = factsSlot?.generated?.artifactsFlags ?? []
const factsFlags = [
...discoveryFlags,
...(sidecarFile ? ['--compute-artifacts-sidecar', sidecarFile] : []),
Expand Down
8 changes: 7 additions & 1 deletion src/commands/fix/generated-socket-facts.mts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { copyFile, mkdir, rm, writeFile } from 'node:fs/promises'
import path from 'node:path'

import { useOnlySocketFactsArtifactsFlags } from '../manifest/scripts/sidecar.mts'
import { runDynamicSbomInference } from '../scan/run-dynamic-sbom-inference.mts'

export type GeneratedSocketFacts = {
// Coana's flags selecting what the facts files resolve.
artifactsFlags: string[]
paths: string[]
// The facts files' per-project classpaths, outside the repository.
// The facts files' per-project classpaths, written outside the repository.
sidecarFile: string | undefined
remove: () => Promise<void>
restore: () => Promise<void>
Expand Down Expand Up @@ -52,6 +55,7 @@ export async function generateSocketFactsForFix({
)
await restoreAll(originals)
}
let artifactsFlags: string[]
let paths: string[]
let sidecarFile: string | undefined
try {
Expand All @@ -62,6 +66,7 @@ export async function generateSocketFactsForFix({
sidecar: true,
withFiles: false,
})
artifactsFlags = useOnlySocketFactsArtifactsFlags(resolvedPathsSidecar)
paths = factsPaths.map(p => path.resolve(cwd, p))
if (resolvedPathsSidecar) {
sidecarFile = path.join(tmpDir, 'sidecar.json')
Expand All @@ -78,6 +83,7 @@ export async function generateSocketFactsForFix({
...(await backUp(paths, path.join(tmpDir, 'generated'))),
]
return {
artifactsFlags,
paths,
sidecarFile,
remove: () => reset(paths),
Expand Down
10 changes: 7 additions & 3 deletions src/commands/fix/generated-socket-facts.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -65,17 +65,21 @@ describe('generateSocketFactsForFix', () => {
it('writes the sidecar outside the repository', async () => {
mockRunDynamicSbomInference.mockImplementation(async () => {
await fs.writeFile(fresh, 'generated')
return { factsPaths: [fresh], resolvedPathsSidecar: { [fresh]: {} } }
return {
factsPaths: [fresh],
resolvedPathsSidecar: { [fresh]: { components: [], projects: [] } },
}
})

const { sidecarFile } = await generate()
const { artifactsFlags, sidecarFile } = await generate()

expect(mockRunDynamicSbomInference).toHaveBeenCalledWith(
expect.objectContaining({ sidecar: true }),
)
expect(artifactsFlags).toEqual(['--maven-use-only-socket-facts'])
expect(path.dirname(sidecarFile!)).toBe(tmpDir)
expect(JSON.parse(await fs.readFile(sidecarFile!, 'utf8'))).toEqual({
[fresh]: {},
[fresh]: { components: [], projects: [] },
})
})

Expand Down
214 changes: 214 additions & 0 deletions src/commands/manifest/cmd-manifest-dotnet.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import path from 'node:path'

import { debugFn } from '@socketsecurity/registry/lib/debug'
import { safeStatsSync } from '@socketsecurity/registry/lib/fs'
import { logger } from '@socketsecurity/registry/lib/logger'

import { convertDotnetToFacts } from './convert-dotnet-to-facts.mts'
import { resolveEcosystemConfig } from './generate-recursive-manifests.mts'
import { excludePathsFlag } from './manifest-flags.mts'
import { parseBuildToolOpts } from './parse-build-tool-opts.mts'
import constants, { SOCKET_JSON } from '../../constants.mts'
import { commonFlags } from '../../flags.mts'
import { checkCommandInput } from '../../utils/check-input.mts'
import { cmdFlagValueToArray } from '../../utils/cmd.mts'
import { getOutputKind } from '../../utils/get-output-kind.mts'
import { meowOrExit } from '../../utils/meow-with-subcommands.mts'
import { getFlagListOutput } from '../../utils/output-formatting.mts'
import { readOrDefaultSocketJson } from '../../utils/socket-json.mts'
import { camelToKebab } from '../../utils/strings.mts'
import { assertValidExcludePaths } from '../scan/exclude-paths.mts'

import type {
CliCommandConfig,
CliCommandContext,
} from '../../utils/meow-with-subcommands.mts'

const config: CliCommandConfig = {
commandName: 'dotnet',
description:
'[beta] Generate Socket facts files from .NET solutions and projects',
hidden: false,
flags: {
...commonFlags,
bin: {
type: 'string',
description:
'Location of the dotnet binary to use, default: dotnet on PATH',
},
dotnetOpts: {
type: 'string',
description:
'Additional options to pass on to `dotnet restore`, e.g. `-p:Configuration=Release --configfile nuget.config`. MSBuild properties among them also apply to project evaluation',
},
includeConfigs: {
type: 'string',
description:
'Comma-separated glob patterns matched against target frameworks (case-sensitive; `*`, `?`, and `[...]` wildcards). Only target frameworks matching at least one pattern are read, e.g. `net8.0`. A runtime-specific target (`net8.0/win-x64`) also matches its framework. Default: every target framework',
},
excludeConfigs: {
type: 'string',
description:
'Comma-separated glob patterns; target frameworks matching any pattern are skipped (applied after --include-configs)',
},
...excludePathsFlag,
ignoreUnresolved: {
type: 'boolean',
description:
'Warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts file)',
},
verbose: {
type: 'boolean',
description: 'Print debug messages',
},
},
help: (command, config) => `
Usage
$ ${command} [options] [CWD=.|FILE]

Options
${getFlagListOutput(config.flags)}

Emits a \`<file>.socket.facts.json\` per solution or project describing its
resolved NuGet dependency graph. Given a directory, it builds every
solution (.sln, .slnx) and project (.csproj, .fsproj, .vbproj) at its top
level, solutions first, skipping projects a solution already covers; given
a file, just that one (a .slnf solution filter too).

Dependencies come from your own \`dotnet restore\` (same SDK, global.json,
nuget.config and credential providers), so a build that restores for you
restores here. PackageReference projects (central package management and
lock files included) and packages.config projects are supported. An
unresolved dependency is a fatal error unless --ignore-unresolved.

Support is beta. Please report issues or give us feedback on what's missing.

Examples

$ ${command} .
$ ${command} ./App.sln
$ ${command} --dotnet-opts="-p:Configuration=Release" .
`,
}

export const cmdManifestDotnet = {
description: config.description,
hidden: config.hidden,
run,
}

async function run(
argv: string[] | readonly string[],
importMeta: ImportMeta,
{ parentName }: CliCommandContext,
): Promise<void> {
const cli = meowOrExit({
argv,
config,
importMeta,
parentName,
})

const { json = false, markdown = false } = cli.flags

const dryRun = !!cli.flags['dryRun']

const outputKind = getOutputKind(json, markdown)

const wasValidInput = checkCommandInput(outputKind, {
nook: true,
test: cli.input.length <= 1,
message: 'Can only accept one DIR or FILE (make sure to escape spaces!)',
fail: 'received ' + cli.input.length,
})
if (!wasValidInput) {
return
}

const target = path.resolve(process.cwd(), cli.input[0] ?? '.')
const isFile = !!safeStatsSync(target)?.isFile()
const cwd = isFile ? path.dirname(target) : target
const entryFile = isFile ? path.basename(target) : undefined

const sockJson = readOrDefaultSocketJson(cwd)
const defaults = sockJson.defaults?.manifest?.dotnet

debugFn('inspect', `override: ${SOCKET_JSON} dotnet`, defaults)

const dotnetConfig = resolveEcosystemConfig('dotnet', cwd, sockJson)
const given = cli.flags as {
bin?: string | undefined
dotnetOpts?: string | undefined
excludeConfigs?: string | undefined
ignoreUnresolved?: boolean | undefined
includeConfigs?: string | undefined
verbose?: boolean | undefined
}
// An empty --bin or --dotnet-opts leaves the default in place.
const flags = {
...given,
bin: given.bin || undefined,
dotnetOpts: given.dotnetOpts || undefined,
}
for (const key of [
'bin',
'dotnetOpts',
'excludeConfigs',
'ignoreUnresolved',
'includeConfigs',
'verbose',
] as const) {
const configured = defaults?.[key]
if (
flags[key] === undefined &&
configured !== undefined &&
configured !== null &&
configured !== ''
) {
logger.info(
`Using default --${camelToKebab(key)} from ${SOCKET_JSON}:`,
configured,
)
}
}
dotnetConfig.bin = flags.bin ?? dotnetConfig.bin
if (flags.dotnetOpts) {
dotnetConfig.buildOpts = parseBuildToolOpts(flags.dotnetOpts)
}
dotnetConfig.excludeConfigs =
flags.excludeConfigs ?? dotnetConfig.excludeConfigs
dotnetConfig.ignoreUnresolved =
flags.ignoreUnresolved ?? dotnetConfig.ignoreUnresolved
dotnetConfig.includeConfigs =
flags.includeConfigs ?? dotnetConfig.includeConfigs
dotnetConfig.verbose = flags.verbose ?? dotnetConfig.verbose
const { bin, verbose } = dotnetConfig

if (verbose) {
logger.group('- ', parentName, config.commandName, ':')
logger.group('- flags:', cli.flags)
logger.groupEnd()
logger.log('- input:', cli.input)
logger.groupEnd()
logger.group()
logger.info('- cwd:', cwd)
logger.info('- entry:', entryFile ?? '(every solution and project)')
logger.info('- dotnet bin:', bin)
logger.groupEnd()
}

if (dryRun) {
logger.log(constants.DRY_RUN_BAILING_NOW)
return
}

const excludePaths = cmdFlagValueToArray(cli.flags['excludePaths'])
assertValidExcludePaths(excludePaths)

await convertDotnetToFacts({
config: dotnetConfig,
cwd,
entryFile,
excludePaths,
})
}
2 changes: 2 additions & 0 deletions src/commands/manifest/cmd-manifest.mts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { cmdManifestBazel } from './bazel/cmd-manifest-bazel.mts'
import { cmdManifestAuto } from './cmd-manifest-auto.mts'
import { cmdManifestCdxgen } from './cmd-manifest-cdxgen.mts'
import { cmdManifestConda } from './cmd-manifest-conda.mts'
import { cmdManifestDotnet } from './cmd-manifest-dotnet.mts'
import { cmdManifestDynamicSbomInference } from './cmd-manifest-dynamic-sbom-inference.mts'
import { cmdManifestGradle } from './cmd-manifest-gradle.mts'
import { cmdManifestKotlin } from './cmd-manifest-kotlin.mts'
Expand Down Expand Up @@ -74,6 +75,7 @@ async function run(
bazel: cmdManifestBazel,
cdxgen: cmdManifestCdxgen,
conda: cmdManifestConda,
dotnet: cmdManifestDotnet,
'dynamic-sbom-inference': cmdManifestDynamicSbomInference,
gradle: cmdManifestGradle,
kotlin: cmdManifestKotlin,
Expand Down
1 change: 1 addition & 0 deletions src/commands/manifest/cmd-manifest.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ describe('socket manifest', async () => {
bazel [beta] Bazel SBOM support \\u2014 generate manifest files for a Bazel project (Maven, PyPI)
cdxgen Run cdxgen for SBOM generation
conda [beta] Convert a Conda environment.yml file to a python requirements.txt
dotnet [beta] Generate Socket facts files from .NET solutions and projects
dynamic-sbom-inference Recursively discover gradle/sbt/maven build roots and generate a Socket facts SBOM for each
gradle [beta] Generate a Socket facts file (or \`pom.xml\` with --pom) for a Gradle/Java/Kotlin/etc project
kotlin [beta] Generate a Socket facts file (or \`pom.xml\` with --pom) for a Kotlin project
Expand Down
Loading
Loading