Skip to content

Commit de78286

Browse files
committed
feat(manifest): generate Socket facts for .NET solutions and projects
`socket manifest dotnet` writes `<entry>.socket.facts.json` per solution, plus one per project no solution builds, from the user's own `dotnet restore`: a bundled C# tool evaluates projects in-process on the selected SDK (MSBuildLocator) and reads project.assets.json with NuGet's own API. PackageReference (central package management, lock files, multi-targeting, runtime targets) and packages.config are supported, for C#, F# and VB. --auto-manifest, `socket manifest auto`, dynamic SBOM inference and the setup wizards detect .NET builds and skip them with a warning where no .NET SDK runs. --reach sidecars carry each project's compiler inputs per target framework from a design-time build, and scan and fix pass Coana --use-only-socket-facts-artifacts when NuGet facts are present. Resolution, restore and design-time build failures fail closed.
1 parent 04d20d1 commit de78286

41 files changed

Lines changed: 3470 additions & 127 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.config/rollup.dist.config.mjs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,20 @@ async function copyManifestScripts() {
131131
path.join(destDir, 'socket-workspaces.plugin.scala'),
132132
),
133133
])
134+
// The dotnet tool's publish output (scripts/dotnet-tool/build-tool.sh),
135+
// like the Maven jar absent from a fresh checkout.
136+
const dotnetToolDir = path.join(srcDir, 'dotnet-tool', 'publish')
137+
if (existsSync(path.join(dotnetToolDir, 'socket-facts-dotnet.dll'))) {
138+
await fs.cp(dotnetToolDir, path.join(destDir, 'dotnet-tool'), {
139+
recursive: true,
140+
})
141+
} else if (constants.ENV[INLINED_SOCKET_CLI_PUBLISHED_BUILD]) {
142+
throw new Error(
143+
'dotnet manifest tool not found at ' +
144+
dotnetToolDir +
145+
' for a published build. Build it first: pnpm run build:dotnet-tool',
146+
)
147+
}
134148
const jarPath = path.join(
135149
srcDir,
136150
'maven-extension',

‎.github/workflows/publish-npm.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,12 @@ jobs:
386386
fi
387387
bash src/commands/manifest/scripts/maven-extension/build-jar.sh
388388
389+
# Same for the dotnet manifest tool: built here, bundled into
390+
# dist/manifest-scripts, never committed. The runner image's preinstalled
391+
# .NET SDK builds it (actions/setup-dotnet is not on the org allowlist).
392+
- name: Build dotnet manifest tool
393+
run: bash src/commands/manifest/scripts/dotnet-tool/build-tool.sh
394+
389395
# PACK ONCE, PUBLISH THOSE BYTES. Each variant is built, packed to a
390396
# tarball, and smoke-tested from that tarball; the three tarballs are
391397
# uploaded as an artifact and the publish job stages those exact files.

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

77
## [Unreleased]
88

9+
### Added
10+
- `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.
11+
- `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.
12+
913
### Fixed
1014
- `--exclude-paths` now also excludes modules a build reaches outside its own directory, such as Maven `../` modules and relocated Gradle projects, when Socket facts are generated for each build root.
1115

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"build:dist": "pnpm build:dist:src && pnpm build:dist:types",
4040
"build:dist:src": "run-p -c clean:dist clean:external && dotenvx -q run -f .env.local -- rollup -c .config/rollup.dist.config.mjs",
4141
"build:dist:types": "pnpm clean:dist:types && tsgo --project tsconfig.dts.json",
42+
"build:dotnet-tool": "bash src/commands/manifest/scripts/dotnet-tool/build-tool.sh",
4243
"build:maven-extension": "bash src/commands/manifest/scripts/maven-extension/build-jar.sh",
4344
"build:sea": "node src/sea/build-sea.mts",
4445
"build:sea:internal:bootstrap": "rollup -c .config/rollup.sea.config.mjs",

‎src/commands/fix/coana-fix.mts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
} from '../../utils/github.mts'
5555
import { getPackageFilesForScan } from '../../utils/path-resolve.mts'
5656
import { setupSdk } from '../../utils/sdk.mts'
57+
import { useOnlySocketFactsArtifactsFlags } from '../manifest/scripts/sidecar.mts'
5758
import { excludePathToScanIgnores } from '../scan/exclude-paths.mts'
5859
import { fetchSupportedScanFileNames } from '../scan/fetch-supported-scan-file-names.mts'
5960

@@ -392,7 +393,7 @@ async function coanaFixWithFacts(
392393
if (!silence) {
393394
spinner?.stop()
394395
logger.info(
395-
'Generating Socket facts for Gradle, sbt and Maven builds ...',
396+
'Generating Socket facts for .NET, Gradle, sbt and Maven builds ...',
396397
)
397398
}
398399
factsSlot.generated = await generateSocketFactsForFix({
@@ -412,7 +413,9 @@ async function coanaFixWithFacts(
412413
// Discovery only needs which artifacts the facts files resolve. Applying
413414
// fixes reads each project's tree; a Coana predating facts trees can only
414415
// tell from the sidecar's classpaths which projects resolve an artifact.
415-
const discoveryFlags = factsSlot ? ['--maven-use-only-socket-facts'] : []
416+
const discoveryFlags = factsSlot
417+
? useOnlySocketFactsArtifactsFlags(factsSlot.generated?.sidecar)
418+
: []
416419
const factsFlags = [
417420
...discoveryFlags,
418421
...(sidecarFile ? ['--compute-artifacts-sidecar', sidecarFile] : []),

‎src/commands/fix/generated-socket-facts.mts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ import path from 'node:path'
33

44
import { runDynamicSbomInference } from '../scan/run-dynamic-sbom-inference.mts'
55

6+
import type { ResolvedPathsSidecar } from '../manifest/scripts/sidecar.mts'
7+
68
export type GeneratedSocketFacts = {
79
paths: string[]
810
// The facts files' per-project classpaths, outside the repository.
911
sidecarFile: string | undefined
12+
sidecar: ResolvedPathsSidecar | undefined
1013
remove: () => Promise<void>
1114
restore: () => Promise<void>
1215
}
@@ -54,6 +57,7 @@ export async function generateSocketFactsForFix({
5457
}
5558
let paths: string[]
5659
let sidecarFile: string | undefined
60+
let sidecar: ResolvedPathsSidecar | undefined
5761
try {
5862
const { factsPaths, resolvedPathsSidecar } = await runDynamicSbomInference({
5963
cwd,
@@ -63,6 +67,7 @@ export async function generateSocketFactsForFix({
6367
withFiles: false,
6468
})
6569
paths = factsPaths.map(p => path.resolve(cwd, p))
70+
sidecar = resolvedPathsSidecar
6671
if (resolvedPathsSidecar) {
6772
sidecarFile = path.join(tmpDir, 'sidecar.json')
6873
await writeFile(sidecarFile, JSON.stringify(resolvedPathsSidecar))
@@ -79,6 +84,7 @@ export async function generateSocketFactsForFix({
7984
]
8085
return {
8186
paths,
87+
sidecar,
8288
sidecarFile,
8389
remove: () => reset(paths),
8490
restore: () => restoreAll(backups),
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { statSync } from 'node:fs'
2+
import path from 'node:path'
3+
4+
import { debugFn } from '@socketsecurity/registry/lib/debug'
5+
import { logger } from '@socketsecurity/registry/lib/logger'
6+
7+
import { convertDotnetToFacts } from './convert-dotnet-to-facts.mts'
8+
import { excludePathsFlag } from './manifest-flags.mts'
9+
import { parseBuildToolOpts } from './parse-build-tool-opts.mts'
10+
import { resolveBuildToolBin } from './scripts/build-tool.mts'
11+
import constants, { SOCKET_JSON } from '../../constants.mts'
12+
import { commonFlags } from '../../flags.mts'
13+
import { checkCommandInput } from '../../utils/check-input.mts'
14+
import { cmdFlagValueToArray } from '../../utils/cmd.mts'
15+
import { getOutputKind } from '../../utils/get-output-kind.mts'
16+
import { meowOrExit } from '../../utils/meow-with-subcommands.mts'
17+
import { getFlagListOutput } from '../../utils/output-formatting.mts'
18+
import { readOrDefaultSocketJson } from '../../utils/socket-json.mts'
19+
import { assertValidExcludePaths } from '../scan/exclude-paths.mts'
20+
21+
import type {
22+
CliCommandConfig,
23+
CliCommandContext,
24+
} from '../../utils/meow-with-subcommands.mts'
25+
26+
const config: CliCommandConfig = {
27+
commandName: 'dotnet',
28+
description:
29+
'[beta] Generate Socket facts files from .NET solutions and projects',
30+
hidden: false,
31+
flags: {
32+
...commonFlags,
33+
bin: {
34+
type: 'string',
35+
description:
36+
'Location of the dotnet binary to use, default: dotnet on PATH',
37+
},
38+
dotnetOpts: {
39+
type: 'string',
40+
description:
41+
'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',
42+
},
43+
includeConfigs: {
44+
type: 'string',
45+
description:
46+
'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',
47+
},
48+
excludeConfigs: {
49+
type: 'string',
50+
description:
51+
'Comma-separated glob patterns; target frameworks matching any pattern are skipped (applied after --include-configs)',
52+
},
53+
...excludePathsFlag,
54+
ignoreUnresolved: {
55+
type: 'boolean',
56+
description:
57+
'Warn on unresolved dependencies instead of failing the run (unresolved deps are not emitted to the facts file)',
58+
},
59+
verbose: {
60+
type: 'boolean',
61+
description: 'Print debug messages',
62+
},
63+
},
64+
help: (command, config) => `
65+
Usage
66+
$ ${command} [options] [CWD=.|FILE]
67+
68+
Options
69+
${getFlagListOutput(config.flags)}
70+
71+
Emits a \`<file>.socket.facts.json\` per solution or project describing its
72+
resolved NuGet dependency graph. Given a directory, it builds every
73+
solution (.sln, .slnx) and project (.csproj, .fsproj, .vbproj) at its top
74+
level, solutions first, skipping projects a solution already covers; given
75+
a file, just that one (a .slnf solution filter too).
76+
77+
Dependencies come from your own \`dotnet restore\` (same SDK, global.json,
78+
nuget.config and credential providers), so a build that restores for you
79+
restores here. PackageReference projects (central package management and
80+
lock files included) and packages.config projects are supported. An
81+
unresolved dependency is a fatal error unless --ignore-unresolved.
82+
83+
Support is beta. Please report issues or give us feedback on what's missing.
84+
85+
Examples
86+
87+
$ ${command} .
88+
$ ${command} ./App.sln
89+
$ ${command} --dotnet-opts="-p:Configuration=Release" .
90+
`,
91+
}
92+
93+
export const cmdManifestDotnet = {
94+
description: config.description,
95+
hidden: config.hidden,
96+
run,
97+
}
98+
99+
async function run(
100+
argv: string[] | readonly string[],
101+
importMeta: ImportMeta,
102+
{ parentName }: CliCommandContext,
103+
): Promise<void> {
104+
const cli = meowOrExit({
105+
argv,
106+
config,
107+
importMeta,
108+
parentName,
109+
})
110+
111+
const { json = false, markdown = false } = cli.flags
112+
113+
const dryRun = !!cli.flags['dryRun']
114+
115+
const outputKind = getOutputKind(json, markdown)
116+
117+
const wasValidInput = checkCommandInput(outputKind, {
118+
nook: true,
119+
test: cli.input.length <= 1,
120+
message: 'Can only accept one DIR or FILE (make sure to escape spaces!)',
121+
fail: 'received ' + cli.input.length,
122+
})
123+
if (!wasValidInput) {
124+
return
125+
}
126+
127+
const target = path.resolve(process.cwd(), cli.input[0] ?? '.')
128+
let isFile = false
129+
try {
130+
isFile = statSync(target).isFile()
131+
} catch {}
132+
const cwd = isFile ? path.dirname(target) : target
133+
const entryFile = isFile ? path.basename(target) : undefined
134+
135+
const sockJson = readOrDefaultSocketJson(cwd)
136+
const defaults = sockJson.defaults?.manifest?.dotnet
137+
138+
debugFn('inspect', `override: ${SOCKET_JSON} dotnet`, defaults)
139+
140+
let {
141+
bin,
142+
dotnetOpts,
143+
excludeConfigs,
144+
ignoreUnresolved,
145+
includeConfigs,
146+
verbose,
147+
} = cli.flags
148+
149+
function useDefault<T>(flag: string, value: T): T {
150+
logger.info(`Using default --${flag} from ${SOCKET_JSON}:`, value)
151+
return value
152+
}
153+
if (!bin) {
154+
bin = defaults?.bin
155+
? useDefault('bin', defaults.bin)
156+
: resolveBuildToolBin('dotnet', cwd)
157+
}
158+
if (!dotnetOpts) {
159+
dotnetOpts = defaults?.dotnetOpts
160+
? useDefault('dotnet-opts', defaults.dotnetOpts)
161+
: ''
162+
}
163+
if (includeConfigs === undefined) {
164+
includeConfigs = defaults?.includeConfigs
165+
? useDefault('include-configs', defaults.includeConfigs)
166+
: ''
167+
}
168+
if (excludeConfigs === undefined) {
169+
excludeConfigs = defaults?.excludeConfigs
170+
? useDefault('exclude-configs', defaults.excludeConfigs)
171+
: ''
172+
}
173+
if (ignoreUnresolved === undefined) {
174+
ignoreUnresolved =
175+
defaults?.ignoreUnresolved !== undefined
176+
? useDefault('ignore-unresolved', defaults.ignoreUnresolved)
177+
: false
178+
}
179+
if (verbose === undefined) {
180+
verbose =
181+
defaults?.verbose !== undefined
182+
? useDefault('verbose', defaults.verbose)
183+
: false
184+
}
185+
186+
if (verbose) {
187+
logger.group('- ', parentName, config.commandName, ':')
188+
logger.group('- flags:', cli.flags)
189+
logger.groupEnd()
190+
logger.log('- input:', cli.input)
191+
logger.groupEnd()
192+
logger.group()
193+
logger.info('- cwd:', cwd)
194+
logger.info('- entry:', entryFile ?? '(every solution and project)')
195+
logger.info('- dotnet bin:', bin)
196+
logger.groupEnd()
197+
}
198+
199+
if (dryRun) {
200+
logger.log(constants.DRY_RUN_BAILING_NOW)
201+
return
202+
}
203+
204+
const excludePaths = cmdFlagValueToArray(cli.flags['excludePaths'])
205+
assertValidExcludePaths(excludePaths)
206+
207+
await convertDotnetToFacts({
208+
bin: String(bin),
209+
cwd,
210+
dotnetOpts: parseBuildToolOpts(String(dotnetOpts || '')),
211+
entryFile,
212+
excludeConfigs: String(excludeConfigs || ''),
213+
excludePaths,
214+
ignoreUnresolved: Boolean(ignoreUnresolved),
215+
includeConfigs: String(includeConfigs || ''),
216+
verbose: Boolean(verbose),
217+
})
218+
}

‎src/commands/manifest/cmd-manifest.mts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { cmdManifestBazel } from './bazel/cmd-manifest-bazel.mts'
22
import { cmdManifestAuto } from './cmd-manifest-auto.mts'
33
import { cmdManifestCdxgen } from './cmd-manifest-cdxgen.mts'
44
import { cmdManifestConda } from './cmd-manifest-conda.mts'
5+
import { cmdManifestDotnet } from './cmd-manifest-dotnet.mts'
56
import { cmdManifestDynamicSbomInference } from './cmd-manifest-dynamic-sbom-inference.mts'
67
import { cmdManifestGradle } from './cmd-manifest-gradle.mts'
78
import { cmdManifestKotlin } from './cmd-manifest-kotlin.mts'
@@ -74,6 +75,7 @@ async function run(
7475
bazel: cmdManifestBazel,
7576
cdxgen: cmdManifestCdxgen,
7677
conda: cmdManifestConda,
78+
dotnet: cmdManifestDotnet,
7779
'dynamic-sbom-inference': cmdManifestDynamicSbomInference,
7880
gradle: cmdManifestGradle,
7981
kotlin: cmdManifestKotlin,

‎src/commands/manifest/cmd-manifest.test.mts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ describe('socket manifest', async () => {
2727
bazel [beta] Bazel SBOM support \\u2014 generate manifest files for a Bazel project (Maven, PyPI)
2828
cdxgen Run cdxgen for SBOM generation
2929
conda [beta] Convert a Conda environment.yml file to a python requirements.txt
30+
dotnet [beta] Generate Socket facts files from .NET solutions and projects
3031
dynamic-sbom-inference Recursively discover gradle/sbt/maven build roots and generate a Socket facts SBOM for each
3132
gradle [beta] Generate a Socket facts file (or \`pom.xml\` with --pom) for a Gradle/Java/Kotlin/etc project
3233
kotlin [beta] Generate a Socket facts file (or \`pom.xml\` with --pom) for a Kotlin project

0 commit comments

Comments
 (0)