diff --git a/README.md b/README.md index 7e8986c2..c15994ef 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,7 @@ See [SCAN_COVERAGE.md](SCAN_COVERAGE.md) for the full catalog of supported detec | Go Modules | `go.mod`/`go.work` declarations, vendored modules, module cache, `GOBIN` tools, recorded checksums (enterprise) | | Rust Packages | `Cargo.toml` declarations, `Cargo.lock`, registry cache, Git checkouts, vendored sources, `cargo install` receipts (enterprise) | | PHP Packages | `composer.json` requirements, `composer.lock` snapshots and Composer 1/2 installed receipts, with separate directory presence (enterprise) | +| .NET Packages | MSBuild project and `packages.config` references, `packages.lock.json`, `project.assets.json` restores, global package cache, .NET tools, templates and `.deps.json` (enterprise) | | Package Configs | npm (`.npmrc`), pnpm, bun (`bunfig.toml`), yarn classic & berry (`.yarnrc`/`.yarnrc.yml`), pip (`pip.conf`) — effective registry, cooldown policy, and auth surface across every scope | | Suspicious Files | Malicious-file IOCs from StepSecurity-maintained rules — e.g. `binding.gyp` that runs during `npm install`, and editor/AI-tool config files that auto-execute on project open | @@ -327,6 +328,8 @@ Configuration is read from `.npmrc` (npm), pnpm config, `bunfig.toml` (bun), `.y Composer auditing reads selected global `config.json`, root manifests and verified process overrides. It reports observed repository, proxy, TLS and plugin settings with credentials redacted. It does not infer the repository that supplied a package or execute Composer to reconstruct runtime configuration. See [PHP Packages](SCAN_COVERAGE.md#php-packages) for scope and limits. +NuGet auditing reads project, ancestor, user and machine `NuGet.Config` files plus verified process overrides. It reports package sources, source mapping, fallback folders, proxy, TLS and audit settings with credentials redacted, and records which configuration applied to each project and restore. It never executes `dotnet` or `nuget` or reads credential-provider output. See [.NET Packages](SCAN_COVERAGE.md#net-packages) for scope and limits. + Enterprise Device Policy can also set StepSecurity Secure Registry as the sole user-level Python index for pip and uv. It manages only the resolved developer's user configuration and shared StepSecurity `.netrc` entry, keeps pip and uv results independent, and restores owned settings on an explicit policy clear. Project files, virtual environments, system configuration, environment variables, direct URLs, and Poetry are not modified. ### Suspicious file detection diff --git a/SCAN_COVERAGE.md b/SCAN_COVERAGE.md index 86b79ad4..9564aff9 100644 --- a/SCAN_COVERAGE.md +++ b/SCAN_COVERAGE.md @@ -323,6 +323,27 @@ Enterprise telemetry emits `composer_inventory` and `composer_config_audit` for **Limits and privacy.** Each JSON input is capped at 2 MiB; discovery at 100,000 entries per root and depth 128; inventory at 50,000 records; audit at 256 KiB; combined sections at 16 MiB. Failures and limits carry partial coverage. URL credentials, queries and fragments are removed before telemetry. Auth objects become presence flags; arbitrary config, headers, scripts and auth-file contents are not collected. Standalone caches, PHARs, copied PHP without supported receipts, historical shell overrides and `installed.php`-only metadata are outside v1 coverage. Native VM and live API acceptance is tracked separately from local parser tests. +## .NET Packages + +Enterprise telemetry emits `nuget_inventory` and `nuget_config_audit` for the resolved developer. Collection is static: `dotnet`, `nuget`, MSBuild, credential providers, scripts and discovered binaries are never executed, and no feed is contacted. + +| Evidence | Source and meaning | +|---|---| +| Declared requirements | `PackageReference`, `PackageDownload` and versioned `Sdk` items in `.csproj`/`.fsproj`/`.vbproj` files, with versions from the nearest `Directory.Packages.props` when central management applies; `packages.config` entries; `dotnet-tools.json` tools; `#:package`/`#:sdk` directives in file-based C# apps and `#r "nuget:"` in F# scripts. Conditional items are flagged. Build-time imports, property functions and `global.json` are not evaluated; a project that needs them is `partial`. | +| Locked packages | `packages.lock.json` formats 1–3, with the direct/transitive dependency type and recorded content hash. A lock does not prove a restore. | +| Restored packages | `project.assets.json` v3/v4 libraries and per-framework targets, including orphan assets whose project is unresolved. Each restored package is joined to the cache it was restored from for presence and origin; a `packages.config` package counts as restored only when its directory exists under the repository path. | +| Cached packages | `//` entries in the global packages folder, configured fallback and custom folders: `.nuspec` identity (loose or read from the `.nupkg` without unpacking), `.nupkg.metadata` content hash and source, `.nupkg.sha512` archive hash. Missing markers or a mismatched identity are reported, never guessed. | +| Installed tools | Global tool store receipts, `DotnetToolSettings.xml` commands and shim presence; local manifests upgraded through the tool resolver cache. | +| Installed templates | The template engine `packages.json` receipt. | +| Binary dependencies | `*.deps.json` package libraries with their recorded hash, attributed to the owning project when one is found. | +| Configuration | Project and ancestor `NuGet.Config` files inside the search roots, user and additional user configs, machine-wide configs and the verified process environment, with allowlisted package source, mapping, fallback folder, path, proxy, TLS, signer, audit and lock settings per ambient and restore context, and findings `nuget-001`…`nuget-004`. Credentials and API keys are recorded as configured only. | + +**Discovery.** Uses the selected search roots with protected-path and network-volume guards. VCS, `node_modules` and cache directories, conventional NuGet homes and directories holding `.nupkg` files are pruned from the project walk; `bin`/`obj` are walked so receipts are found by name. Directory symlinks are not followed. Paths named by configuration or restore metadata are resolved through the guard. + +**Meaning.** Declared, locked, restored and cached evidence stay independent when they disagree. Recorded SHA-512 values are reported as `recorded` and never verified. A feed origin comes only from a restore marker that names the source; everything else is `local` or `unknown`. Effective sources are not computed from `` order. + +**Limits and privacy.** Each metadata file is capped at 2 MiB and each `.nupkg` read at 32 MiB; discovery at 100,000 entries per root and depth 128; inventory at 50,000 records; audit at 256 KiB; combined sections at 16 MiB. Failures and limits carry partial coverage. URL credentials, queries and fragments are removed before telemetry; `ClearTextPassword`, `Password`, API keys and credential-provider arguments are never collected, and an environment-variable reference is not reported as a stored password. `project.json`, `.ridpackage.json`, `#i` script sources and `global.json` SDK resolution are outside v1 coverage. + ## System Package Scanning (Linux) System package scanning is **automatic on Linux** — no opt-in flag required. Multiple package managers can coexist. diff --git a/internal/detector/configaudit/nugetconfig.go b/internal/detector/configaudit/nugetconfig.go new file mode 100644 index 00000000..567f5e28 --- /dev/null +++ b/internal/detector/configaudit/nugetconfig.go @@ -0,0 +1,920 @@ +package configaudit + +import ( + "bytes" + "context" + "encoding/json" + "encoding/xml" + "errors" + "io" + "io/fs" + "maps" + "path/filepath" + "slices" + "strconv" + "strings" + "unicode/utf8" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +var maxNuGetAuditBytes = 256 << 10 + +const nugetConfigBytes int64 = 2 << 20 +const nugetSettingLimit = 256 + +// NuGetConfigScope comes from the resolved developer, never a login shell. +type NuGetConfigScope struct { + Username, Home string + Roots []string // approved scope for ancestor NuGet.Config probes + Protected func(string) string + Volume func(string) bool + ProcessVerified bool +} + +// NuGetPaths are the locations an ambient context selects for inventory. +type NuGetPaths struct { + Repository string // legacy repositoryPath, resolved + Partial bool +} + +// NuGetConfigSnapshot accumulates observed NuGet configuration as the +// inventory discovers projects and restore graphs. +type NuGetConfigSnapshot struct { + GlobalPackages string // effective expanded-package root for the user context + CLIHome string // profile root for tools and templates + PackageRoots []string // every configured fallback, custom and legacy root + exec executor.Executor + ctx context.Context + scope NuGetConfigScope + audit model.NuGetConfigAudit + files map[string]*model.NuGetConfigFile + paths map[string]map[string]string // source ID → resolved path settings + byPath map[string]string // path key → source ID + env map[string]string + process *model.NuGetConfigFile + userChain []*model.NuGetConfigFile // highest precedence first + dirs map[string][]*model.NuGetConfigFile + contexts map[string]bool + bytes int + limited bool + selectionReasons []string // why every selected chain is uncertain + cleartext map[string]bool // source IDs holding a literal ClearTextPassword +} + +type NuGetConfigDetector struct{ exec executor.Executor } + +func NewNuGetConfigDetector(exec executor.Executor) *NuGetConfigDetector { + return &NuGetConfigDetector{exec: exec} +} + +func emptyNuGetAudit() model.NuGetConfigAudit { + return model.NuGetConfigAudit{ + SchemaVersion: model.NuGetInventorySchemaVersion, Status: "complete", Reasons: []string{}, + Files: []model.NuGetConfigFile{}, Contexts: []model.NuGetConfigContext{}, Findings: []model.CargoConfigFinding{}, + ObservedRegistries: []string{}, ObservedProxies: []string{}, FindingCodeCounts: map[string]int{}, FindingCounts: map[string]int{}, + } +} + +func (d *NuGetConfigDetector) Detect(ctx context.Context, scope NuGetConfigScope) *NuGetConfigSnapshot { + a := &NuGetConfigSnapshot{exec: d.exec, ctx: ctx, scope: scope, audit: emptyNuGetAudit(), files: map[string]*model.NuGetConfigFile{}, paths: map[string]map[string]string{}, byPath: map[string]string{}, env: map[string]string{}, dirs: map[string][]*model.NuGetConfigFile{}, contexts: map[string]bool{}, cleartext: map[string]bool{}} + if scope.Username == "" || scope.Home == "" { + a.degrade("user_unresolved") + return a + } + if ctx.Err() != nil { + a.degrade("deadline_exceeded") + return a + } + a.CLIHome = scope.Home + a.process = a.record("process", "") + if a.process == nil { + a.selectionReason("output_size_limit") + return a + } + if scope.ProcessVerified { + a.processEnv() + } else { + a.fileReason(a.process, "unsupported", "process_context_unverified") + a.selectionReason("process_context_unverified") + } + a.userConfigs() + a.GlobalPackages = filepath.Join(a.CLIHome, ".nuget", "packages") + for _, f := range a.userChain { + if p := a.paths[f.SourceID]["globalPackagesFolder"]; p != "" { + a.GlobalPackages = p + break + } + } + if p := a.env["NUGET_PACKAGES"]; p != "" { + a.GlobalPackages = p + } + return a +} + +// processEnv observes the allowlisted variables of the verified developer process. +func (a *NuGetConfigSnapshot) processEnv() { + for _, key := range []string{"NUGET_PACKAGES", "NUGET_HTTP_CACHE_PATH", "NUGET_FALLBACK_PACKAGES", "DOTNET_CLI_HOME", "HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "NO_PROXY", "no_proxy"} { + value := a.exec.Getenv(key) + if value == "" { + continue + } + display, redacted := value, false + switch { + case strings.EqualFold(key, "NO_PROXY"): + if !composerNoProxy(value) { + display, redacted = goRedacted, true + } + case strings.HasSuffix(strings.ToLower(key), "proxy"): + display, redacted = sanitizeComposerProxy(value) + case key == "NUGET_FALLBACK_PACKAGES": + list := a.pathList(value, "", "") + if list == nil { + a.fileReason(a.process, a.process.Status, "path_unresolved") + continue + } + a.PackageRoots = append(a.PackageRoots, list...) + display = strings.Join(list, ";") + default: + if !composerSafeText(value, 4096) || !filepath.IsAbs(value) { + a.fileReason(a.process, a.process.Status, "path_unresolved") + a.selectionReason("path_unresolved") + continue + } + a.env[key] = filepath.Clean(value) + display = a.env[key] + } + a.setting(a.process, "environment."+key, display, redacted) + } + if a.env["DOTNET_CLI_HOME"] != "" { + a.CLIHome = a.env["DOTNET_CLI_HOME"] + } + if a.exec.HasEnvPrefix("NuGetPackageSourceCredentials_") || a.exec.Getenv("VSS_NUGET_ACCESSTOKEN") != "" || a.exec.Getenv("VSS_NUGET_EXTERNAL_FEED_ENDPOINTS") != "" { + a.setting(a.process, "auth_configured", "true", true) + } + if a.exec.Getenv("NUGET_PLUGIN_PATHS") != "" || a.exec.Getenv("NUGET_CREDENTIALPROVIDERS_PATH") != "" { + a.setting(a.process, "credential_provider_configured", "true", true) + } + if len(a.process.Settings) > 0 { + a.process.Status = "read" + } +} + +// userConfigs reads the user, additional and machine files in precedence order. +// Windows keeps the APPDATA profile; elsewhere the user directory follows the +// CLI home NuGet selected, as its tools and cache already do. +func (a *NuGetConfigSnapshot) userConfigs() { + home := a.CLIHome + var userDir, machineDir string + switch a.exec.GOOS() { + case model.PlatformWindows: + appdata := filepath.Join(a.scope.Home, "AppData", "Roaming") + if a.scope.ProcessVerified { + if v := a.exec.Getenv("APPDATA"); v != "" { + if filepath.IsAbs(v) && composerSafeText(v, 4096) { + appdata = v + } else { + a.selectionReason("path_unresolved") + } + } + } + userDir = filepath.Join(appdata, "NuGet") + programs := `C:\Program Files (x86)` + if a.scope.ProcessVerified { + if v := a.exec.Getenv("ProgramFiles(x86)"); v != "" && filepath.IsAbs(v) && composerSafeText(v, 4096) { + programs = v + } + } + machineDir = filepath.Join(programs, "NuGet", "Config") + case model.PlatformDarwin: + userDir = filepath.Join(home, ".nuget", "NuGet") + machineDir = "/Library/Application Support/NuGet/Config" + default: + userDir = filepath.Join(home, ".nuget", "NuGet") + machineDir = "/etc/opt/NuGet/Config" + } + if f := a.file("user", filepath.Join(userDir, "NuGet.Config")); f != nil { + a.userChain = append(a.userChain, f) + } + if a.exec.GOOS() != model.PlatformWindows { + // The Mono-era location is an independent candidate, not a merged home. + alt := filepath.Join(a.scope.Home, ".config", "NuGet", "NuGet.Config") + if info, err := a.guarded(alt, 0).Stat(alt); err == nil && info.Mode().IsRegular() { + if f := a.file("user", alt); f != nil && len(a.userChain) > 0 && a.userChain[0].Status == "not_found" { + a.userChain = append(a.userChain, f) + } + } else if err != nil && !errors.Is(err, fs.ErrNotExist) { + a.degrade(a.reason(err, alt)) + } + } + for _, dir := range []struct{ path, scope string }{{filepath.Join(userDir, "config"), "user"}, {machineDir, "machine"}} { + for _, p := range a.configFiles(dir.path) { + if f := a.file(dir.scope, p); f != nil { + a.userChain = append(a.userChain, f) + } + } + } +} + +// configFiles lists *.config files directly inside dir, in name order. +func (a *NuGetConfigSnapshot) configFiles(dir string) []string { + if a.ctx.Err() != nil { + a.selectionReason("deadline_exceeded") + return nil + } + entries, more, err := a.guarded(dir, 0).ReadDirLimit(dir, nugetSettingLimit) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + a.selectionReason(a.reason(err, dir)) + return nil + } + if more { + a.selectionReason("entry_limit") + } + var out []string + for _, e := range entries { + if e.Type().IsRegular() && strings.EqualFold(filepath.Ext(e.Name()), ".config") { + out = append(out, filepath.Join(dir, e.Name())) + } + } + slices.Sort(out) + return out +} + +func (a *NuGetConfigSnapshot) guarded(p string, limit int64) executor.Executor { + return a.exec.GuardedFiles([]string{p}, a.scope.Protected, limit) +} +func (a *NuGetConfigSnapshot) key(p string) string { return GoPathKey(a.exec.GOOS(), p) } +func (a *NuGetConfigSnapshot) degrade(reason string) { + a.audit.Status = "partial" + if !slices.Contains(a.audit.Reasons, reason) { + a.audit.Reasons = append(a.audit.Reasons, reason) + } +} + +// selectionReason marks every context partial: the selected chain itself is +// uncertain, not just one file in it. +func (a *NuGetConfigSnapshot) selectionReason(reason string) { + a.degrade(reason) + if !slices.Contains(a.selectionReasons, reason) { + a.selectionReasons = append(a.selectionReasons, reason) + } +} +func (a *NuGetConfigSnapshot) fileReason(f *model.NuGetConfigFile, status, reason string) { + if f != nil { + f.Status = status + if !slices.Contains(f.Reasons, reason) { + f.Reasons = append(f.Reasons, reason) + } + } + a.degrade(reason) +} +func (a *NuGetConfigSnapshot) reason(err error, p string) string { + return CargoReadReason(err, p, a.scope.Volume) +} + +func (a *NuGetConfigSnapshot) record(scope, path string) *model.NuGetConfigFile { + id := GoSourceID(a.scope.Username, "nuget_config_"+scope, path) + if f := a.files[id]; f != nil { + return f + } + if a.limited || a.bytes+len(path)+256 > maxNuGetAuditBytes { + a.limited = true + a.degrade("output_size_limit") + return nil + } + f := &model.NuGetConfigFile{SourceID: id, Scope: scope, Path: path, Status: "not_found", Reasons: []string{}, Settings: []model.CargoConfigSetting{}} + a.files[id] = f + if path != "" { + a.byPath[a.key(path)] = id + } + a.bytes += len(path) + 256 + return f +} +func (a *NuGetConfigSnapshot) setting(f *model.NuGetConfigFile, key, value string, redacted bool) { + if f == nil { + return + } + if !composerSafeText(value, 4096) { + a.fileReason(f, "unreadable", "parse_error") + return + } + if len(f.Settings) >= nugetSettingLimit { + a.fileReason(f, "unsupported", "record_limit") + return + } + if a.bytes+len(key)+len(value)+128 > maxNuGetAuditBytes { + a.limited = true + a.fileReason(f, "unsupported", "output_size_limit") + return + } + a.bytes += len(key) + len(value) + 128 + f.Settings = append(f.Settings, model.CargoConfigSetting{Key: key, Display: value, SourceID: f.SourceID, Redacted: redacted}) +} + +// file records and reads one NuGet.Config once; later callers get the record. +func (a *NuGetConfigSnapshot) file(scope, p string) *model.NuGetConfigFile { + if id := a.byPath[a.key(p)]; id != "" { + return a.files[id] + } + f := a.record(scope, p) + if f == nil { + return nil + } + if a.ctx.Err() != nil { + a.fileReason(f, "unreadable", "deadline_exceeded") + return f + } + files := a.guarded(p, nugetConfigBytes) + info, err := files.Stat(p) + if errors.Is(err, fs.ErrNotExist) { + return f + } + if err != nil { + a.readFailure(f, err, p) + return f + } + if !info.Mode().IsRegular() { + a.fileReason(f, "unsupported", "unsupported_entry") + return f + } + if info.Size() > nugetConfigBytes { + a.fileReason(f, "unreadable", "size_limit") + return f + } + data, err := files.ReadFile(p) + if errors.Is(err, fs.ErrNotExist) { + a.fileReason(f, "unreadable", "changed_during_scan") + return f + } + if err != nil { + a.readFailure(f, err, p) + return f + } + a.parse(f, data) + return f +} +func (a *NuGetConfigSnapshot) readFailure(f *model.NuGetConfigFile, err error, p string) { + reason := a.reason(err, p) + status := "unreadable" + if reason == "skipped_protected" || reason == "refused_network_volume" { + status = "unsupported" + } + a.fileReason(f, status, reason) +} + +// dir returns the NuGet.Config files directly in dir, probing every spelling +// the released reader accepts on a case-sensitive filesystem. +func (a *NuGetConfigSnapshot) dir(dir string) []*model.NuGetConfigFile { + key := a.key(dir) + if files, ok := a.dirs[key]; ok { + return files + } + names := []string{"nuget.config", "NuGet.config", "NuGet.Config"} + if a.exec.GOOS() != model.PlatformLinux { + names = names[2:] + } + var out []*model.NuGetConfigFile + for _, name := range names { + p := filepath.Join(dir, name) + info, err := a.guarded(p, 0).Stat(p) + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + a.selectionReason(a.reason(err, p)) + continue + } + if !info.Mode().IsRegular() { + continue + } + if f := a.file("project", p); f != nil { + out = append(out, f) + } + } + a.dirs[key] = out + return out +} + +// ProjectFile records the allowlisted literal properties of an MSBuild file +// already read by inventory. Property values that need evaluation are skipped. +// projectDir is the consuming project; a shared Directory.*.props/targets +// passes "" and leaves project-relative paths to each consumer's record. +func (a *NuGetConfigSnapshot) ProjectFile(path, status string, reasons []string, props map[string]string, projectDir string) *model.NuGetConfigFile { + f := a.record("project", path) + if f == nil { + return nil + } + f.Status = status + f.Reasons = slices.Clone(reasons) + if f.Reasons == nil { + f.Reasons = []string{} + } + if status != "read" { + return f + } + a.paths[f.SourceID] = map[string]string{} + dir := filepath.Dir(path) + for _, key := range []string{"NuGetAudit", "RestoreLockedMode"} { + a.boolean(f, "project."+key, props[key]) + } + a.enum(f, "project.NuGetAuditMode", props["NuGetAuditMode"], "direct", "all") + a.enum(f, "project.NuGetAuditLevel", props["NuGetAuditLevel"], "low", "moderate", "high", "critical") + for _, key := range []string{"RestorePackagesPath", "NuGetLockFilePath"} { + if props[key] == "" || projectDir == "" && nugetConsumerRelative(props[key]) { + continue + } + p, ok := a.resolvePath(props[key], dir, projectDir) + if !ok { + a.degrade("path_unresolved") + continue + } + a.paths[f.SourceID][key] = p + a.setting(f, "project."+key, p, false) + } + for _, key := range []string{"RestoreFallbackFolders", "RestoreAdditionalProjectFallbackFolders"} { + if props[key] == "" || projectDir == "" && nugetConsumerRelative(props[key]) { + continue + } + list := a.pathList(props[key], dir, projectDir) + if list == nil { + a.degrade("path_unresolved") + continue + } + a.PackageRoots = append(a.PackageRoots, list...) + a.setting(f, "project."+key, strings.Join(list, ";"), false) + } + if p := a.paths[f.SourceID]["RestorePackagesPath"]; p != "" { + a.PackageRoots = append(a.PackageRoots, p) + } + return f +} + +// Ambient records the config sources NuGet would load for a project in dir: +// the leading records (process, project files), the directory chain inside +// approved roots, then user and machine configuration. +func (a *NuGetConfigSnapshot) Ambient(dir string, leading ...*model.NuGetConfigFile) NuGetPaths { + result := NuGetPaths{} + chain := []*model.NuGetConfigFile{} + for _, f := range leading { + if f != nil { + chain = append(chain, f) + } + } + for p := filepath.Clean(dir); GoWithinRoots(a.exec.GOOS(), p, a.scope.Roots); p = filepath.Dir(p) { + chain = append(chain, a.dir(p)...) + if filepath.Dir(p) == p { + break + } + } + chain = append(chain, a.userChain...) + reasons := slices.Clone(a.selectionReasons) + ids := []string{} + for _, f := range chain { + ids = append(ids, f.SourceID) + if f.Status != "not_found" { + reasons = append(reasons, f.Reasons...) // a read file can still be partial + } + if result.Repository == "" { + result.Repository = a.paths[f.SourceID]["repositoryPath"] + } + } + if a.process != nil && !slices.Contains(ids, a.process.SourceID) { + ids = append([]string{a.process.SourceID}, ids...) + } + result.Partial = len(reasons) > 0 + a.context(model.NuGetConfigContext{ProjectPath: filepath.Clean(dir), ContextKind: "ambient", ConfigSourceIDs: ids}, reasons) + return result +} + +// Restore records the ordered config input one assets file recorded. Paths +// outside approved roots get a targeted read only. +func (a *NuGetConfigSnapshot) Restore(assets, project string, paths []string) { + ids := []string{} + reasons := slices.Clone(a.selectionReasons) + for _, p := range paths { + if !filepath.IsAbs(p) { + reasons = append(reasons, "path_unresolved") + a.degrade("path_unresolved") + continue + } + f := a.file("project", filepath.Clean(p)) + if f == nil { + reasons = append(reasons, "output_size_limit") + continue + } + ids = append(ids, f.SourceID) + if f.Status != "not_found" { + reasons = append(reasons, f.Reasons...) + } + } + a.context(model.NuGetConfigContext{ProjectPath: project, ContextKind: "restore", RestoreMetadataPath: assets, ConfigSourceIDs: ids}, reasons) +} + +func (a *NuGetConfigSnapshot) context(c model.NuGetConfigContext, reasons []string) { + key := c.ContextKind + "\x00" + a.key(c.ProjectPath) + "\x00" + c.RestoreMetadataPath + if a.limited || a.contexts[key] { + return + } + a.contexts[key] = true + slices.Sort(reasons) + c.Reasons = slices.Compact(reasons) + if c.Reasons == nil { + c.Reasons = []string{} + } + c.Status = "complete" + if len(c.Reasons) > 0 { + c.Status = "partial" + } + a.audit.Contexts = append(a.audit.Contexts, c) + a.bytes += len(c.ProjectPath) + len(c.RestoreMetadataPath) + len(c.ConfigSourceIDs)*70 + 128 +} + +// nugetConsumerRelative reports whether an MSBuild path only has meaning in +// the consuming project's directory, which a shared file does not have. +func nugetConsumerRelative(value string) bool { + for entry := range strings.SplitSeq(value, ";") { + entry = strings.TrimSpace(entry) + if entry != "" && !strings.HasPrefix(entry, "$(MSBuildThisFileDirectory)") && !filepath.IsAbs(entry) { + return true + } + } + return false +} + +// resolvePath anchors a literal path: $(MSBuildThisFileDirectory) to the +// defining file, everything else to the project directory (the config file's +// own directory for NuGet.Config). MSBuild property functions and %VAR% +// references stay unresolved, as does a project-relative path without a project. +func (a *NuGetConfigSnapshot) resolvePath(value, fileDir, projectDir string) (string, bool) { + if rest, ok := strings.CutPrefix(value, "$(MSBuildThisFileDirectory)"); ok { + value = fileDir + string(filepath.Separator) + strings.TrimLeft(rest, `/\`) + } else if rest, ok := strings.CutPrefix(value, "$(MSBuildProjectDirectory)"); ok { + value = filepath.Join(projectDir, strings.TrimLeft(rest, `/\`)) + } + if value == "" || !composerSafeText(value, 4096) || strings.ContainsAny(value, "$%@*?") { + return "", false + } + if !filepath.IsAbs(value) { + if projectDir == "" { + return "", false + } + value = filepath.Join(projectDir, filepath.FromSlash(value)) + } + return filepath.Clean(value), true +} + +// pathList resolves a ';'-separated path list; one bad entry rejects the list. +func (a *NuGetConfigSnapshot) pathList(value, fileDir, projectDir string) []string { + var out []string + for entry := range strings.SplitSeq(value, ";") { + if entry = strings.TrimSpace(entry); entry == "" { + continue + } + p, ok := a.resolvePath(entry, fileDir, projectDir) + if !ok { + return nil + } + out = append(out, p) + } + return out +} + +func (a *NuGetConfigSnapshot) boolean(f *model.NuGetConfigFile, key, value string) { + switch strings.ToLower(value) { + case "": + case "true", "false": + a.setting(f, key, strings.ToLower(value), false) + default: + a.fileReason(f, "unreadable", "parse_error") + } +} +func (a *NuGetConfigSnapshot) enum(f *model.NuGetConfigFile, key, value string, allowed ...string) { + if value == "" { + return + } + if slices.Contains(allowed, strings.ToLower(value)) { + a.setting(f, key, strings.ToLower(value), false) + return + } + a.fileReason(f, "unreadable", "parse_error") +} + +// nugetLiteralSecret reports whether a ClearTextPassword value stores the +// password itself. NuGet resolves a whole-value %VARIABLE% reference from the +// environment at use time, so that spelling leaves nothing in the file. +func nugetLiteralSecret(value string) bool { + if value == "" { + return false + } + inner, ok := strings.CutPrefix(value, "%") + inner, ok2 := strings.CutSuffix(inner, "%") + return !(ok && ok2 && inner != "" && !strings.Contains(inner, "%")) +} + +// nugetAlias bounds a user-chosen source name; names may carry secrets. +func nugetAlias(name string) (string, bool) { + if name == "" || !composerSafeText(name, 128) || strings.ContainsAny(name, "@:/\\?#") { + return goRedacted, true + } + return name, false +} + +// nugetSourceValue is a sanitized URL or an anchored local path. +func (a *NuGetConfigSnapshot) nugetSourceValue(value, base string) (string, bool) { + if !composerSafeText(value, 4096) { + return goRedacted, true + } + // URLs, file URIs, UNC and drive paths are already whole; only a + // relative path needs the config directory as its anchor. + if strings.Contains(value, "://") || strings.Contains(value, "@") || strings.HasPrefix(value, `\\`) || len(value) > 2 && value[1] == ':' && (value[2] == '\\' || value[2] == '/') { + return SanitizeComposerURL(value) + } + if p, ok := a.resolvePath(value, base, base); ok { + return p, false + } + return goRedacted, true +} + +type nugetSourceEntry struct { + name, value, insecure, disableTLS string +} + +// parse walks the configuration XML, reading only allowlisted scalar and +// list settings. Credential values become presence flags. +func (a *NuGetConfigSnapshot) parse(f *model.NuGetConfigFile, data []byte) { + if !utf8.Valid(data) || bytes.Contains(bytes.ToUpper(data), []byte(" only matters for the effective-feed question the audit + // does not answer, so it is not a setting. + switch { + case section == "packageSources" && len(stack) == 3 && name == "add": + sources = append(sources, nugetSourceEntry{name: attr(t, "key"), value: attr(t, "value"), insecure: attr(t, "allowInsecureConnections"), disableTLS: attr(t, "disableTLSCertificateValidation")}) + case section == "disabledPackageSources" && len(stack) == 3 && name == "add": + if strings.EqualFold(attr(t, "value"), "true") { + disabled[strings.ToLower(attr(t, "key"))] = true + } + case section == "packageSourceMapping" && len(stack) == 4 && name == "package": + alias, redacted := nugetAlias(attr(stack[2], "key")) + pattern := attr(t, "pattern") + if pattern == "" || !composerSafeText(pattern, 256) { + a.fileReason(f, "unreadable", "parse_error") + continue + } + prefix := "packageSourceMapping." + strconv.Itoa(mapping) + "." + mapping++ + a.setting(f, prefix+"source", alias, redacted) + a.setting(f, prefix+"pattern", pattern, false) + case section == "fallbackPackageFolders" && len(stack) == 3 && name == "add": + p, ok := a.resolvePath(attr(t, "value"), base, base) + if !ok { + a.degrade("path_unresolved") + continue + } + a.PackageRoots = append(a.PackageRoots, p) + a.setting(f, "fallbackPackageFolders."+strconv.Itoa(fallback)+".path", p, false) + fallback++ + case section == "config" && len(stack) == 3 && name == "add": + a.configSetting(f, attr(t, "key"), attr(t, "value"), base, &auth) + case section == "trustedSigners" && len(stack) == 3 && (name == "author" || name == "repository"): + a.setting(f, "trustedSigners."+strconv.Itoa(signers)+".type", name, false) + case section == "trustedSigners" && len(stack) == 4 && name == "certificate": + if v := strings.ToLower(attr(t, "allowUntrustedRoot")); v == "true" || v == "false" { + a.setting(f, "trustedSigners."+strconv.Itoa(signers)+".allowUntrustedRoot", v, false) + } + case section == "packageSourceCredentials" && len(stack) == 4 && name == "add": + auth = true + if strings.EqualFold(attr(t, "key"), "ClearTextPassword") && nugetLiteralSecret(attr(t, "value")) { + cleartext = true + } + case section == "apikeys" && len(stack) == 3 && name == "add": + auth = true + } + case xml.CharData: + if len(stack) == 0 && len(bytes.TrimSpace(t)) != 0 { + a.fileReason(f, "unreadable", "parse_error") + return + } + case xml.EndElement: + if len(stack) == 0 { + a.fileReason(f, "unreadable", "parse_error") + return + } + top := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if len(stack) == 2 && strings.EqualFold(stack[0].Name.Local, "configuration") && stack[1].Name.Local == "trustedSigners" && (top.Name.Local == "author" || top.Name.Local == "repository") { + signers++ + } + } + } + if !rootSeen { + a.fileReason(f, "unreadable", "parse_error") + return + } + for i, s := range sources { + prefix := "packageSources." + strconv.Itoa(i) + "." + alias, redacted := nugetAlias(s.name) + a.setting(f, prefix+"name", alias, redacted) + value, redactedValue := a.nugetSourceValue(s.value, base) + a.setting(f, prefix+"url", value, redactedValue) + enabled := "true" + if disabled[strings.ToLower(s.name)] { + enabled = "false" + delete(disabled, strings.ToLower(s.name)) + } + a.setting(f, prefix+"enabled", enabled, false) + for _, b := range []struct{ key, value string }{{"allowInsecureConnections", s.insecure}, {"disableTLSCertificateValidation", s.disableTLS}} { + if v := strings.ToLower(b.value); v == "true" || v == "false" { + a.setting(f, prefix+b.key, v, false) + } + } + } + for i, name := range slices.Sorted(func(yield func(string) bool) { + for n := range disabled { + if !yield(n) { + return + } + } + }) { + // A source disabled here but added elsewhere is still an observed fact. + alias, redacted := nugetAlias(name) + prefix := "packageSources." + strconv.Itoa(len(sources)+i) + "." + a.setting(f, prefix+"name", alias, redacted) + a.setting(f, prefix+"enabled", "false", false) + } + if auth { + a.setting(f, "auth_configured", "true", true) + } + // Decided here, before redaction; only the finding leaves the device. + a.cleartext[f.SourceID] = cleartext +} + +func (a *NuGetConfigSnapshot) configSetting(f *model.NuGetConfigFile, key, value, base string, auth *bool) { + switch key { + case "globalPackagesFolder", "repositoryPath": + p, ok := a.resolvePath(value, base, base) + if !ok { + a.degrade("path_unresolved") + return + } + a.paths[f.SourceID][key] = p + a.PackageRoots = append(a.PackageRoots, p) + a.setting(f, "config."+key, p, false) + case "http_proxy": + display, redacted := sanitizeComposerProxy(value) + a.setting(f, "config.http_proxy", display, redacted) + case "no_proxy": + display, redacted := value, false + if !composerNoProxy(value) { + display, redacted = goRedacted, true + } + a.setting(f, "config.no_proxy", display, redacted) + case "http_proxy.user", "http_proxy.password": + *auth = true + case "signatureValidationMode": + a.enum(f, "config.signatureValidationMode", value, "accept", "require") + case "maxHttpRequestsPerSource": + if n, err := strconv.Atoi(value); err == nil && n > 0 && strconv.Itoa(n) == value { + a.setting(f, "config.maxHttpRequestsPerSource", value, false) + } else { + a.fileReason(f, "unreadable", "parse_error") + } + } +} + +func nugetFindings(f model.NuGetConfigFile, cleartext bool) []model.CargoConfigFinding { + values := map[string]string{} + for _, s := range f.Settings { + values[s.Key] = s.Display + } + var out []model.CargoConfigFinding + add := func(code, severity, key, detail string) { + out = append(out, model.CargoConfigFinding{SourceID: f.SourceID, Code: code, Severity: severity, Key: key, Detail: detail}) + } + for _, s := range f.Settings { + prefix, field, _ := strings.Cut(s.Key, ".url") + switch { + case strings.HasPrefix(s.Key, "packageSources.") && field == "" && strings.HasSuffix(s.Key, ".url") && values[prefix+".enabled"] != "false": + if strings.HasPrefix(strings.ToLower(s.Display), "http://") { + add("nuget-001", "MEDIUM", s.Key, "NuGet package source uses plaintext HTTP.") + } + if values[prefix+".disableTLSCertificateValidation"] == "true" { + add("nuget-002", "HIGH", prefix+".disableTLSCertificateValidation", "NuGet package source disables TLS certificate validation.") + } + case s.Key == "auth_configured" && cleartext: + add("nuget-003", "MEDIUM", s.Key, "NuGet configuration stores a literal cleartext password.") + case s.Key == "project.NuGetAudit" && s.Display == "false": + add("nuget-004", "MEDIUM", s.Key, "NuGet audit is disabled for the project.") + } + } + return out +} + +func (a *NuGetConfigSnapshot) Finish() model.NuGetConfigAudit { + for _, f := range a.files { + // An incomplete config source has no current settings. The consumer + // retains its last-known settings separately until a complete reread. + if f.Status != "read" { + f.Settings = []model.CargoConfigSetting{} + } + slices.Sort(f.Reasons) + slices.SortFunc(f.Settings, func(x, y model.CargoConfigSetting) int { return strings.Compare(x.Key, y.Key) }) + a.audit.Files = append(a.audit.Files, *f) + a.audit.Findings = append(a.audit.Findings, nugetFindings(*f, a.cleartext[f.SourceID])...) + for _, r := range f.Reasons { + a.degrade(r) + } + } + nugetSummarize(&a.audit) + slices.SortFunc(a.audit.Files, func(x, y model.NuGetConfigFile) int { + return strings.Compare(x.Scope+"\x00"+x.Path, y.Scope+"\x00"+y.Path) + }) + slices.SortFunc(a.audit.Contexts, func(x, y model.NuGetConfigContext) int { + return strings.Compare(x.ProjectPath+"\x00"+x.ContextKind+"\x00"+x.RestoreMetadataPath, y.ProjectPath+"\x00"+y.ContextKind+"\x00"+y.RestoreMetadataPath) + }) + slices.SortFunc(a.audit.Findings, func(x, y model.CargoConfigFinding) int { + return strings.Compare(x.Code+x.SourceID+x.Key, y.Code+y.SourceID+y.Key) + }) + slices.Sort(a.audit.Reasons) + data, _ := json.Marshal(a.audit) + if len(data) > maxNuGetAuditBytes { + a.audit = emptyNuGetAudit() + a.degrade("output_size_limit") + } + return a.audit +} + +// nugetSummarize derives the endpoint lists and counts from the read files. +func nugetSummarize(audit *model.NuGetConfigAudit) { + registries, proxies := map[string]bool{}, map[string]bool{} + for _, f := range audit.Files { + if f.Status != "read" { + continue + } + audit.SourceCount++ + for _, s := range f.Settings { + switch { + case s.Display == "" || s.Display == goRedacted: + case strings.HasPrefix(s.Key, "packageSources.") && strings.HasSuffix(s.Key, ".url"): + registries[s.Display] = true + case slices.Contains([]string{"config.http_proxy", "environment.HTTP_PROXY", "environment.HTTPS_PROXY", "environment.http_proxy", "environment.https_proxy"}, s.Key): + proxies[s.Display] = true + } + } + } + audit.ObservedRegistries = append(audit.ObservedRegistries[:0], slices.Sorted(maps.Keys(registries))...) + audit.ObservedProxies = append(audit.ObservedProxies[:0], slices.Sorted(maps.Keys(proxies))...) + audit.ObservedRegistryCount, audit.ObservedProxyCount, audit.FindingCount = len(audit.ObservedRegistries), len(audit.ObservedProxies), len(audit.Findings) + for _, f := range audit.Findings { + audit.FindingCodeCounts[f.Code]++ + audit.FindingCounts[f.Severity]++ + } +} diff --git a/internal/detector/configaudit/nugetconfig_test.go b/internal/detector/configaudit/nugetconfig_test.go new file mode 100644 index 00000000..14cdaefc --- /dev/null +++ b/internal/detector/configaudit/nugetconfig_test.go @@ -0,0 +1,398 @@ +package configaudit + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +// nugetConfigMock adds the directory listings the NuGet audit probes; an +// unregistered directory is absent rather than an error. +type nugetConfigMock struct{ *composerConfigMock } + +func (m *nugetConfigMock) GuardedFiles([]string, func(string) string, int64) executor.Executor { + return m +} +func (m *nugetConfigMock) ReadDirLimit(path string, max int) ([]os.DirEntry, bool, error) { + if !m.DirExists(path) { + return nil, false, os.ErrNotExist + } + return m.Mock.ReadDirLimit(path, max) +} +func nugetConfigTest(t *testing.T) (*nugetConfigMock, NuGetConfigScope) { + t.Helper() + inner, scope := composerConfigTest(t) + return &nugetConfigMock{inner}, NuGetConfigScope{Username: scope.Username, Home: scope.Home, Roots: []string{scope.Home}, ProcessVerified: true} +} +func nugetSettings(a model.NuGetConfigAudit, path string) map[string]string { + result := map[string]string{} + for _, f := range a.Files { + if f.Path == path { + for _, s := range f.Settings { + result[s.Key] = s.Display + } + } + } + return result +} +func nugetFile(t *testing.T, a model.NuGetConfigAudit, path string) model.NuGetConfigFile { + t.Helper() + for _, f := range a.Files { + if f.Path == path { + return f + } + } + t.Fatalf("no file record for %q", path) + return model.NuGetConfigFile{} +} + +func TestNuGetConfigAllowlistRedactionAndFindings(t *testing.T) { + m, scope := nugetConfigTest(t) + packages := filepath.Join(scope.Home, "CANARY_DIR", "packages") + fallback := filepath.Join(scope.Home, "fallback") + legacy := filepath.Join(scope.Home, "legacy") + m.SetEnv("NUGET_PACKAGES", packages) + m.SetEnv("NUGET_FALLBACK_PACKAGES", "/opt/fallback;relative/ignored") + m.SetEnv("HTTPS_PROXY", "https://alice:CANARY_PROXY@proxy.example.invalid:8443/?token=CANARY_QUERY") + m.SetEnv("NO_PROXY", "localhost,.example.invalid") + m.SetEnv("NuGetPackageSourceCredentials_internal", "Username=alice;Password=CANARY_ENV_PASSWORD") + m.SetEnv("NUGET_CREDENTIALPROVIDERS_PATH", "/opt/CANARY_PROVIDER") + user := filepath.Join(scope.Home, ".nuget", "NuGet", "NuGet.Config") + m.SetFile(user, []byte(` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CANARY_OWNER + +`)) + a := NewNuGetConfigDetector(m).Detect(context.Background(), scope) + if a.GlobalPackages != packages || a.CLIHome != scope.Home { + t.Errorf("GlobalPackages = %q CLIHome = %q", a.GlobalPackages, a.CLIHome) + } + if !slices.Equal(a.PackageRoots, []string{fallback, filepath.Join(filepath.Dir(user), "pkgs"), legacy}) { + t.Errorf("PackageRoots = %v", a.PackageRoots) + } + audit := a.Finish() + if audit.Status != "partial" || !slices.Contains(audit.Reasons, "path_unresolved") { + t.Errorf("status = %s reasons = %v", audit.Status, audit.Reasons) + } + process := nugetSettings(audit, "") + want := map[string]string{ + "environment.NUGET_PACKAGES": packages, "environment.HTTPS_PROXY": "https://proxy.example.invalid:8443/", + "environment.NO_PROXY": "localhost,.example.invalid", "auth_configured": "true", "credential_provider_configured": "true", + } + for key, value := range want { + if process[key] != value { + t.Errorf("process %s = %q, want %q", key, process[key], value) + } + } + if _, ok := process["environment.NUGET_FALLBACK_PACKAGES"]; ok { + t.Error("list with a relative entry recorded") + } + got := nugetSettings(audit, user) + want = map[string]string{ + "packageSources.0.name": "nuget.org", "packageSources.0.url": "https://api.nuget.org/v3/index.json", "packageSources.0.enabled": "true", + "packageSources.1.name": "plain", "packageSources.1.url": "http://plain.example.invalid/v3/index.json", "packageSources.1.enabled": "true", "packageSources.1.allowInsecureConnections": "true", + "packageSources.2.name": "lab", "packageSources.2.url": "https://lab.example.invalid/v3/index.json", "packageSources.2.enabled": "true", "packageSources.2.disableTLSCertificateValidation": "true", + "packageSources.3.name": "off", "packageSources.3.url": "http://off.example.invalid/v3/index.json", "packageSources.3.enabled": "false", + "packageSources.4.name": goRedacted, "packageSources.4.url": `C:\feeds\local`, "packageSources.4.enabled": "true", + "packageSources.5.name": "rel", "packageSources.5.url": filepath.Join(filepath.Dir(user), "..", "shared", "feed"), "packageSources.5.enabled": "true", + "packageSources.6.name": "elsewhere", "packageSources.6.enabled": "false", + "packageSourceMapping.0.source": "nuget.org", "packageSourceMapping.0.pattern": "*", + "packageSourceMapping.1.source": "lab", "packageSourceMapping.1.pattern": "Contoso.*", "packageSourceMapping.2.source": "lab", "packageSourceMapping.2.pattern": "Fabrikam", + "fallbackPackageFolders.0.path": fallback, + "config.globalPackagesFolder": filepath.Join(filepath.Dir(user), "pkgs"), "config.repositoryPath": legacy, + "config.http_proxy": "http://proxy.example.invalid:8080", "config.no_proxy": "localhost", + "config.signatureValidationMode": "require", "config.maxHttpRequestsPerSource": "16", + "trustedSigners.0.type": "author", "trustedSigners.0.allowUntrustedRoot": "true", + "trustedSigners.1.type": "repository", "trustedSigners.1.allowUntrustedRoot": "false", + "auth_configured": "true", + } + for key, value := range want { + if got[key] != value { + t.Errorf("%s = %q, want %q", key, got[key], value) + } + } + for key := range got { + if _, ok := want[key]; !ok { + t.Errorf("unexpected setting %s = %q", key, got[key]) + } + } + codes := []string{} + for _, f := range audit.Findings { + codes = append(codes, f.Code+":"+f.Key) + } + if !slices.Equal(codes, []string{"nuget-001:packageSources.1.url", "nuget-002:packageSources.2.disableTLSCertificateValidation"}) { + t.Errorf("findings = %v", codes) + } + wantRegistries := []string{`C:\feeds\local`, filepath.Join(filepath.Dir(user), "..", "shared", "feed"), "http://off.example.invalid/v3/index.json", "http://plain.example.invalid/v3/index.json", "https://api.nuget.org/v3/index.json", "https://lab.example.invalid/v3/index.json"} + slices.Sort(wantRegistries) + if audit.SourceCount != 2 || !slices.Equal(audit.ObservedRegistries, wantRegistries) || !slices.Equal(audit.ObservedProxies, []string{"http://proxy.example.invalid:8080", "https://proxy.example.invalid:8443/"}) || + audit.FindingCount != 2 || audit.FindingCodeCounts["nuget-001"] != 1 || audit.FindingCounts["HIGH"] != 1 || audit.ObservedRegistryCount != 6 || audit.ObservedProxyCount != 2 { + t.Errorf("summary = %+v", audit) + } + if data := string(mustJSON(t, audit)); strings.Contains(data, "CANARY_") && !strings.Contains(data, "CANARY_DIR") || strings.Count(data, "CANARY_") != 1 { + t.Errorf("audit leaked a secret: %s", data) + } +} + +func mustJSON(t *testing.T, v any) []byte { + t.Helper() + data, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return data +} + +// Only a literal password in the file is a cleartext finding; a %VARIABLE% +// reference, an empty value and an encrypted password keep presence only. +func TestNuGetConfigCredentialFinding(t *testing.T) { + tests := []struct { + name, credentials string + finding bool + }{ + {"literal password", ``, true}, + {"mixed literal and reference", ``, true}, + {"environment reference", ``, false}, + {"empty value", ``, false}, + {"encrypted password", ``, false}, + {"provider only", ``, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m, scope := nugetConfigTest(t) + path := filepath.Join(scope.Home, ".nuget", "NuGet", "NuGet.Config") + m.SetFile(path, []byte(``+tc.credentials+``)) + audit := NewNuGetConfigDetector(m).Detect(context.Background(), scope).Finish() + got := nugetSettings(audit, path) + if got["auth_configured"] != "true" || len(got) != 4 { + t.Errorf("settings = %v", got) + } + found := slices.ContainsFunc(audit.Findings, func(f model.CargoConfigFinding) bool { + return f.Code == "nuget-003" && f.Key == "auth_configured" && f.Severity == "MEDIUM" + }) + if found != tc.finding || len(audit.Findings) != audit.FindingCount { + t.Errorf("nuget-003 reported = %v, want %v (%+v)", found, tc.finding, audit.Findings) + } + if strings.Contains(string(mustJSON(t, audit)), "CANARY_") { + t.Error("credential text leaked") + } + }) + } +} + +func TestNuGetConfigPrecedenceAndContexts(t *testing.T) { + m, scope := nugetConfigTest(t) + fallback := filepath.Join(scope.Home, "fallback") + userDir := filepath.Join(scope.Home, ".nuget", "NuGet") + primary := filepath.Join(userDir, "NuGet.Config") + alt := filepath.Join(scope.Home, ".config", "NuGet", "NuGet.Config") + extra := filepath.Join(userDir, "config", "zextra.config") + additional := filepath.Join(userDir, "config", "additional.config") + machine := filepath.Join("/etc/opt/NuGet/Config", "machine.config") + for _, p := range []string{primary, alt, extra, additional, machine} { + m.SetFile(p, []byte(``)) + } + m.SetDirEntries(filepath.Join(userDir, "config"), []os.DirEntry{executor.MockDirEntry("zextra.config", false), executor.MockDirEntry("additional.config", false), executor.MockDirEntry("notes.txt", false), executor.MockDirEntry("sub", true)}) + m.SetDirEntries("/etc/opt/NuGet/Config", []os.DirEntry{executor.MockDirEntry("machine.config", false)}) + project := filepath.Join(scope.Home, "src", "app") + m.SetFile(filepath.Join(scope.Home, "src", "nuget.config"), []byte(``)) + m.SetFile(filepath.Join(project, "NuGet.Config"), []byte(``)) + a := NewNuGetConfigDetector(m).Detect(context.Background(), scope) + record := a.ProjectFile(filepath.Join(project, "App.csproj"), "read", nil, map[string]string{"NuGetAudit": "false", "NuGetAuditLevel": "High", "RestorePackagesPath": "$(MSBuildProjectDirectory)/pkgs", "NuGetLockFilePath": "locks/packages.lock.json", "RestoreFallbackFolders": fallback + ";../shared"}, project) + // A shared file keeps only paths anchored to itself; consumer-relative ones wait for each consumer. + shared := a.ProjectFile(filepath.Join(scope.Home, "src", "Directory.Build.props"), "read", nil, map[string]string{"RestorePackagesPath": "packages", "NuGetLockFilePath": "$(MSBuildThisFileDirectory)locks/shared.lock.json", "RestoreFallbackFolders": "/opt/fb2;fallback"}, "") + if len(shared.Settings) != 1 || shared.Settings[0].Key != "project.NuGetLockFilePath" || shared.Settings[0].Display != filepath.Join(scope.Home, "src", "locks", "shared.lock.json") { + t.Errorf("shared file settings = %+v", shared.Settings) + } + paths := a.Ambient(project, record) + a.Restore(filepath.Join(project, "obj", "project.assets.json"), project, []string{filepath.Join(project, "NuGet.Config"), primary, "relative.config"}) + audit := a.Finish() + if paths.Repository != filepath.Join(scope.Home, "src", "packages") { + t.Errorf("Repository = %q", paths.Repository) + } + if !slices.Equal(a.PackageRoots, []string{fallback, filepath.Join(scope.Home, "src", "shared"), filepath.Join(project, "pkgs"), filepath.Join(scope.Home, "src", "packages")}) { + t.Errorf("PackageRoots = %v", a.PackageRoots) + } + ids := func(paths ...string) []string { + out := []string{} + for _, p := range paths { + out = append(out, GoSourceID(scope.Username, "nuget_config_"+nugetFile(t, audit, p).Scope, p)) + } + return out + } + process := GoSourceID(scope.Username, "nuget_config_process", "") + if len(audit.Contexts) != 2 { + t.Fatalf("contexts = %+v", audit.Contexts) + } + ambient, restore := audit.Contexts[0], audit.Contexts[1] + wantAmbient := append([]string{process}, ids(filepath.Join(project, "App.csproj"), filepath.Join(project, "NuGet.Config"), filepath.Join(scope.Home, "src", "nuget.config"), primary, additional, extra, machine)...) + if ambient.ContextKind != "ambient" || ambient.Status != "complete" || len(ambient.Reasons) != 0 || !slices.Equal(ambient.ConfigSourceIDs, wantAmbient) { + t.Errorf("ambient = %+v\nwant %v", ambient, wantAmbient) + } + if restore.ContextKind != "restore" || restore.RestoreMetadataPath != filepath.Join(project, "obj", "project.assets.json") || restore.Status != "partial" || !slices.Equal(restore.Reasons, []string{"path_unresolved"}) || !slices.Equal(restore.ConfigSourceIDs, ids(filepath.Join(project, "NuGet.Config"), primary)) { + t.Errorf("restore = %+v", restore) + } + if f := nugetFile(t, audit, alt); f.Scope != "user" || f.Status != "read" || slices.Contains(wantAmbient, f.SourceID) { + t.Errorf("alternate user file = %+v", f) + } + got := nugetSettings(audit, filepath.Join(project, "App.csproj")) + want := map[string]string{"project.NuGetAudit": "false", "project.NuGetAuditLevel": "high", "project.RestorePackagesPath": filepath.Join(project, "pkgs"), "project.NuGetLockFilePath": filepath.Join(project, "locks", "packages.lock.json"), "project.RestoreFallbackFolders": fallback + ";" + filepath.Join(scope.Home, "src", "shared")} + for key, value := range want { + if got[key] != value { + t.Errorf("%s = %q, want %q", key, got[key], value) + } + } + if len(got) != len(want) { + t.Errorf("project settings = %v", got) + } + codes := []string{} + for _, f := range audit.Findings { + codes = append(codes, f.Code) + } + if !slices.Equal(codes, []string{"nuget-004"}) { + t.Errorf("findings = %v", codes) + } +} + +// A verified DOTNET_CLI_HOME moves the Unix user profile NuGet reads; Windows +// stays on APPDATA. +func TestNuGetConfigCLIHomeUserConfig(t *testing.T) { + tests := []struct { + name, goos string + want func(home, cli string) string + }{ + {"unix follows cli home", "linux", func(_, cli string) string { return filepath.Join(cli, ".nuget", "NuGet", "NuGet.Config") }}, + {"windows keeps appdata", "windows", func(home, _ string) string { return filepath.Join(home, "AppData", "Roaming", "NuGet", "NuGet.Config") }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m, scope := nugetConfigTest(t) + m.SetGOOS(tc.goos) + cli := filepath.Join(scope.Home, "profiles", "cli") + m.SetEnv("DOTNET_CLI_HOME", cli) + want := tc.want(scope.Home, cli) + m.SetFile(want, []byte(``)) + a := NewNuGetConfigDetector(m).Detect(context.Background(), scope) + a.Ambient(filepath.Join(scope.Home, "app")) + audit := a.Finish() + if a.CLIHome != cli { + t.Fatalf("CLIHome = %q", a.CLIHome) + } + f := nugetFile(t, audit, want) + if f.Scope != "user" || f.Status != "read" || !slices.Contains(audit.Contexts[0].ConfigSourceIDs, f.SourceID) { + t.Errorf("user config = %+v", f) + } + for _, other := range audit.Files { + if other.Scope == "user" && other.Status == "read" && other.Path != want { + t.Errorf("unexpected user config read: %+v", other) + } + } + }) + } +} + +func TestNuGetConfigUnverifiedProcessAndFailedSources(t *testing.T) { + m, scope := nugetConfigTest(t) + scope.ProcessVerified = false + m.SetEnv("NUGET_PACKAGES", "/srv/CANARY_DIR") + m.SetEnv("NuGetPackageSourceCredentials_x", "CANARY") + user := filepath.Join(scope.Home, ".nuget", "NuGet", "NuGet.Config") + m.SetFile(user, []byte(`]>&x;`)) + a := NewNuGetConfigDetector(m).Detect(context.Background(), scope) + paths := a.Ambient(filepath.Join(scope.Home, "app")) + audit := a.Finish() + if a.GlobalPackages != filepath.Join(scope.Home, ".nuget", "packages") || !paths.Partial { + t.Errorf("GlobalPackages = %q partial = %v", a.GlobalPackages, paths.Partial) + } + if f := nugetFile(t, audit, ""); f.Status != "unsupported" || !slices.Equal(f.Reasons, []string{"process_context_unverified"}) || len(f.Settings) != 0 { + t.Errorf("process file = %+v", f) + } + if f := nugetFile(t, audit, user); f.Status != "unreadable" || !slices.Equal(f.Reasons, []string{"parse_error"}) || len(f.Settings) != 0 { + t.Errorf("invalid file = %+v", f) + } + if c := audit.Contexts[0]; c.Status != "partial" || !slices.Equal(c.Reasons, []string{"parse_error", "process_context_unverified"}) || strings.Contains(string(mustJSON(t, audit)), "CANARY") { + t.Errorf("audit = %s", mustJSON(t, audit)) + } +} + +func TestNuGetConfigBoundedAudit(t *testing.T) { + m, scope := nugetConfigTest(t) + old := maxNuGetAuditBytes + maxNuGetAuditBytes = 256 + t.Cleanup(func() { maxNuGetAuditBytes = old }) + a := NewNuGetConfigDetector(m).Detect(context.Background(), scope) + a.Ambient(filepath.Join(scope.Home, "app")) + audit := a.Finish() + if audit.Status != "partial" || !slices.Contains(audit.Reasons, "output_size_limit") { + t.Fatalf("audit overflow = %+v", audit) + } +} + +func TestNuGetConfigInvalidAndIncompleteInputs(t *testing.T) { + for _, input := range []string{"", " ", ``, ``, `text`, ``} { + m, scope := nugetConfigTest(t) + path := filepath.Join(scope.Home, ".nuget", "NuGet", "NuGet.Config") + m.SetFile(path, []byte(input)) + a := NewNuGetConfigDetector(m).Detect(t.Context(), scope) + a.Ambient(scope.Home) + a.Restore(filepath.Join(scope.Home, "obj", "project.assets.json"), scope.Home, []string{path}) + audit := a.Finish() + if f := nugetFile(t, audit, path); f.Status != "unreadable" || len(f.Settings) != 0 || !slices.Contains(f.Reasons, "parse_error") { + t.Errorf("input %q: %+v", input, f) + } + for _, c := range audit.Contexts { + if c.Status != "partial" || !slices.Contains(c.Reasons, "parse_error") { + t.Errorf("input %q: %+v", input, c) + } + } + } + m, scope := nugetConfigTest(t) + entries := make([]os.DirEntry, nugetSettingLimit+1) + for i := range entries { + entries[i] = executor.MockDirEntry("extra.config", false) + } + m.SetDirEntries(filepath.Join(scope.Home, ".nuget", "NuGet", "config"), entries) + a := NewNuGetConfigDetector(m).Detect(t.Context(), scope) + a.Ambient(scope.Home) + a.Restore(filepath.Join(scope.Home, "obj", "project.assets.json"), scope.Home, nil) + for _, c := range a.Finish().Contexts { + if c.Status != "partial" || !slices.Contains(c.Reasons, "entry_limit") { + t.Errorf("truncated config selection: %+v", c) + } + } +} diff --git a/internal/detector/nuget_golden_test.go b/internal/detector/nuget_golden_test.go new file mode 100644 index 00000000..e90ef640 --- /dev/null +++ b/internal/detector/nuget_golden_test.go @@ -0,0 +1,81 @@ +package detector + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/detector/configaudit" + "github.com/step-security/dev-machine-guard/internal/model" +) + +// The shared contract is generated by the real guarded scanner over the +// fixture tree. Only the temporary home and the IDs derived from it are +// normalized. +func TestNuGetGoldenProducer(t *testing.T) { + if runtime.GOOS == model.PlatformWindows { + t.Skip("canonical Unix fixture; wire round-trip and scanner tests also run on Windows") + } + home := goTestHome(t) + root := filepath.Join(home, "code") + nugetWriteFixture(t, home, root) + scanner, x := nugetTestScanner(t, home) + nugetFixtureEnv(x) + inv, audit := scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + nugetAssertRefs(t, inv, audit) + type sections struct { + Inventory *model.NuGetInventory `json:"nuget_inventory"` + Audit *model.NuGetConfigAudit `json:"nuget_config_audit"` + } + pair := sections{inv, audit} + raw, err := json.Marshal(pair) + if err != nil { + t.Fatal(err) + } + raw = bytes.ReplaceAll(raw, []byte(home), []byte("/home/nuget-fixture")) + if err := json.Unmarshal(raw, &pair); err != nil { + t.Fatal(err) + } + replacements := []string{} + for _, s := range pair.Inventory.Sources { + replacements = append(replacements, s.SourceID, configaudit.GoSourceID(goTestTarget(home).Username, s.Kind, s.Path)) + } + for _, f := range pair.Audit.Files { + replacements = append(replacements, f.SourceID, configaudit.GoSourceID(goTestTarget(home).Username, "nuget_config_"+f.Scope, f.Path)) + } + raw = []byte(strings.NewReplacer(replacements...).Replace(string(raw))) + if err := json.Unmarshal(raw, &pair); err != nil { + t.Fatal(err) + } + for i := range pair.Inventory.Sources { + slices.Sort(pair.Inventory.Sources[i].DiscoveredSources) + } + slices.SortFunc(pair.Audit.Findings, func(a, b model.CargoConfigFinding) int { + return strings.Compare(a.Code+a.SourceID+a.Key, b.Code+b.SourceID+b.Key) + }) + nugetAssertRefs(t, pair.Inventory, pair.Audit) + raw, err = json.MarshalIndent(pair, "", " ") + if err != nil { + t.Fatal(err) + } + raw = append(raw, '\n') + path := "../model/testdata/nuget_inventory_v1_golden.json" + if os.Getenv("UPDATE_NUGET_GOLDEN") == "1" { + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(raw, want) { + t.Fatal("producer differs from shared NuGet golden; review the contract before regenerating") + } +} diff --git a/internal/detector/nugetmetadata.go b/internal/detector/nugetmetadata.go new file mode 100644 index 00000000..ce131c22 --- /dev/null +++ b/internal/detector/nugetmetadata.go @@ -0,0 +1,1041 @@ +package detector + +import ( + "archive/zip" + "bytes" + "encoding/base64" + "encoding/json" + "encoding/xml" + "errors" + "io" + "maps" + "path" + "regexp" + "slices" + "strings" + "unicode/utf8" + + "github.com/step-security/dev-machine-guard/internal/model" +) + +var ( + maxNuGetMetadataBytes int64 = 2 << 20 + maxNuGetArchiveBytes int64 = 32 << 20 +) + +var errNuGetUnsupportedFormat = errors.New("unsupported metadata format") + +// nugetPackageID is NuGet's documented ID syntax; comparison is case-insensitive. +var nugetPackageID = regexp.MustCompile(`^\w+([.-]\w+)*$`) + +func nugetID(raw string) (string, bool) { + if len(raw) > 100 || !nugetPackageID.MatchString(raw) { + return "", false + } + return strings.ToLower(raw), true +} + +// nugetNormalizeVersion is the comparison identity of an exact NuGet version: +// 1-4 numeric parts without leading zeros, padded to three, a zero fourth +// part dropped, build metadata removed and the prerelease label lowercased. +// Ranges and floating requests are not exact versions. +func nugetNormalizeVersion(raw string) (string, bool) { + raw = strings.TrimSpace(raw) + if raw == "" || len(raw) > 256 || strings.ContainsAny(raw, " \t[](),*") { + return "", false + } + core, _, _ := strings.Cut(raw, "+") + core, pre, hasPre := strings.Cut(core, "-") + parts := strings.Split(core, ".") + if len(parts) > 4 { + return "", false + } + for i, p := range parts { + if p == "" || len(p) > 18 || strings.ContainsFunc(p, func(r rune) bool { return r < '0' || r > '9' }) { + return "", false + } + if parts[i] = strings.TrimLeft(p, "0"); parts[i] == "" { + parts[i] = "0" + } + } + for len(parts) < 3 { + parts = append(parts, "0") + } + if len(parts) == 4 && parts[3] == "0" { + parts = parts[:3] + } + out := strings.Join(parts, ".") + if !hasPre { + return out, true + } + if pre == "" { + return "", false + } + for ident := range strings.SplitSeq(pre, ".") { + if ident == "" || strings.ContainsFunc(ident, func(r rune) bool { + return !(r >= '0' && r <= '9' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r == '-') + }) { + return "", false + } + // Numeric identifiers with leading zeros are invalid; alpha01 is fine. + if len(ident) > 1 && ident[0] == '0' && !strings.ContainsFunc(ident, func(r rune) bool { return r < '0' || r > '9' }) { + return "", false + } + } + return out + "-" + strings.ToLower(pre), true +} + +func nugetText(s string, limit int) bool { return composerText(s, limit) } + +func newNuGetPackage(rawID string) model.NuGetPackage { + id, _ := nugetID(rawID) + return model.NuGetPackage{ + PackageName: id, DisplayName: rawID, DependencyRelation: "unknown", VersionStatus: "unknown", + Reasons: []string{}, Origin: model.NuGetOrigin{Kind: "unknown"}, ChecksumStatus: "not_recorded", + RecordedChecksums: []model.NuGetRecordedChecksum{}, + } +} + +// nugetExact fills the exact version fields of a resolved or receipt record. +func nugetExact(p *model.NuGetPackage, version string) bool { + normalized, ok := nugetNormalizeVersion(version) + if !ok { + return false + } + p.Version, p.VersionNormalized, p.VersionStatus = version, normalized, "known" + return true +} + +// nugetContentHash validates a recorded base64 SHA-512 and records its kind. +func nugetContentHash(p *model.NuGetPackage, kind, value string) { + if value == "" { + return + } + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil || len(decoded) != 64 { + p.ChecksumStatus = "partial" + p.Reasons = append(p.Reasons, "checksum_invalid") + return + } + if p.ChecksumStatus == "not_recorded" { + p.ChecksumStatus = "recorded" + } + p.RecordedChecksums = append(p.RecordedChecksums, model.NuGetRecordedChecksum{Kind: kind, Algorithm: "sha512", Value: value, Verification: "recorded"}) +} + +// nugetXML returns a strict decoder for bounded UTF-8 XML. A DOCTYPE is +// rejected outright: no entity or external resolution ever happens. +func nugetXML(data []byte) (*xml.Decoder, error) { + if !utf8.Valid(data) || int64(len(data)) > maxNuGetMetadataBytes { + return nil, errors.New("invalid metadata") + } + if bytes.Contains(bytes.ToUpper(data), []byte(" 0 && stack[len(stack)-1].conditional + fr := frame{name: t.Name.Local, conditional: conditional} + parent := "" + if len(stack) > 0 { + parent = stack[len(stack)-1].name + } + switch { + case len(stack) == 0 && t.Name.Local == "Project": + if sdk, ok := nugetAttr(t, "Sdk"); ok { + addSDK(sdk, false) + } + case t.Name.Local == "Sdk" && parent == "Project": + name, _ := nugetAttr(t, "Name") + if version, ok := nugetAttr(t, "Version"); ok && name != "" && version != "" { + f.sdks = append(f.sdks, nugetItem{kind: "msbuild_sdk", id: name, version: version, conditional: conditional}) + } + case t.Name.Local == "Import": + if sdk, ok := nugetAttr(t, "Sdk"); ok { + addSDK(sdk, conditional) + } else { + f.imports = true + } + case parent == "PropertyGroup" && slices.Contains(nugetProjectProperties, t.Name.Local): + var text string + if err := dec.DecodeElement(&text, &t); err != nil { + return nil, err + } + if conditional { + f.conditional[t.Name.Local] = true + } else { + f.properties[t.Name.Local] = strings.TrimSpace(text) + } + continue + case parent == "ItemGroup" && (t.Name.Local == "PackageReference" || t.Name.Local == "PackageVersion" || t.Name.Local == "GlobalPackageReference" || t.Name.Local == "PackageDownload"): + item := &nugetItem{kind: t.Name.Local, conditional: conditional} + item.id, _ = nugetAttr(t, "Include") + if item.id == "" { + item.id, _ = nugetAttr(t, "Update") + } + item.version, _ = nugetAttr(t, "Version") + item.versionOverride, _ = nugetAttr(t, "VersionOverride") + item.privateAssets, _ = nugetAttr(t, "PrivateAssets") + item.includeAssets, _ = nugetAttr(t, "IncludeAssets") + item.excludeAssets, _ = nugetAttr(t, "ExcludeAssets") + fr.item = item + case len(stack) > 0 && stack[len(stack)-1].item != nil: + item := stack[len(stack)-1].item + item.conditional = item.conditional || conditional + var text string + if err := dec.DecodeElement(&text, &t); err != nil { + return nil, err + } + text = strings.TrimSpace(text) + switch t.Name.Local { + case "Version": + item.version = text + case "VersionOverride": + item.versionOverride = text + case "PrivateAssets": + item.privateAssets = text + case "IncludeAssets": + item.includeAssets = text + case "ExcludeAssets": + item.excludeAssets = text + } + continue + } + stack = append(stack, fr) + case xml.CharData: + if len(stack) == 0 && len(bytes.TrimSpace(t)) != 0 { + return nil, errors.New("text outside Project root") + } + case xml.EndElement: + if len(stack) == 0 { + return nil, errors.New("unbalanced element") + } + top := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if top.item != nil && top.item.id != "" { + f.items = append(f.items, *top.item) + } + } + } + if !rootSeen { + return nil, errors.New("missing Project root") + } + return f, nil +} + +// nugetUnresolved reports an MSBuild value that needs evaluation. +func nugetUnresolved(value string) bool { + return strings.Contains(value, "$(") || strings.Contains(value, "@(") || strings.Contains(value, "%(") +} + +type nugetLegacyEntry struct { + id, version, targetFramework, allowedVersions string + developmentDependency *bool +} + +func parseNuGetPackagesConfig(data []byte) ([]nugetLegacyEntry, bool, error) { + dec, err := nugetXML(data) + if err != nil { + return nil, false, err + } + var doc struct { + XMLName xml.Name `xml:"packages"` + Packages []struct { + ID string `xml:"id,attr"` + Version string `xml:"version,attr"` + TargetFramework string `xml:"targetFramework,attr"` + AllowedVersions string `xml:"allowedVersions,attr"` + DevelopmentDependency string `xml:"developmentDependency,attr"` + } `xml:"package"` + } + if err := dec.Decode(&doc); err != nil { + return nil, false, err + } + var out []nugetLegacyEntry + partial := false + for _, p := range doc.Packages { + if _, ok := nugetID(p.ID); !ok { + partial = true + continue + } + e := nugetLegacyEntry{id: p.ID, version: p.Version, targetFramework: p.TargetFramework, allowedVersions: p.AllowedVersions} + switch strings.ToLower(p.DevelopmentDependency) { + case "true": + e.developmentDependency = new(bool) + *e.developmentDependency = true + case "false": + e.developmentDependency = new(bool) + } + out = append(out, e) + } + return out, partial, nil +} + +// parseNuGetNuspec reads the package identity; every other member is ignored. +func parseNuGetNuspec(data []byte) (id, version string, err error) { + if id, err = nugetXMLText(data, "package", "metadata", "id"); err != nil { + return "", "", err + } + if version, err = nugetXMLText(data, "package", "metadata", "version"); err != nil { + return "", "", err + } + if _, ok := nugetID(id); !ok || version == "" { + return "", "", errors.New("missing package identity") + } + return id, version, nil +} + +// nugetArchiveNuspec returns the single root .nuspec member of a .nupkg, read +// without extracting anything else. Unsafe member names reject the archive. +func nugetArchiveNuspec(data []byte) ([]byte, string) { + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return nil, "parse_error" + } + var nuspec *zip.File + seen := map[string]bool{} + for i, f := range zr.File { + if i >= maxGoZipEntries { + return nil, "entry_limit" + } + name := strings.TrimSuffix(f.Name, "/") + if name == "" || path.Clean(name) != name || strings.HasPrefix(name, "/") || strings.HasPrefix(name, "../") || strings.Contains(name, "\\") || seen[name] { + return nil, "parse_error" + } + seen[name] = true + if !strings.Contains(name, "/") && strings.HasSuffix(strings.ToLower(name), ".nuspec") { + if nuspec != nil { + return nil, "parse_error" + } + nuspec = f + } + } + if nuspec == nil { + return nil, "unsupported_format" + } + if nuspec.UncompressedSize64 > uint64(maxNuGetMetadataBytes) { // #nosec G115 -- the cap is a positive byte count + return nil, "size_limit" + } + rc, err := nuspec.Open() + if err != nil { + return nil, "parse_error" + } + defer rc.Close() + out, err := io.ReadAll(io.LimitReader(rc, maxNuGetMetadataBytes+1)) + if err != nil || int64(len(out)) > maxNuGetMetadataBytes { + return nil, "size_limit" + } + return out, "" +} + +// nugetObject decodes a bounded JSON object, isolating malformed members. +func nugetObject(data []byte) (map[string]json.RawMessage, error) { + if int64(len(data)) > maxNuGetMetadataBytes { + return nil, errors.New("invalid metadata") + } + return composerObject(bytes.TrimPrefix(data, []byte("\xef\xbb\xbf"))) +} + +func nugetString(raw json.RawMessage, limit int) (string, bool) { return composerString(raw, limit) } + +// nugetLibrary splits a "Id/Version" graph key. +func nugetLibrary(key string) (id, version string, ok bool) { + id, version, ok = strings.Cut(key, "/") + if !ok || version == "" { + return "", "", false + } + _, ok = nugetID(id) + return id, version, ok +} + +type nugetTargetPackage struct { + id, version string +} + +type nugetTarget struct { + alias, framework, rid string + packages []nugetTargetPackage +} + +type nugetAssets struct { + version int + libraries map[string]nugetAssetsLibrary // lowercase "id/version" + targets []nugetTarget + direct map[string]map[string]bool // alias → lowercase direct IDs + hasProject bool + folders []string // packageFolders, primary first + restore nugetRestore + partial bool +} + +type nugetAssetsLibrary struct { + id, version, sha512, path string +} + +type nugetRestore struct { + projectPath, packagesPath string + configFilePaths, fallback []string +} + +// parseNuGetAssets reads schema 3 and 4 restore graphs, including the +// minimal tool-store receipt whose libraries map is empty. +func parseNuGetAssets(data []byte) (*nugetAssets, error) { + obj, err := nugetObject(data) + if err != nil { + return nil, err + } + a := &nugetAssets{libraries: map[string]nugetAssetsLibrary{}, direct: map[string]map[string]bool{}} + if json.Unmarshal(obj["version"], &a.version) != nil || a.version < 3 || a.version > 4 { + return nil, errNuGetUnsupportedFormat + } + var libraries map[string]json.RawMessage + if obj["libraries"] == nil || json.Unmarshal(obj["libraries"], &libraries) != nil { + return nil, errNuGetUnsupportedFormat + } + for key, raw := range libraries { + id, version, ok := nugetLibrary(key) + entry, err := composerObject(raw) + if !ok || err != nil { + a.partial = true + continue + } + typ, _ := nugetString(entry["type"], 64) + if typ != "package" { + continue + } + lib := nugetAssetsLibrary{id: id, version: version} + var okHash, okPath bool + lib.sha512, okHash = nugetString(entry["sha512"], 256) + lib.path, okPath = nugetString(entry["path"], 4096) + if !okHash || !okPath { + a.partial = true + } + a.libraries[strings.ToLower(key)] = lib + } + frameworks := map[string]string{} + if project := obj["project"]; project != nil { + p, err := composerObject(project) + if err != nil { + a.partial = true + } else { + a.hasProject = true + a.parseRestore(p) + var fws map[string]json.RawMessage + if p["frameworks"] != nil && json.Unmarshal(p["frameworks"], &fws) == nil { + for alias, raw := range fws { + fw, err := composerObject(raw) + if err != nil { + a.partial = true + continue + } + if name, ok := nugetString(fw["framework"], 256); ok && name != "" { + frameworks[alias] = name + } else if ok { + frameworks[alias] = alias + } + if name, ok := nugetString(fw["targetAlias"], 256); ok && name != "" && name != alias { + a.partial = true + } + direct := map[string]bool{} + var deps map[string]json.RawMessage + if fw["dependencies"] != nil && json.Unmarshal(fw["dependencies"], &deps) == nil { + for id := range deps { + direct[strings.ToLower(id)] = true + } + } + a.direct[alias] = direct + } + } + } + } + var targets map[string]json.RawMessage + if obj["targets"] == nil || json.Unmarshal(obj["targets"], &targets) != nil { + return nil, errNuGetUnsupportedFormat + } + // Schema 3 keys targets by framework name rather than alias; map a unique + // name back to the project framework it belongs to. + byName := map[string]string{} + for alias, name := range frameworks { + if _, dup := byName[name]; dup { + byName[name] = "" + } else { + byName[name] = alias + } + } + for _, key := range slices.Sorted(maps.Keys(targets)) { + alias, rid, _ := strings.Cut(key, "/") + t := nugetTarget{alias: alias, framework: frameworks[alias], rid: rid} + if t.framework == "" { + t.framework = alias + } + if _, known := frameworks[alias]; !known && a.hasProject { + t.alias, t.framework = byName[alias], alias + } + var entries map[string]json.RawMessage + if json.Unmarshal(targets[key], &entries) != nil { + a.partial = true + continue + } + for _, name := range slices.Sorted(maps.Keys(entries)) { + id, version, ok := nugetLibrary(name) + entry, err := composerObject(entries[name]) + if !ok || err != nil { + a.partial = true + continue + } + if typ, _ := nugetString(entry["type"], 64); typ == "package" { + t.packages = append(t.packages, nugetTargetPackage{id: id, version: version}) + } + } + a.targets = append(a.targets, t) + } + if obj["packageFolders"] != nil { + var folders map[string]json.RawMessage + if json.Unmarshal(obj["packageFolders"], &folders) != nil { + a.partial = true + } else { + // Object order is the restore's probe order; the decoder keeps it. + dec := json.NewDecoder(bytes.NewReader(obj["packageFolders"])) + if tok, err := dec.Token(); err == nil && tok == json.Delim('{') { + for dec.More() { + key, err := dec.Token() + if err != nil { + break + } + var skip json.RawMessage + if dec.Decode(&skip) != nil { + break + } + if folder, ok := key.(string); ok && nugetText(folder, 4096) { + a.folders = append(a.folders, folder) + } + } + } + } + } + return a, nil +} + +func (a *nugetAssets) parseRestore(project map[string]json.RawMessage) { + if project["restore"] == nil { + return + } + r, err := composerObject(project["restore"]) + if err != nil { + a.partial = true + return + } + var ok bool + if a.restore.projectPath, ok = nugetString(r["projectPath"], 4096); !ok { + a.partial = true + } + if a.restore.packagesPath, ok = nugetString(r["packagesPath"], 4096); !ok { + a.partial = true + } + for _, field := range []struct { + key string + dest *[]string + }{{"configFilePaths", &a.restore.configFilePaths}, {"fallbackFolders", &a.restore.fallback}} { + if r[field.key] == nil { + continue + } + var list []string + if json.Unmarshal(r[field.key], &list) != nil { + a.partial = true + continue + } + for _, p := range list { + if nugetText(p, 4096) { + *field.dest = append(*field.dest, p) + } else { + a.partial = true + } + } + } +} + +type nugetLockEntry struct { + id, requested, resolved, typ, contentHash, alias, framework string +} + +// parseNuGetLock reads packages.lock.json versions 1-3. Project entries are +// not packages and are skipped. +func parseNuGetLock(data []byte) ([]nugetLockEntry, bool, error) { + obj, err := nugetObject(data) + if err != nil { + return nil, false, err + } + var version int + if json.Unmarshal(obj["version"], &version) != nil || version < 1 || version > 3 { + return nil, false, errNuGetUnsupportedFormat + } + type group struct { + alias, framework string + deps json.RawMessage + } + var groups []group + partial := false + if version == 3 { + for _, alias := range slices.Sorted(maps.Keys(obj)) { + if alias == "version" { + continue + } + g, err := composerObject(obj[alias]) + if err != nil { + partial = true + continue + } + framework, _ := nugetString(g["framework"], 256) + groups = append(groups, group{alias: alias, framework: framework, deps: g["dependencies"]}) + } + } else { + var frameworks map[string]json.RawMessage + if obj["dependencies"] == nil || json.Unmarshal(obj["dependencies"], &frameworks) != nil { + return nil, false, errNuGetUnsupportedFormat + } + for _, framework := range slices.Sorted(maps.Keys(frameworks)) { + groups = append(groups, group{framework: framework, deps: frameworks[framework]}) + } + } + var out []nugetLockEntry + for _, g := range groups { + var deps map[string]json.RawMessage + if g.deps == nil || json.Unmarshal(g.deps, &deps) != nil { + partial = true + continue + } + for _, id := range slices.Sorted(maps.Keys(deps)) { + entry, err := composerObject(deps[id]) + if _, ok := nugetID(id); !ok || err != nil { + partial = true + continue + } + typ, _ := nugetString(entry["type"], 64) + if typ == "Project" { + continue + } + e := nugetLockEntry{id: id, typ: typ, alias: g.alias, framework: g.framework} + var okReq, okRes, okHash bool + e.requested, okReq = nugetString(entry["requested"], 256) + e.resolved, okRes = nugetString(entry["resolved"], 256) + e.contentHash, okHash = nugetString(entry["contentHash"], 256) + if !okReq || !okRes || !okHash || e.resolved == "" || (typ != "Direct" && typ != "Transitive" && typ != "CentralTransitive") { + partial = true + continue + } + out = append(out, e) + } + } + return out, partial, nil +} + +// parseNuGetMarker reads .nupkg.metadata versions 1 and 2; only v2 records +// the download source. +func parseNuGetMarker(data []byte) (contentHash, source string, err error) { + obj, err := nugetObject(data) + if err != nil { + return "", "", err + } + var version int + if json.Unmarshal(obj["version"], &version) != nil || version < 1 || version > 2 { + return "", "", errNuGetUnsupportedFormat + } + var ok bool + if contentHash, ok = nugetString(obj["contentHash"], 256); !ok || contentHash == "" { + return "", "", errors.New("missing content hash") + } + if source, ok = nugetString(obj["source"], 4096); !ok { + return "", "", errors.New("invalid source") + } + return contentHash, source, nil +} + +type nugetToolEntry struct { + id, version string + commands []string +} + +func parseNuGetToolManifest(data []byte) ([]nugetToolEntry, bool, error) { + obj, err := nugetObject(data) + if err != nil { + return nil, false, err + } + var version int + if json.Unmarshal(obj["version"], &version) != nil || version != 1 { + return nil, false, errNuGetUnsupportedFormat + } + var tools map[string]json.RawMessage + if obj["tools"] == nil || json.Unmarshal(obj["tools"], &tools) != nil { + return nil, false, errNuGetUnsupportedFormat + } + var out []nugetToolEntry + partial := false + for _, id := range slices.Sorted(maps.Keys(tools)) { + entry, err := composerObject(tools[id]) + if _, ok := nugetID(id); !ok || err != nil { + partial = true + continue + } + e := nugetToolEntry{id: id} + var ok bool + if e.version, ok = nugetString(entry["version"], 256); !ok || e.version == "" { + partial = true + continue + } + if e.commands, ok = nugetCommands(entry["commands"]); !ok { + partial = true + } + out = append(out, e) + } + return out, partial, nil +} + +// nugetCommands bounds tool command names: they are names, never arguments. +func nugetCommands(raw json.RawMessage) ([]string, bool) { + var names []string + if raw == nil || json.Unmarshal(raw, &names) != nil || len(names) > 64 { + return nil, false + } + for _, n := range names { + if n == "" || !nugetText(n, 256) || strings.ContainsAny(n, "/\\ \t") { + return nil, false + } + } + return names, true +} + +type nugetResolverEntry struct { + version, framework, rid, name, executable string +} + +func parseNuGetResolverCache(data []byte) ([]nugetResolverEntry, bool, error) { + if int64(len(data)) > maxNuGetMetadataBytes || !utf8.Valid(data) { + return nil, false, errors.New("invalid metadata") + } + var entries []json.RawMessage + if json.Unmarshal(data, &entries) != nil { + return nil, false, errNuGetUnsupportedFormat + } + var out []nugetResolverEntry + partial := false + for _, raw := range entries { + obj, err := composerObject(raw) + if err != nil { + partial = true + continue + } + e := nugetResolverEntry{} + ok := true + for _, field := range []struct { + key string + dest *string + limit int + }{{"Version", &e.version, 256}, {"TargetFramework", &e.framework, 256}, {"RuntimeIdentifier", &e.rid, 256}, {"Name", &e.name, 256}, {"PathToExecutable", &e.executable, 4096}} { + var valid bool + *field.dest, valid = nugetString(obj[field.key], field.limit) + ok = ok && valid + } + if !ok || e.version == "" || e.name == "" { + partial = true + continue + } + out = append(out, e) + } + return out, partial, nil +} + +// parseNuGetToolSettings reads command names from DotnetToolSettings.xml. +func parseNuGetToolSettings(data []byte) ([]string, error) { + dec, err := nugetXML(data) + if err != nil { + return nil, err + } + var doc struct { + XMLName xml.Name `xml:"DotNetCliTool"` + Commands []struct { + Name string `xml:"Name,attr"` + } `xml:"Commands>Command"` + } + if err := dec.Decode(&doc); err != nil { + return nil, err + } + var names []string + for _, c := range doc.Commands { + if c.Name != "" && nugetText(c.Name, 256) && !strings.ContainsAny(c.Name, "/\\ \t") { + names = append(names, c.Name) + } + } + return names, nil +} + +type nugetTemplateEntry struct { + id, version, mount, source string +} + +func parseNuGetTemplates(data []byte) ([]nugetTemplateEntry, bool, error) { + obj, err := nugetObject(data) + if err != nil { + return nil, false, err + } + var packages []json.RawMessage + if obj["Packages"] == nil || json.Unmarshal(obj["Packages"], &packages) != nil { + return nil, false, errNuGetUnsupportedFormat + } + var out []nugetTemplateEntry + partial := false + for _, raw := range packages { + entry, err := composerObject(raw) + if err != nil { + partial = true + continue + } + details, err := composerObject(entry["Details"]) + if err != nil { + // Directory-mounted templates have no NuGet identity. + continue + } + e := nugetTemplateEntry{} + var okID, okVer, okMount, okSource bool + e.id, okID = nugetString(details["PackageId"], 100) + e.version, okVer = nugetString(details["Version"], 256) + e.mount, okMount = nugetString(entry["MountPointUri"], 4096) + e.source, okSource = nugetString(details["NuGetSource"], 4096) + if _, valid := nugetID(e.id); e.id == "" || e.version == "" { + continue + } else if !valid || !okID || !okVer || !okMount || !okSource { + partial = true + continue + } + out = append(out, e) + } + return out, partial, nil +} + +type nugetDepsEntry struct { + id, version, sha512, framework, rid string +} + +// parseNuGetDeps reads package libraries from a runtime *.deps.json. +func parseNuGetDeps(data []byte) ([]nugetDepsEntry, bool, error) { + obj, err := nugetObject(data) + if err != nil { + return nil, false, err + } + var libraries map[string]json.RawMessage + if obj["libraries"] == nil || obj["targets"] == nil || json.Unmarshal(obj["libraries"], &libraries) != nil { + return nil, false, errNuGetUnsupportedFormat + } + framework, rid := "", "" + if obj["runtimeTarget"] != nil { + if rt, err := composerObject(obj["runtimeTarget"]); err == nil { + name, _ := nugetString(rt["name"], 256) + framework, rid, _ = strings.Cut(name, "/") + } + } + var out []nugetDepsEntry + partial := false + for _, key := range slices.Sorted(maps.Keys(libraries)) { + id, version, ok := nugetLibrary(key) + entry, err := composerObject(libraries[key]) + if !ok || err != nil { + partial = true + continue + } + if typ, _ := nugetString(entry["type"], 64); typ != "package" { + continue + } + e := nugetDepsEntry{id: id, version: version, framework: framework, rid: rid} + hash, okHash := nugetString(entry["sha512"], 256) + if !okHash { + partial = true + } + e.sha512 = strings.TrimPrefix(hash, "sha512-") + out = append(out, e) + } + return out, partial, nil +} + +type nugetDirective struct { + kind, id, version string // kind: package | sdk +} + +// parseNuGetCSharpDirectives reads #:package and #:sdk directives from the +// leading trivia of a file-based app; directives after code are ignored. +func parseNuGetCSharpDirectives(data []byte) []nugetDirective { + if !utf8.Valid(data) { + return nil + } + data = bytes.TrimPrefix(data, []byte("\xef\xbb\xbf")) + var out []nugetDirective + inComment := false + for line := range strings.SplitSeq(string(data), "\n") { + line = strings.TrimSpace(line) + if inComment { + if _, after, ok := strings.Cut(line, "*/"); ok { + inComment, line = false, strings.TrimSpace(after) + } else { + continue + } + } + switch { + case line == "", strings.HasPrefix(line, "//"), strings.HasPrefix(line, "#!"): + continue + case strings.HasPrefix(line, "/*"): + if !strings.Contains(line, "*/") { + inComment = true + } + continue + case strings.HasPrefix(line, "#:"): + kind, rest, _ := strings.Cut(line[2:], " ") + if kind != "package" && kind != "sdk" { + continue + } + id, version, _ := strings.Cut(strings.TrimSpace(rest), "@") + version = strings.TrimSpace(version) + if kind == "sdk" && version == "" { + continue // a built-in SDK name is not a package + } + if _, ok := nugetID(id); ok { + out = append(out, nugetDirective{kind: kind, id: id, version: version}) + } + default: + return out + } + } + return out +} + +var nugetFSharpReference = regexp.MustCompile(`(?m)^\s*#r\s+"nuget:\s*([^",]+?)\s*(?:,\s*([^"]*?)\s*)?"`) + +// parseNuGetFSharpDirectives reads literal #r "nuget: ID[, version]" lines. +// #i source directives and #if branches are not evaluated. +func parseNuGetFSharpDirectives(data []byte) []nugetDirective { + if !utf8.Valid(data) { + return nil + } + data = bytes.TrimPrefix(data, []byte("\xef\xbb\xbf")) + var out []nugetDirective + for _, m := range nugetFSharpReference.FindAllStringSubmatch(string(data), 1000) { + if _, ok := nugetID(m[1]); ok { + out = append(out, nugetDirective{kind: "package", id: m[1], version: m[2]}) + } + } + return out +} diff --git a/internal/detector/nugetmetadata_test.go b/internal/detector/nugetmetadata_test.go new file mode 100644 index 00000000..b7aeb742 --- /dev/null +++ b/internal/detector/nugetmetadata_test.go @@ -0,0 +1,319 @@ +package detector + +import ( + "archive/zip" + "bytes" + "errors" + "reflect" + "strings" + "testing" +) + +func TestNuGetNormalizeVersion(t *testing.T) { + tests := []struct { + raw, want string + ok bool + }{ + {"1.2.3", "1.2.3", true}, + {"01.02.03", "1.2.3", true}, + {"1.2", "1.2.0", true}, + {"1", "1.0.0", true}, + {"1.2.0.0", "1.2.0", true}, + {"1.2.0.4", "1.2.0.4", true}, + {"1.2.3+build.7", "1.2.3", true}, + {"1.2.3-BETA", "1.2.3-beta", true}, + {"1.2.3-alpha01", "1.2.3-alpha01", true}, + {"1.2.3-rc.1.x-y", "1.2.3-rc.1.x-y", true}, + {"1.2.3-01", "", false}, + {"1.2.3-", "", false}, + {"1.2.3.4.5", "", false}, + {"1.2.3-beta+", "1.2.3-beta", true}, + {"[1.2.3]", "", false}, + {"1.*", "", false}, + {"", "", false}, + {"1..2", "", false}, + {"1.2.3-be ta", "", false}, + {"1.2.3-be_ta", "", false}, + } + for _, tc := range tests { + t.Run(tc.raw, func(t *testing.T) { + got, ok := nugetNormalizeVersion(tc.raw) + if got != tc.want || ok != tc.ok { + t.Errorf("nugetNormalizeVersion(%q) = %q, %v, want %q, %v", tc.raw, got, ok, tc.want, tc.ok) + } + }) + } +} + +func TestNuGetID(t *testing.T) { + tests := []struct { + raw, want string + ok bool + }{ + {"Newtonsoft.Json", "newtonsoft.json", true}, + {"My-Lib_2", "my-lib_2", true}, + {"a", "a", true}, + {".hidden", "", false}, + {"bad..id", "", false}, + {"bad/id", "", false}, + {"", "", false}, + {strings.Repeat("a", 101), "", false}, + } + for _, tc := range tests { + t.Run(tc.raw, func(t *testing.T) { + got, ok := nugetID(tc.raw) + if got != tc.want || ok != tc.ok { + t.Errorf("nugetID(%q) = %q, %v, want %q, %v", tc.raw, got, ok, tc.want, tc.ok) + } + }) + } +} + +func TestNuGetProjectParser(t *testing.T) { + data := []byte(` + + + + net8.0;net9.0 + false + C:\packages + + + + 3.*runtime + + + + + + + + + +`) + f, err := parseNuGetProject(data) + if err != nil { + t.Fatal(err) + } + if !f.imports || f.properties["TargetFrameworks"] != "net8.0;net9.0" || f.properties["NuGetAudit"] != "false" || !f.conditional["RestorePackagesPath"] || f.properties["RestorePackagesPath"] != "" { + t.Errorf("properties = %+v conditional = %+v imports = %v", f.properties, f.conditional, f.imports) + } + wantSDKs := []nugetItem{{kind: "msbuild_sdk", id: "Example.Sdk", version: "1.2.3"}, {kind: "msbuild_sdk", id: "Other.Sdk", version: "2.0.0"}, {kind: "msbuild_sdk", id: "Imported.Sdk", version: "3.0.0"}} + if !reflect.DeepEqual(f.sdks, wantSDKs) { + t.Errorf("sdks = %+v", f.sdks) + } + wantItems := []nugetItem{ + {kind: "PackageReference", id: "Newtonsoft.Json", version: "13.0.1", privateAssets: "all"}, + {kind: "PackageReference", id: "Serilog", version: "3.*", includeAssets: "runtime", conditional: true}, + {kind: "PackageReference", id: "Central", versionOverride: "2.0.0"}, + {kind: "PackageVersion", id: "Central", version: "1.0.0"}, + {kind: "PackageDownload", id: "Downloaded", version: "[1.0.0]"}, + {kind: "PackageReference", id: "Updated"}, + {kind: "PackageReference", id: "Conditional", version: "1.0.0", conditional: true}, + } + if !reflect.DeepEqual(f.items, wantItems) { + t.Errorf("items = %+v", f.items) + } + for name, bad := range map[string]string{ + "doctype": `]>&x;`, + "unbalanced": ``, + "empty": "", + "wrong root": ``, + "multiple roots": ``, + "outside text": `text`, + "binary": "\xff\xfe", + } { + if _, err := parseNuGetProject([]byte(bad)); err == nil { + t.Errorf("%s accepted", name) + } + } +} + +func TestNuGetLegacyAndNuspecParsers(t *testing.T) { + entries, partial, err := parseNuGetPackagesConfig([]byte(` + + + +`)) + if err != nil || !partial || len(entries) != 2 { + t.Fatalf("entries = %+v partial = %v err = %v", entries, partial, err) + } + if e := entries[0]; e.id != "Newtonsoft.Json" || e.version != "13.0.1" || e.targetFramework != "net472" || e.allowedVersions != "[13,14)" || e.developmentDependency == nil || !*e.developmentDependency { + t.Errorf("entry = %+v", e) + } + if entries[1].developmentDependency != nil { + t.Error("absent developmentDependency should stay nil") + } + nuspec := []byte(`Newtonsoft.Json13.0.1`) + id, version, err := parseNuGetNuspec(nuspec) + if err != nil || id != "Newtonsoft.Json" || version != "13.0.1" { + t.Fatalf("nuspec = %q %q %v", id, version, err) + } + if _, _, err := parseNuGetNuspec([]byte(`Only`)); err == nil { + t.Error("nuspec without version accepted") + } + if _, _, err := parseNuGetNuspec([]byte(`X1`)); err == nil { + t.Error("DOCTYPE accepted") + } + archive := func(names ...string) []byte { + var buf bytes.Buffer + w := zip.NewWriter(&buf) + for _, name := range names { + f, err := w.Create(name) + if err != nil { + t.Fatal(err) + } + if strings.HasSuffix(name, ".nuspec") { + _, _ = f.Write(nuspec) + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() + } + tests := []struct { + name string + data []byte + reason string + }{ + {"good", archive("Newtonsoft.Json.nuspec", "lib/net6.0/Newtonsoft.Json.dll", "[Content_Types].xml"), ""}, + {"nested nuspec only", archive("lib/Newtonsoft.Json.nuspec"), "unsupported_format"}, + {"two nuspecs", archive("A.nuspec", "B.nuspec"), "parse_error"}, + {"traversal", archive("Newtonsoft.Json.nuspec", "../evil"), "parse_error"}, + {"backslash", archive("Newtonsoft.Json.nuspec", `lib\x.dll`), "parse_error"}, + {"not a zip", []byte("PK garbage"), "parse_error"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + data, reason := nugetArchiveNuspec(tc.data) + if reason != tc.reason { + t.Fatalf("reason = %q, want %q", reason, tc.reason) + } + if reason == "" && !bytes.Equal(data, nuspec) { + t.Error("nuspec member not returned") + } + }) + } +} + +func TestNuGetAssetsParser(t *testing.T) { + v3 := []byte(`{"version":3,"targets":{".NETCoreApp,Version=v8.0":{"Newtonsoft.Json/13.0.1":{"type":"package"},"App/1.0.0":{"type":"project"}},".NETCoreApp,Version=v8.0/win-x64":{"Newtonsoft.Json/13.0.1":{"type":"package"}}},"libraries":{"Newtonsoft.Json/13.0.1":{"sha512":"abc","type":"package","path":"newtonsoft.json/13.0.1"},"App/1.0.0":{"type":"project"}},"packageFolders":{"/home/dev/.nuget/packages/":{},"/usr/share/fallback/":{}},"project":{"restore":{"projectPath":"/home/dev/app/App.csproj","packagesPath":"/home/dev/.nuget/packages/","configFilePaths":["/home/dev/app/NuGet.Config","/home/dev/.nuget/NuGet/NuGet.Config"],"fallbackFolders":["/usr/share/fallback/"]},"frameworks":{"net8.0":{"dependencies":{"Newtonsoft.Json":{"target":"Package","version":"[13.0.1, )"}}}}}}`) + a, err := parseNuGetAssets(v3) + if err != nil { + t.Fatal(err) + } + if a.version != 3 || !a.hasProject || a.partial || len(a.libraries) != 1 || a.libraries["newtonsoft.json/13.0.1"].sha512 != "abc" { + t.Errorf("assets = %+v", a) + } + if !reflect.DeepEqual(a.folders, []string{"/home/dev/.nuget/packages/", "/usr/share/fallback/"}) || a.restore.projectPath != "/home/dev/app/App.csproj" || len(a.restore.configFilePaths) != 2 { + t.Errorf("folders = %v restore = %+v", a.folders, a.restore) + } + // Without a recorded framework name the schema 3 target cannot be tied to + // the alias, so its membership stays unresolved. + if len(a.targets) != 2 || a.targets[0].alias != "" || a.targets[0].framework != ".NETCoreApp,Version=v8.0" || a.targets[0].rid != "" || a.targets[1].rid != "win-x64" || len(a.targets[0].packages) != 1 { + t.Errorf("targets = %+v", a.targets) + } + if !a.direct["net8.0"]["newtonsoft.json"] { + t.Error("direct dependency missing") + } + // Ordinary schema 3 output keys targets by framework name while the project + // records the alias; the unique name maps back so direct lookups work. + named := []byte(`{"version":3,"targets":{".NETCoreApp,Version=v8.0":{"Newtonsoft.Json/13.0.1":{"type":"package"}},".NETFramework,Version=v4.7.2":{}},"libraries":{"Newtonsoft.Json/13.0.1":{"sha512":"abc","type":"package","path":"newtonsoft.json/13.0.1"}},"project":{"frameworks":{"net8.0":{"targetAlias":"net8.0","framework":".NETCoreApp,Version=v8.0","dependencies":{"Newtonsoft.Json":{"version":"[13.0.1, )"}}},"net472":{"targetAlias":"net472","framework":".NETFramework,Version=v4.7.2"},"net472-dup":{"targetAlias":"net472-dup","framework":".NETFramework,Version=v4.7.2"}}}}`) + if a, err = parseNuGetAssets(named); err != nil || len(a.targets) != 2 { + t.Fatalf("named = %+v %v", a, err) + } + if a.targets[0].alias != "net8.0" || a.targets[0].framework != ".NETCoreApp,Version=v8.0" || !a.direct[a.targets[0].alias]["newtonsoft.json"] || a.targets[1].alias != "" { + t.Errorf("named targets = %+v", a.targets) + } + v4 := []byte(`{"version":4,"targets":{"net8.0":{"Serilog/3.1.1":{"type":"package"}},"net8.0/linux-x64":{}},"libraries":{"Serilog/3.1.1":{"sha512":"def","type":"package","path":"serilog/3.1.1"}},"project":{"frameworks":{"net8.0":{"targetAlias":"net8.0","framework":".NETCoreApp,Version=v8.0","dependencies":{"Serilog":{"version":"[3.1.1, )"}}}}}}`) + if a, err = parseNuGetAssets(v4); err != nil || a.version != 4 || a.partial { + t.Fatalf("v4 = %+v %v", a, err) + } + if a.targets[0].alias != "net8.0" || a.targets[0].framework != ".NETCoreApp,Version=v8.0" || a.targets[1].rid != "linux-x64" || !a.direct["net8.0"]["serilog"] { + t.Errorf("v4 targets = %+v", a.targets) + } + tool := []byte(`{"version":3,"targets":{".NETCoreApp,Version=v8.0/any":{"dotnet-format/8.0.0":{"type":"package"}}},"libraries":{"dotnet-format/8.0.0":{"sha512":"ghi","type":"package","path":"dotnet-format/8.0.0"}},"packageFolders":{"/home/dev/.dotnet/tools/.store":{}}}`) + if a, err = parseNuGetAssets(tool); err != nil || a.hasProject || a.targets[0].rid != "any" || a.targets[0].framework != ".NETCoreApp,Version=v8.0" { + t.Errorf("tool receipt = %+v %v", a, err) + } + for name, bad := range map[string]string{"version 2": `{"version":2,"targets":{},"libraries":{}}`, "no libraries": `{"version":3,"targets":{}}`, "not an object": `[]`} { + if _, err := parseNuGetAssets([]byte(bad)); err == nil { + t.Errorf("%s accepted", name) + } + } +} + +func TestNuGetLockAndMarkerParsers(t *testing.T) { + v1 := []byte(`{"version":1,"dependencies":{"net8.0":{"Newtonsoft.Json":{"type":"Direct","requested":"[13.0.1, )","resolved":"13.0.1","contentHash":"abc=="},"System.Text.Json":{"type":"Transitive","resolved":"8.0.0","contentHash":"def=="},"App":{"type":"Project"},"Broken":{"type":"Direct","resolved":"","contentHash":""}}}}`) + entries, partial, err := parseNuGetLock(v1) + if err != nil || !partial || len(entries) != 2 { + t.Fatalf("v1 = %+v %v %v", entries, partial, err) + } + if e := entries[0]; e.id != "Newtonsoft.Json" || e.typ != "Direct" || e.requested != "[13.0.1, )" || e.resolved != "13.0.1" || e.contentHash != "abc==" || e.framework != "net8.0" || e.alias != "" { + t.Errorf("v1 entry = %+v", e) + } + v3 := []byte(`{"version":3,"net8.0":{"framework":".NETCoreApp,Version=v8.0","dependencies":{"Serilog":{"type":"CentralTransitive","requested":"[3.1.1, )","resolved":"3.1.1","contentHash":"ghi=="}}}}`) + if entries, partial, err = parseNuGetLock(v3); err != nil || partial || len(entries) != 1 || entries[0].alias != "net8.0" || entries[0].framework != ".NETCoreApp,Version=v8.0" || entries[0].typ != "CentralTransitive" { + t.Errorf("v3 = %+v %v %v", entries, partial, err) + } + if _, _, err := parseNuGetLock([]byte(`{"version":4}`)); !errors.Is(err, errNuGetUnsupportedFormat) { + t.Errorf("v4 err = %v", err) + } + hash, source, err := parseNuGetMarker([]byte(`{"version":2,"contentHash":"abc==","source":"https://api.nuget.org/v3/index.json"}`)) + if err != nil || hash != "abc==" || source != "https://api.nuget.org/v3/index.json" { + t.Errorf("marker v2 = %q %q %v", hash, source, err) + } + if hash, source, err = parseNuGetMarker([]byte(`{"version":1,"contentHash":"abc=="}`)); err != nil || hash != "abc==" || source != "" { + t.Errorf("marker v1 = %q %q %v", hash, source, err) + } + if _, _, err := parseNuGetMarker([]byte(`{"version":3,"contentHash":"x"}`)); err == nil { + t.Error("marker v3 accepted") + } +} + +func TestNuGetToolTemplateAndDepsParsers(t *testing.T) { + tools, partial, err := parseNuGetToolManifest([]byte(`{"version":1,"isRoot":true,"tools":{"dotnet-format":{"version":"8.0.0","commands":["dotnet-format"]},"bad/id":{"version":"1.0.0","commands":[]},"noversion":{"commands":["x"]}}}`)) + if err != nil || !partial || len(tools) != 1 || tools[0].id != "dotnet-format" || tools[0].version != "8.0.0" || !reflect.DeepEqual(tools[0].commands, []string{"dotnet-format"}) { + t.Errorf("tools = %+v %v %v", tools, partial, err) + } + if _, _, err := parseNuGetToolManifest([]byte(`{"version":2,"tools":{}}`)); !errors.Is(err, errNuGetUnsupportedFormat) { + t.Errorf("manifest v2 err = %v", err) + } + cache, partial, err := parseNuGetResolverCache([]byte(`[{"Version":"8.0.0","TargetFramework":"net8.0","RuntimeIdentifier":"any","Name":"dotnet-format","PathToExecutable":"/home/dev/.nuget/packages/dotnet-format/8.0.0/tools/net8.0/any/dotnet-format.dll"},{"Name":"nover"}]`)) + if err != nil || !partial || len(cache) != 1 || cache[0].executable == "" || cache[0].rid != "any" { + t.Errorf("resolver = %+v %v %v", cache, partial, err) + } + commands, err := parseNuGetToolSettings([]byte(``)) + if err != nil || !reflect.DeepEqual(commands, []string{"dotnet-format"}) { + t.Errorf("commands = %v %v", commands, err) + } + for _, prefix := range []string{"", "\xef\xbb\xbf"} { + templates, partial, err := parseNuGetTemplates([]byte(prefix + `{"Packages":[{"MountPointUri":"/home/dev/.templateengine/packages/Example.Templates.1.0.0.nupkg","Details":{"PackageId":"Example.Templates","Version":"1.0.0","NuGetSource":"https://api.nuget.org/v3/index.json"}},{"MountPointUri":"/opt/templates","Details":{"Author":"dir"}},{"MountPointUri":"/x","Details":{"PackageId":"bad/id","Version":"1"}}]}`)) + if err != nil || !partial || len(templates) != 1 || templates[0].id != "Example.Templates" || templates[0].source == "" { + t.Errorf("templates = %+v %v %v", templates, partial, err) + } + } + deps, partial, err := parseNuGetDeps([]byte(`{"runtimeTarget":{"name":".NETCoreApp,Version=v8.0/linux-x64"},"targets":{},"libraries":{"App/1.0.0":{"type":"project"},"Newtonsoft.Json/13.0.1":{"type":"package","sha512":"sha512-abc==","path":"newtonsoft.json/13.0.1"},"bad":{"type":"package"}}}`)) + if err != nil || !partial || len(deps) != 1 || deps[0].sha512 != "abc==" || deps[0].framework != ".NETCoreApp,Version=v8.0" || deps[0].rid != "linux-x64" { + t.Errorf("deps = %+v %v %v", deps, partial, err) + } +} + +func TestNuGetScriptDirectives(t *testing.T) { + // A built-in SDK name is not a package; only a versioned #:sdk is a declaration. + cs := []byte("#!/usr/bin/env dotnet\n// comment\n/* block\n comment */ #:sdk Microsoft.NET.Sdk.Web\n#:sdk Aspire.AppHost.Sdk@9.0.0\n#:package Humanizer@2.14.1\n#:package NoVersion\n#:property LangVersion=preview\nusing System;\n#:package Late@1.0.0\n") + want := []nugetDirective{{kind: "sdk", id: "Aspire.AppHost.Sdk", version: "9.0.0"}, {kind: "package", id: "Humanizer", version: "2.14.1"}, {kind: "package", id: "NoVersion"}} + for _, prefix := range []string{"", "\xef\xbb\xbf"} { + if got := parseNuGetCSharpDirectives(append([]byte(prefix), cs...)); !reflect.DeepEqual(got, want) { + t.Errorf("csharp prefix %q = %+v", prefix, got) + } + } + fsx := []byte("#r \"nuget: FSharp.Data, 6.3.0\"\n#r \"nuget:Newtonsoft.Json\"\n#r \"nuget: bad/id\"\n#i \"nuget: https://private.example.invalid/v3/index.json\"\nlet x = 1\n") + want = []nugetDirective{{kind: "package", id: "FSharp.Data", version: "6.3.0"}, {kind: "package", id: "Newtonsoft.Json"}} + for _, prefix := range []string{"", "\xef\xbb\xbf"} { + if got := parseNuGetFSharpDirectives(append([]byte(prefix), fsx...)); !reflect.DeepEqual(got, want) { + t.Errorf("fsharp prefix %q = %+v", prefix, got) + } + } +} diff --git a/internal/detector/nugetscan.go b/internal/detector/nugetscan.go new file mode 100644 index 00000000..59f8f83f --- /dev/null +++ b/internal/detector/nugetscan.go @@ -0,0 +1,1779 @@ +package detector + +import ( + "cmp" + "context" + "encoding/json" + "errors" + "io/fs" + "maps" + "os/user" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/detector/configaudit" + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" + "github.com/step-security/dev-machine-guard/internal/progress" +) + +var ( + maxNuGetWalkEntries = 100_000 + maxNuGetWalkDepth = 128 + maxNuGetRecords = 50_000 + maxNuGetOutputBytes = 16 << 20 + maxNuGetScriptReads = 2_000 // source files probed for package directives per root +) + +// NuGetScanner reads metadata only: no dotnet, nuget, MSBuild or credential +// provider ever runs. The raw executor is required because a UserAwareExecutor +// may source a login shell when reading environment values. +type NuGetScanner struct { + exec executor.Executor + log *progress.Logger + protection func(string, *bool) (func(string) string, func(string) bool) +} + +func NewNuGetScanner(exec executor.Executor, log *progress.Logger) *NuGetScanner { + if log == nil { + log = progress.NewNoop() + } + return &NuGetScanner{exec: exec, log: log, protection: configaudit.GoProtection} +} + +type nugetFound struct{ path, root string } + +type nugetProjectState struct { + src *model.NuGetSource + file *nugetProjectFile + status string + project model.NuGetProject +} + +// nugetImportState is a Directory.*.props/targets file read once and shared +// by every project below it. +type nugetImportState struct { + src *model.NuGetSource + file *nugetProjectFile + record *model.NuGetConfigFile + versions map[string]string // lowercase ID → PackageVersion (central only) + deferred bool // carries declarations this reader leaves unparsed +} + +// nugetLayer is one MSBuild file in import order; dir anchors +// $(MSBuildThisFileDirectory) while every other path belongs to the consumer. +type nugetLayer struct { + dir string + file *nugetProjectFile +} + +// nugetPathProperties take effect in the consuming project's directory no +// matter which imported file declared them. +var nugetPathProperties = []string{"RestorePackagesPath", "NuGetLockFilePath", "RestoreFallbackFolders", "RestoreAdditionalProjectFallbackFolders"} + +// nugetImports are the MSBuild directory files discovery records by name. +var nugetImports = []string{"directory.build.props", "directory.packages.props", "directory.build.targets"} + +type nugetAssetsState struct { + src *model.NuGetSource + assets *nugetAssets + project *nugetProjectState +} + +// nugetCached is what a cache directory recorded about one extracted package. +type nugetCached struct{ contentHash, source string } + +type nugetScan struct { + ctx context.Context + exec executor.Executor + home, identity, goos string + roots []string + guard func(string) string + volume func(string) bool + files executor.Executor + config *configaudit.NuGetConfigSnapshot + inv model.NuGetInventory + sources []*model.NuGetSource + byID map[string]*model.NuGetSource + projects map[string]*nugetProjectState // key(manifest path) + projectDirs map[string]string // key(dir) → dir + importPaths map[string]string // key(dir)+name → Directory.*.props/targets + imports map[string]*nugetImportState // key(path) + assets []*nugetAssetsState + legacy, deps, tools []nugetFound + scripts []nugetFound + stores map[string]string // key(.store dir) → path + caches map[string]bool // pruned from discovery + cacheRoots []string + cachesScanned int // prefix of cacheRoots already read + cacheScope map[string]string // key(root) → user | custom + cacheDone map[string]bool // root listed completely + index map[string]nugetCached + resolver map[string][]nugetResolverEntry + remaining int + bytes int + limited bool +} + +func emptyNuGetInventory() model.NuGetInventory { + return model.NuGetInventory{SchemaVersion: model.NuGetInventorySchemaVersion, Status: "complete", Reasons: []string{}, Sources: []model.NuGetSource{}, Projects: []model.NuGetProject{}, Packages: []model.NuGetPackage{}} +} + +func (s *NuGetScanner) Scan(ctx context.Context, target *user.User, searchDirs []string, includeNetworkVolumes *bool) (*model.NuGetInventory, *model.NuGetConfigAudit) { + start := time.Now() + inv := emptyNuGetInventory() + configDetector := configaudit.NewNuGetConfigDetector(s.exec) + if target == nil || target.Username == "" || target.HomeDir == "" || !filepath.IsAbs(target.HomeDir) || isGoServiceIdentity(s.exec.GOOS(), target) { + inv.Status, inv.Reasons = "partial", []string{"user_unresolved"} + audit := configDetector.Detect(ctx, configaudit.NuGetConfigScope{}).Finish() + return &inv, &audit + } + guard, volume := s.protection(target.HomeDir, includeNetworkVolumes) + // Reserve the audit's separate 256 KiB allowance before collecting inventory. + c := &nugetScan{ctx: ctx, exec: s.exec, home: filepath.Clean(target.HomeDir), identity: target.Username, goos: s.exec.GOOS(), guard: guard, volume: volume, inv: inv, + byID: map[string]*model.NuGetSource{}, projects: map[string]*nugetProjectState{}, projectDirs: map[string]string{}, importPaths: map[string]string{}, imports: map[string]*nugetImportState{}, + stores: map[string]string{}, caches: map[string]bool{}, cacheScope: map[string]string{}, cacheDone: map[string]bool{}, index: map[string]nugetCached{}, resolver: map[string][]nugetResolverEntry{}, remaining: maxNuGetRecords, bytes: goJSONSize(&inv) + (256 << 10)} + c.roots = []string{c.home} + dirs := []string{} + for _, dir := range searchDirs { + if dir == "" { + continue + } + dir = canonicalNoStat(dir, c.home) + if !filepath.IsAbs(dir) || !nugetText(dir, 4096) { + c.degrade(nil, "path_unresolved") + continue + } + dirs = append(dirs, dir) + c.roots = append(c.roots, dir) + } + c.files = s.exec.GuardedFiles(c.roots, guard, maxNuGetMetadataBytes) + current, err := s.exec.CurrentUser() + verified := err == nil && current != nil && target.Uid != "" && current.Uid == target.Uid + c.config = configDetector.Detect(ctx, configaudit.NuGetConfigScope{Username: target.Username, Home: c.home, Roots: c.roots, Protected: guard, Volume: volume, ProcessVerified: verified}) + // Conventional caches and profiles never become projects; they are read by + // their own phases below. + for _, p := range []string{filepath.Join(c.home, ".nuget"), filepath.Join(c.home, ".dotnet"), filepath.Join(c.home, ".templateengine"), filepath.Join(c.home, ".local", "share", "NuGet"), + filepath.Join(c.home, "AppData", "Local", "NuGet"), filepath.Join(c.config.CLIHome, ".dotnet"), filepath.Join(c.config.CLIHome, ".templateengine")} { + c.caches[c.key(p)] = true + } + c.cacheRoot(c.config.GlobalPackages, "user") + for _, p := range c.config.PackageRoots { + c.cacheRoot(p, "custom") + } + c.walkRoots(dirs) + c.discoverProjectAssets() + c.readAssets() + c.emitProjects() + // Project files add their own package roots; scan caches once all are known. + c.scanCaches() + for _, f := range c.legacy { + c.emitLegacy(f) + } + for _, st := range c.assets { + c.emitAssets(st) + } + c.toolStore(filepath.Join(c.config.CLIHome, ".dotnet", "tools", ".store"), "user") + for _, key := range slices.Sorted(maps.Keys(c.stores)) { + c.toolStore(c.stores[key], "custom") + } + for _, f := range c.tools { + c.emitToolManifest(f) + } + c.templates() + for _, f := range c.deps { + c.emitDeps(f) + } + for _, f := range c.scripts { + c.emitScript(f) + } + // Legacy, tool and script contexts may name caches of their own. + c.scanCaches() + audit := c.config.Finish() + c.finish() + if goJSONSize(&c.inv)+goJSONSize(&audit) > maxNuGetOutputBytes { + c.inv = emptyNuGetInventory() + c.degrade(nil, "output_size_limit") + } + s.log.Debug("nuget scan: inventory=%s (%d sources, %d projects, %d packages) audit=%s (%d files, %d findings) in %dms", + c.inv.Status, len(c.inv.Sources), len(c.inv.Projects), len(c.inv.Packages), audit.Status, len(audit.Files), len(audit.Findings), time.Since(start).Milliseconds()) + return &c.inv, &audit +} +func (c *nugetScan) key(p string) string { return configaudit.GoPathKey(c.goos, p) } +func (c *nugetScan) reason(err error, p string) string { + return configaudit.CargoReadReason(err, p, c.volume) +} +func (c *nugetScan) degrade(src *model.NuGetSource, reason string) { + c.inv.Status = "partial" + if !slices.Contains(c.inv.Reasons, reason) { + c.inv.Reasons = append(c.inv.Reasons, reason) + } + for ; src != nil; src = c.byID[src.ParentSourceID] { + src.Status = "partial" + if !slices.Contains(src.Reasons, reason) { + src.Reasons = append(src.Reasons, reason) + } + } +} +func (c *nugetScan) charge(src *model.NuGetSource, record any) bool { + if c.remaining <= 0 { + c.degrade(src, "record_limit") + return false + } + // Reserve later coverage reasons, child IDs and separators per record. + size := goJSONSize(record) + 128 + if c.limited || c.bytes+size > maxNuGetOutputBytes { + c.limited = true + c.degrade(src, "output_size_limit") + return false + } + c.remaining-- + c.bytes += size + return true +} +func (c *nugetScan) stopReason() string { + switch { + case c.ctx.Err() != nil: + return "deadline_exceeded" + case c.limited: + return "output_size_limit" + case c.remaining <= 0: + return "record_limit" + } + return "" +} +func (c *nugetScan) stopped(src *model.NuGetSource) bool { + if reason := c.stopReason(); reason != "" { + c.degrade(src, reason) + return true + } + return false +} +func (c *nugetScan) source(kind, path, parent string) *model.NuGetSource { + if !filepath.IsAbs(path) || !nugetText(path, 4096) { + c.degrade(c.byID[parent], "path_unresolved") + return nil + } + id := configaudit.GoSourceID(c.identity, kind, path) + if src := c.byID[id]; src != nil { + return src + } + owner := c.byID[parent] + if owner == nil { + parent = "" + } + src := &model.NuGetSource{SourceID: id, Kind: kind, Path: path, ParentSourceID: parent, Status: "complete", Presence: "present", Reasons: []string{}, DiscoveredSources: []string{}} + if !c.charge(owner, src) { + return nil + } + c.byID[id] = src + c.sources = append(c.sources, src) + return src +} +func (c *nugetScan) fs(path string, limit int64) executor.Executor { + if configaudit.GoWithinRoots(c.goos, path, c.roots) { + return c.exec.GuardedFiles(c.roots, c.guard, limit) + } + // An exact metadata reference permits only targeted access, never a new walk. + return c.exec.GuardedFiles([]string{path}, c.guard, limit) +} +func (c *nugetScan) read(src *model.NuGetSource, targeted bool) ([]byte, bool) { + if c.stopped(src) { + src.Presence = "unknown" + return nil, false + } + files := c.fs(src.Path, maxNuGetMetadataBytes) + info, err := files.Stat(src.Path) + switch { + case errors.Is(err, fs.ErrNotExist): + src.Presence = "absent" + if !targeted { + src.Presence = "unknown" + c.degrade(src, "changed_during_scan") + } + return nil, false + case err != nil: + src.Presence = "unknown" + c.degrade(src, c.reason(err, src.Path)) + return nil, false + case !info.Mode().IsRegular(): + c.degrade(src, "unsupported_entry") + return nil, false + case info.Size() > maxNuGetMetadataBytes: + c.degrade(src, "size_limit") + return nil, false + } + data, err := files.ReadFile(src.Path) + if err != nil { + src.Presence = "unknown" + reason := c.reason(err, src.Path) + if errors.Is(err, fs.ErrNotExist) { + reason = "changed_during_scan" + } + c.degrade(src, reason) + return nil, false + } + return data, true +} + +// status maps a read outcome onto the config audit's file vocabulary. +func nugetReadStatus(src *model.NuGetSource, ok bool) string { + switch { + case ok: + return "read" + case src.Presence == "absent": + return "not_found" + case slices.Contains(src.Reasons, "skipped_protected"), slices.Contains(src.Reasons, "refused_network_volume"): + return "unsupported" + } + return "unreadable" +} + +// parseFailure maps a metadata parser error onto a source reason. +func (c *nugetScan) parseFailure(src *model.NuGetSource, err error) { + if errors.Is(err, errNuGetUnsupportedFormat) { + c.degrade(src, "unsupported_format") + return + } + c.degrade(src, "parse_error") +} + +func (c *nugetScan) cacheRoot(path, scope string) { + if path == "" || !filepath.IsAbs(path) || !nugetText(path, 4096) { + return + } + key := c.key(path) + c.caches[key] = true + if _, seen := c.cacheScope[key]; seen { + return + } + c.cacheScope[key] = scope + c.cacheRoots = append(c.cacheRoots, filepath.Clean(path)) +} + +// scanCaches reads every package root registered so far, once each; later +// phases register more through their own config contexts and call it again. +func (c *nugetScan) scanCaches() { + for _, p := range c.config.PackageRoots { + c.cacheRoot(p, "custom") + } + for ; c.cachesScanned < len(c.cacheRoots); c.cachesScanned++ { + c.scanCache(c.cacheRoots[c.cachesScanned]) + } +} +func (c *nugetScan) inCache(path string) bool { + path = c.key(path) + for root := range c.caches { + if path == root || goPathWithin(path, root) { + return true + } + } + return false +} +func (c *nugetScan) walkRoots(dirs []string) { + // Outermost logical/physical roots own discovery. Identical physical roots + // retain the first spelling, like Cargo, and never get walked twice. + type root struct { + path, phys string + src *model.NuGetSource + } + roots := []root{} + seen := map[string]bool{} + for _, dir := range dirs { + if seen[c.key(dir)] { + continue + } + seen[c.key(dir)] = true + src := c.source("project_search_root", dir, "") + if src == nil { + continue + } + if c.stopped(src) { + src.Presence = "unknown" + continue + } + phys, err := c.fs(dir, 0).EvalSymlinks(dir) + if errors.Is(err, fs.ErrNotExist) { + src.Presence = "absent" + continue + } + if err != nil { + src.Presence = "unknown" + c.degrade(src, c.reason(err, dir)) + continue + } + roots = append(roots, root{dir, phys, src}) + } + for i, r := range roots { + duplicate := slices.ContainsFunc(roots[:i], func(other root) bool { return c.key(other.phys) == c.key(r.phys) }) + nested := slices.ContainsFunc(roots, func(other root) bool { return goPathWithin(c.key(r.phys), c.key(other.phys)) }) + if duplicate || nested { + delete(c.byID, r.src.SourceID) + continue + } + c.walk(r.path, r.src) + } +} +func (c *nugetScan) walk(root string, src *model.NuGetSource) { + type dir struct { + path string + depth int + project bool // an ancestor or this directory holds a project file + } + queue := []dir{{root, 0, false}} + budget, scripts := maxNuGetWalkEntries, maxNuGetScriptReads + for len(queue) > 0 { + if c.stopped(src) { + return + } + d := queue[0] + queue = queue[1:] + if c.inCache(d.path) { + continue + } + entries, more, err := c.files.ReadDirLimit(d.path, budget) + if err != nil { + c.degrade(src, c.reason(err, d.path)) + continue + } + budget -= len(entries) + here, archive := false, false + for _, e := range entries { + if e.IsDir() { + continue + } + switch name := strings.ToLower(e.Name()); { + case strings.HasSuffix(name, ".nupkg"): + archive = true + case strings.HasSuffix(name, ".csproj"), strings.HasSuffix(name, ".fsproj"), strings.HasSuffix(name, ".vbproj"): + here = true + } + } + if archive && !here { + // Extracted package contents are never projects; pack output beside + // a project file is. + continue + } + project := d.project || here + for _, e := range entries { + if e.IsDir() { + continue + } + p := filepath.Join(d.path, e.Name()) + found := nugetFound{p, src.SourceID} + switch name := strings.ToLower(e.Name()); { + case strings.HasSuffix(name, ".csproj"), strings.HasSuffix(name, ".fsproj"), strings.HasSuffix(name, ".vbproj"): + c.project(found) + case slices.Contains(nugetImports, name): + c.importPaths[c.key(d.path)+"\x00"+name] = p + case name == "packages.config", strings.HasPrefix(name, "packages.") && strings.HasSuffix(name, ".config"): + c.legacy = append(c.legacy, found) + case name == "project.assets.json": + if store := filepath.Dir(filepath.Dir(d.path)); filepath.Base(store) == ".store" { + c.stores[c.key(store)] = store + } else if as := c.source("project_assets", p, src.SourceID); as != nil { + c.assets = append(c.assets, &nugetAssetsState{src: as}) + } + case strings.HasSuffix(name, ".deps.json"): + c.deps = append(c.deps, found) + case name == "dotnet-tools.json": + c.tools = append(c.tools, found) + case strings.HasSuffix(name, ".fsx"), strings.HasSuffix(name, ".cs") && !project: + if scripts <= 0 { + c.degrade(src, "entry_limit") + continue + } + scripts-- + c.scripts = append(c.scripts, found) + } + } + for _, e := range entries { + if !e.IsDir() { + continue + } + p := filepath.Join(d.path, e.Name()) + switch e.Name() { + case ".git", ".hg", ".svn", "node_modules", "target", ".rustup", ".cache", "__pycache__": + continue + } + if c.inCache(p) { + continue + } + if c.guard(p) != "" { + c.degrade(src, c.reasonForGuard(p)) + continue + } + if d.depth+1 > maxNuGetWalkDepth { + c.degrade(src, "depth_limit") + continue + } + queue = append(queue, dir{p, d.depth + 1, project}) + } + if more { + c.degrade(src, "entry_limit") + return + } + } +} +func (c *nugetScan) reasonForGuard(path string) string { + if c.volume != nil && c.volume(path) { + return "refused_network_volume" + } + return "skipped_protected" +} + +// project reads an MSBuild project at discovery; emission waits for the +// restore graphs that attach to it. +func (c *nugetScan) project(f nugetFound) { + key := c.key(f.path) + if c.projects[key] != nil { + return + } + src := c.source("project_manifest", f.path, f.root) + if src == nil { + return + } + dir := filepath.Dir(f.path) + st := &nugetProjectState{src: src, project: model.NuGetProject{ManifestSourceID: src.SourceID, ManifestPath: f.path, ProjectPath: dir, ProjectKind: "msbuild", TargetFrameworks: []string{}, SelectionStatus: "resolved"}} + c.projects[key] = st + c.projectDirs[c.key(dir)] = dir + data, ok := c.read(src, false) + st.status = nugetReadStatus(src, ok) + if !ok { + return + } + var err error + if st.file, err = parseNuGetProject(data); err != nil { + c.degrade(src, "parse_error") + st.status = "unreadable" + } +} + +// owner attributes a path to the nearest discovered project directory. +func (c *nugetScan) owner(dir string) (string, string) { + for p := dir; configaudit.GoWithinRoots(c.goos, p, c.roots); p = filepath.Dir(p) { + if d := c.projectDirs[c.key(p)]; d != "" { + return d, "project" + } + if filepath.Dir(p) == p { + break + } + } + return "", "unknown" +} + +// nearestImport returns the closest Directory.*.props/targets of that name +// above dir, read once. MSBuild imports only the nearest one of each. +func (c *nugetScan) nearestImport(dir, name, root string) *nugetImportState { + for p := dir; configaudit.GoWithinRoots(c.goos, p, c.roots); p = filepath.Dir(p) { + if path := c.importPaths[c.key(p)+"\x00"+name]; path != "" { + return c.loadImport(path, name, root) + } + if filepath.Dir(p) == p { + break + } + } + return nil +} +func (c *nugetScan) loadImport(path, name, root string) *nugetImportState { + key := c.key(path) + if st := c.imports[key]; st != nil { + return st + } + kind := "project_manifest" + if name == "directory.packages.props" { + kind = "central_versions" + } + src := c.source(kind, path, root) + if src == nil { + return nil + } + st := &nugetImportState{src: src, versions: map[string]string{}} + c.imports[key] = st + data, ok := c.read(src, false) + status := nugetReadStatus(src, ok) + props := map[string]string{} + if ok { + var err error + if st.file, err = parseNuGetProject(data); err != nil { + c.degrade(src, "parse_error") + status = "unreadable" + } else { + props = st.file.properties + for _, item := range st.file.items { + switch { + case item.kind == "PackageVersion" && !item.conditional: + st.versions[strings.ToLower(item.id)] = item.version + case kind == "project_manifest" && item.kind != "PackageVersion": + st.deferred = true + } + } + if st.deferred || kind == "project_manifest" && len(st.file.sdks) > 0 { + // Declarations in a shared build file stay unparsed; that is + // incomplete coverage for every consumer, never absence. + st.deferred = true + c.degrade(src, "unsupported_format") + } + } + } + st.record = c.config.ProjectFile(path, status, src.Reasons, props, "") + return st +} + +// degradeImports marks shared files partial: a consumer that could not be +// read may still consume their declarations, so absence is unproven. +func (c *nugetScan) degradeImports(imports []*nugetImportState, reasons []string) { + for _, imp := range imports { + for _, reason := range reasons { + c.degrade(imp.src, reason) + } + } +} + +// discoverProjectAssets follows literal restore-output paths outside the walk. +// These are exact guarded reads, not additional project search roots. +func (c *nugetScan) discoverProjectAssets() { + seen := map[string]bool{} + for _, st := range c.assets { + seen[c.key(st.src.Path)] = true + } + for _, key := range slices.Sorted(maps.Keys(c.projects)) { + st := c.projects[key] + if c.stopped(st.src) || st.file == nil { + continue + } + dir := st.project.ProjectPath + props := c.nearestImport(dir, "directory.build.props", st.src.ParentSourceID) + targets := c.nearestImport(dir, "directory.build.targets", st.src.ParentSourceID) + for _, imp := range []*nugetImportState{props, targets} { + if imp != nil && imp.file == nil { + // An unreadable import may still point at the previous assets. + st.project.SelectionStatus = "partial" + for _, reason := range imp.src.Reasons { + c.degrade(st.src, reason) + } + } + } + layers := []nugetLayer{} + if props != nil { + layers = append(layers, nugetLayer{filepath.Dir(props.src.Path), props.file}) + } + layers = append(layers, nugetLayer{dir, st.file}) + if targets != nil { + layers = append(layers, nugetLayer{filepath.Dir(targets.src.Path), targets.file}) + } + effective := nugetEffective(layers...) + p := effective.properties + output := cmp.Or(p["MSBuildProjectExtensionsPath"], p["BaseIntermediateOutputPath"]) + if output == "" && !strings.EqualFold(p["UseArtifactsOutput"], "false") && !strings.EqualFold(p["UseArtifactsIntermediateOutput"], "false") && (p["ArtifactsPath"] != "" || strings.EqualFold(p["UseArtifactsOutput"], "true")) { + artifacts := p["ArtifactsPath"] + if artifacts == "" && props != nil { + artifacts = filepath.Join(filepath.Dir(props.src.Path), "artifacts") + } + if artifacts != "" { + output = filepath.Join(artifacts, "obj") + if !strings.EqualFold(p["IncludeProjectNameInArtifactsPaths"], "false") { + projectName := cmp.Or(p["ArtifactsProjectName"], strings.TrimSuffix(filepath.Base(st.project.ManifestPath), filepath.Ext(st.project.ManifestPath))) + output = filepath.Join(output, projectName) + } + } + } + if output == "" { + continue + } + uncertain := nugetUnresolved(output) || !nugetText(output, 4096) + for _, property := range []string{"MSBuildProjectExtensionsPath", "BaseIntermediateOutputPath", "UseArtifactsOutput", "ArtifactsPath", "ArtifactsProjectName", "IncludeProjectNameInArtifactsPaths", "UseArtifactsIntermediateOutput"} { + uncertain = uncertain || effective.conditional[property] + } + if uncertain { + st.project.SelectionStatus = "partial" + c.degrade(st.src, "path_unresolved") + continue + } + if !filepath.IsAbs(output) { + output = filepath.Join(dir, filepath.FromSlash(output)) + } + path := filepath.Join(output, "project.assets.json") + if seen[c.key(path)] || c.stopped(st.src) { + continue + } + info, err := c.fs(path, 0).Stat(path) + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + c.degrade(st.src, c.reason(err, path)) + continue + } + if !info.Mode().IsRegular() { + c.degrade(st.src, "unsupported_entry") + continue + } + if src := c.source("project_assets", path, st.src.SourceID); src != nil { + c.assets = append(c.assets, &nugetAssetsState{src: src}) + seen[c.key(path)] = true + } + } +} + +func (c *nugetScan) readAssets() { + slices.SortFunc(c.assets, func(a, b *nugetAssetsState) int { return strings.Compare(a.src.Path, b.src.Path) }) + kept := c.assets[:0] + for _, st := range c.assets { + if st.src == nil { + continue + } + data, ok := c.read(st.src, false) + if !ok { + continue + } + a, err := parseNuGetAssets(data) + if err != nil { + c.parseFailure(st.src, err) + continue + } + if a.partial { + c.degrade(st.src, "parse_error") + } + st.assets = a + if pr := c.projects[c.key(a.restore.projectPath)]; pr != nil && pr.project.AssetsPath == "" { + pr.project.AssetsPath = st.src.Path + st.project = pr + } + for _, folder := range a.folders { + c.cacheRoot(folder, "custom") + } + kept = append(kept, st) + } + c.assets = kept +} + +// scanCache reads every extracted package under a NuGet package root. Only +// the nuspec identity and the recorded hashes are read; contents stay packed. +func (c *nugetScan) scanCache(root string) { + src := c.source("package_cache", root, "") + if src == nil { + return + } + if c.stopped(src) { + src.Presence = "unknown" + return + } + files := c.fs(root, maxNuGetArchiveBytes) + budget := maxNuGetWalkEntries + ids, more, err := files.ReadDirLimit(root, budget) + if errors.Is(err, fs.ErrNotExist) { + src.Presence = "absent" + return + } + if err != nil { + src.Presence = "unknown" + c.degrade(src, c.reason(err, root)) + return + } + budget -= len(ids) + complete := !more + scope := c.cacheScope[c.key(root)] + for _, id := range ids { + if !id.IsDir() || strings.HasPrefix(id.Name(), ".") { + continue + } + if _, ok := nugetID(id.Name()); !ok { + continue + } + if c.stopped(src) { + complete = false + break + } + idDir := filepath.Join(root, id.Name()) + versions, more, err := files.ReadDirLimit(idDir, budget) + if err != nil { + c.degrade(src, c.reason(err, idDir)) + complete = false + continue + } + budget -= len(versions) + complete = complete && !more + for _, version := range versions { + if !version.IsDir() || c.stopped(src) { + continue + } + c.cachedPackage(src, files, root, id.Name(), version.Name(), scope) + } + } + if !complete { + c.degrade(src, "entry_limit") + } + c.cacheDone[c.key(root)] = complete && src.Status == "complete" +} + +// nugetPackageDir is the identity one extracted package directory proves, +// plus the completion markers found beside it. +type nugetPackageDir struct{ id, version, marker, sha string } + +// packageDir reads a package directory's nuspec identity from the loose +// nuspec or the bounded archive; contents stay packed. A missing directory is +// silently absent; anything else that blocks identity is charged to src. +func (c *nugetScan) packageDir(src *model.NuGetSource, files executor.Executor, dir string) (nugetPackageDir, bool) { + var d nugetPackageDir + entries, _, err := files.ReadDirLimit(dir, 64) + if errors.Is(err, fs.ErrNotExist) { + return d, false + } + if err != nil { + c.degrade(src, c.reason(err, dir)) + return d, false + } + var nuspec, nupkg string + for _, e := range entries { + if e.IsDir() { + continue + } + p := filepath.Join(dir, e.Name()) + switch name := strings.ToLower(e.Name()); { + case strings.HasSuffix(name, ".nuspec"): + nuspec = p + case strings.HasSuffix(name, ".nupkg"): + nupkg = p + case name == ".nupkg.metadata": + d.marker = p + case strings.HasSuffix(name, ".nupkg.sha512"): + d.sha = p + } + } + var data []byte + switch { + case nuspec != "": + if data, err = files.ReadFile(nuspec); err != nil { + c.degrade(src, c.reason(err, nuspec)) + return d, false + } + case nupkg != "": + var reason string + if data, reason = c.archiveNuspec(files, nupkg); reason != "" { + c.degrade(src, reason) + return d, false + } + default: + c.degrade(src, "extraction_incomplete") + return d, false + } + if d.id, d.version, err = parseNuGetNuspec(data); err != nil { + c.degrade(src, "parse_error") + return d, false + } + return d, true +} + +// archiveNuspec reads the root nuspec member of one .nupkg. +func (c *nugetScan) archiveNuspec(files executor.Executor, nupkg string) ([]byte, string) { + raw, err := files.ReadFile(nupkg) + if err != nil { + return nil, c.reason(err, nupkg) + } + return nugetArchiveNuspec(raw) +} + +// nugetIdentityMatches reports whether a nuspec identity is the package the +// directory or receipt claims to hold, filling the exact version on success. +func nugetIdentityMatches(p *model.NuGetPackage, d nugetPackageDir, id, version string) bool { + want, _ := nugetNormalizeVersion(version) + return strings.EqualFold(d.id, id) && nugetExact(p, d.version) && p.VersionNormalized == want && want != "" +} + +func (c *nugetScan) cachedPackage(src *model.NuGetSource, files executor.Executor, root, id, version, scope string) { + dir := filepath.Join(root, id, version) + d, ok := c.packageDir(src, files, dir) + if !ok { + return + } + p := newNuGetPackage(d.id) + if !nugetIdentityMatches(&p, d, id, version) { + c.degrade(src, "identity_mismatch") + return + } + p.DependencyRelation = "unknown" + p.Installation = &model.NuGetInstallation{Path: dir, PathStatus: "resolved", Presence: "present"} + entry := nugetCached{} + marker, sha := d.marker, d.sha + if marker != "" { + md, err := files.ReadFile(marker) + if err != nil { + p.Reasons = append(p.Reasons, c.reason(err, marker)) + } else if hash, source, err := parseNuGetMarker(md); err != nil { + p.Reasons = append(p.Reasons, "parse_error") + } else { + nugetContentHash(&p, "content", hash) + entry = nugetCached{contentHash: hash, source: source} + p.Origin = c.markerOrigin(source) + } + } + if sha != "" { + if sd, err := files.ReadFile(sha); err != nil { + p.Reasons = append(p.Reasons, c.reason(err, sha)) + } else { + nugetContentHash(&p, "archive", strings.TrimSpace(string(sd))) + } + } + if marker == "" && sha == "" { + p.Reasons = append(p.Reasons, "extraction_incomplete") + } + c.index[c.key(root)+"\x00"+strings.ToLower(id)+"/"+p.VersionNormalized] = entry + c.add(src, p, "cached_package", "", scope) +} + +// markerOrigin is the download source a .nupkg.metadata v2 file recorded. +func (c *nugetScan) markerOrigin(source string) model.NuGetOrigin { + if source == "" { + return model.NuGetOrigin{Kind: "unknown"} + } + if path, local := composerLocalURL(source, ""); local && !strings.Contains(source, "://") { + return model.NuGetOrigin{Kind: "local", Path: filepath.Clean(path)} + } + // A stripped credential still leaves a usable feed identity. + url, _ := configaudit.SanitizeComposerURL(source) + if url == "[redacted]" { + return model.NuGetOrigin{Kind: "unknown"} + } + return model.NuGetOrigin{Kind: "feed", URL: url} +} + +func (c *nugetScan) emitProjects() { + for _, key := range slices.Sorted(maps.Keys(c.projects)) { + st := c.projects[key] + if c.stopped(st.src) { + // An unvisited consumer leaves every shared declaration unproven. + c.degradeImports(slices.Collect(maps.Values(c.imports)), []string{c.stopReason()}) + continue + } + dir := st.project.ProjectPath + var central *nugetImportState + var imports []*nugetImportState + var layers []nugetLayer // import order: props, project, targets + for _, name := range nugetImports { + imp := c.nearestImport(dir, name, st.src.ParentSourceID) + switch { + case imp == nil: + case name == "directory.packages.props": + central = imp + imports = append(imports, imp) + default: + layers = append(layers, nugetLayer{filepath.Dir(imp.src.Path), imp.file}) + imports = append(imports, imp) + if imp.deferred { + // A reference moved into the shared file is unparsed, so + // this project's manifest no longer proves its absence. + st.project.SelectionStatus = "partial" + c.degrade(st.src, "unsupported_format") + } + } + if name == "directory.build.props" { + layers = append(layers, nugetLayer{dir, st.file}) + } + } + effective := nugetEffective(layers...) + if effective.imports { + c.degrade(st.src, "unsupported_format") + } + // Central versions apply only when the literal settings enable them. + managed := effective.properties["ManagePackageVersionsCentrally"] + uncertain := effective.conditional["ManagePackageVersionsCentrally"] || effective.imports + if central != nil && central.file != nil { + if managed == "" { + managed = central.file.properties["ManagePackageVersionsCentrally"] + uncertain = uncertain || central.file.conditional["ManagePackageVersionsCentrally"] + } + uncertain = uncertain || central.file.imports + } + uncertain = uncertain || nugetUnresolved(managed) + if uncertain && central != nil { + c.degrade(st.src, "unsupported_format") + c.degrade(central.src, "unsupported_format") + st.project.SelectionStatus = "partial" + } + if !strings.EqualFold(managed, "true") || uncertain { + central = nil + } + props := map[string]string{} + if st.file != nil { + props = maps.Clone(st.file.properties) + for _, key := range nugetPathProperties { + if v := effective.properties[key]; v != "" { + props[key] = v + } + } + } + leading := []*model.NuGetConfigFile{c.config.ProjectFile(st.project.ManifestPath, st.status, st.src.Reasons, props, dir)} + for _, imp := range imports { + leading = append(leading, imp.record) + } + c.config.Ambient(dir, leading...) + if st.file == nil { + st.project.SelectionStatus = "unresolved" + c.degradeImports(imports, st.src.Reasons) + } else { + c.frameworks(st, effective) + } + c.lock(st, effective.properties["NuGetLockFilePath"]) + if !c.charge(st.src, st.project) { + c.degradeImports(imports, []string{c.stopReason()}) + continue + } + c.inv.Projects = append(c.inv.Projects, st.project) + if st.file == nil { + continue + } + for _, item := range st.file.items { + if item.kind == "PackageVersion" { + continue // central declarations surface through consumers + } + c.declared(st.src, dir, item, central) + } + for _, item := range st.file.sdks { + c.declared(st.src, dir, item, nil) + } + if central != nil && central.file != nil { + for _, item := range central.file.items { + if item.kind == "GlobalPackageReference" { + c.declared(central.src, dir, item, nil) + } + } + } + } +} + +// frameworks keeps the literal target framework list; anything needing +// evaluation leaves the selection partial. +func (c *nugetScan) frameworks(st *nugetProjectState, f *nugetProjectFile) { + value := f.properties["TargetFrameworks"] + if value == "" { + value = f.properties["TargetFramework"] + } + if f.imports || f.conditional["TargetFramework"] || f.conditional["TargetFrameworks"] || nugetUnresolved(value) { + st.project.SelectionStatus = "partial" + if nugetUnresolved(value) { + return + } + } + for tf := range strings.SplitSeq(value, ";") { + if tf = strings.TrimSpace(tf); tf != "" && nugetText(tf, 64) { + st.project.TargetFrameworks = append(st.project.TargetFrameworks, tf) + } + } +} + +// nugetEffective layers literal properties in MSBuild import order; a later +// file wins, and any conditional or imported value keeps its uncertainty. +// $(MSBuildThisFileDirectory) is the one anchor that belongs to the declaring +// file, so it is substituted here in each list entry; every other path stays +// consumer-relative. +func nugetEffective(layers ...nugetLayer) *nugetProjectFile { + out := &nugetProjectFile{properties: map[string]string{}, conditional: map[string]bool{}} + for _, l := range layers { + if l.file == nil { + continue + } + for key, value := range l.file.properties { + entries := strings.Split(value, ";") + for i, e := range entries { + if rest, ok := strings.CutPrefix(strings.TrimSpace(e), "$(MSBuildThisFileDirectory)"); ok { + entries[i] = l.dir + string(filepath.Separator) + strings.TrimLeft(rest, `/\`) + } + } + out.properties[key] = strings.Join(entries, ";") + } + maps.Copy(out.conditional, l.file.conditional) + out.imports = out.imports || l.file.imports + } + return out +} + +// lock attaches the project's lock file: the literal NuGetLockFilePath, else +// the project-specific or shared default name when it exists. +func (c *nugetScan) lock(st *nugetProjectState, configured string) { + dir := st.project.ProjectPath + candidates := []string{filepath.Join(dir, "packages."+strings.TrimSuffix(filepath.Base(st.project.ManifestPath), filepath.Ext(st.project.ManifestPath))+".lock.json"), filepath.Join(dir, "packages.lock.json")} + if configured != "" { + if nugetUnresolved(configured) || !nugetText(configured, 4096) { + st.project.SelectionStatus = "partial" + c.degrade(st.src, "path_unresolved") + return + } + if !filepath.IsAbs(configured) { + configured = filepath.Join(dir, filepath.FromSlash(configured)) + } + candidates = []string{filepath.Clean(configured)} + } + for _, p := range candidates { + info, err := c.fs(p, 0).Stat(p) + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + c.degrade(st.src, c.reason(err, p)) + return + } + if !info.Mode().IsRegular() { + continue + } + st.project.LockfilePath = p + src := c.source("lockfile", p, st.src.SourceID) + if src == nil { + return + } + c.locked(src, dir) + return + } +} +func (c *nugetScan) locked(src *model.NuGetSource, dir string) { + data, ok := c.read(src, true) + if !ok { + return + } + entries, partial, err := parseNuGetLock(data) + if err != nil { + c.parseFailure(src, err) + return + } + if partial { + c.degrade(src, "parse_error") + } + for _, e := range entries { + if c.stopped(src) { + return + } + p := newNuGetPackage(e.id) + if !nugetExact(&p, e.resolved) { + p.Reasons = append(p.Reasons, "parse_error") + } + p.RequestedVersion, p.LockDependencyType, p.FrameworkAlias, p.TargetFramework = e.requested, e.typ, e.alias, e.framework + p.DependencyRelation = "transitive" + if e.typ == "Direct" { + p.DependencyRelation = "direct" + } + nugetContentHash(&p, "content", e.contentHash) + c.add(src, p, "locked_package", dir, "project") + } +} + +// declared emits one project-file item. Only literal versions are kept; a +// reference that depends on evaluation stays a requirement without a version. +func (c *nugetScan) declared(src *model.NuGetSource, dir string, item nugetItem, central *nugetImportState) { + p := newNuGetPackage(item.id) + if p.PackageName == "" { + c.degrade(src, "parse_error") + return + } + p.DependencyRelation, p.Conditional = "direct", item.conditional + p.PrivateAssets, p.IncludeAssets, p.ExcludeAssets = item.privateAssets, item.includeAssets, item.excludeAssets + version := item.version + switch item.kind { + case "PackageReference": + p.DeclarationKind = "package_reference" + if version == "" { + version = item.versionOverride + } + if version == "" && central != nil { + version = central.versions[p.PackageName] + } + case "GlobalPackageReference": + p.DeclarationKind = "global_package_reference" + // Even an exact bracketed PackageDownload or SDK version is a request; + // only legacy and tool manifests record known declared versions. + case "PackageDownload": + p.DeclarationKind = "package_download" + case "msbuild_sdk": + p.DeclarationKind = "msbuild_sdk" + } + switch { + case nugetUnresolved(version) || !nugetText(version, 256): + p.Reasons = append(p.Reasons, "context_unresolved") + case version == "" && p.Version == "": + p.Reasons = append(p.Reasons, "context_unresolved") + default: + p.RequestedVersion = version + } + c.add(src, p, "declared_requirement", dir, "project") +} + +func (c *nugetScan) emitLegacy(f nugetFound) { + src := c.source("legacy_manifest", f.path, f.root) + if src == nil { + return + } + dir := filepath.Dir(f.path) + data, ok := c.read(src, false) + if !ok { + return + } + entries, partial, err := parseNuGetPackagesConfig(data) + if err != nil { + c.degrade(src, "parse_error") + return + } + if partial { + c.degrade(src, "parse_error") + } + paths := c.config.Ambient(dir) + project := model.NuGetProject{ManifestSourceID: src.SourceID, ManifestPath: f.path, ProjectPath: dir, ProjectKind: "legacy", TargetFrameworks: []string{}, SelectionStatus: "resolved", PackagesPath: paths.Repository} + if project.PackagesPath == "" { + // Without repositoryPath, NuGet uses the solution's packages folder. + for p, i := dir, 0; i < 3 && configaudit.GoWithinRoots(c.goos, p, c.roots); p, i = filepath.Dir(p), i+1 { + repo := filepath.Join(p, "packages") + if info, err := c.fs(repo, 0).Stat(repo); err == nil && info.IsDir() { + project.PackagesPath = repo + break + } + } + } + if !c.charge(src, project) { + return + } + c.inv.Projects = append(c.inv.Projects, project) + for _, e := range entries { + if c.stopped(src) { + return + } + p := newNuGetPackage(e.id) + if !nugetExact(&p, e.version) { + p.RequestedVersion = e.version + } + p.TargetFramework, p.AllowedVersions, p.DevelopmentDependency = e.targetFramework, e.allowedVersions, e.developmentDependency + c.add(src, p, "declared_requirement", dir, "project") + if c.stopped(src) { + return + } + if project.PackagesPath == "" || p.Version == "" { + continue + } + // Only a readable nuspec proves the folder holds this package. + installed := filepath.Join(project.PackagesPath, e.id+"."+e.version) + d, ok := c.packageDir(src, c.fs(installed, maxNuGetArchiveBytes), installed) + if !ok { + continue + } + r := p + r.Reasons, r.RecordedChecksums = []string{}, []model.NuGetRecordedChecksum{} + if !nugetIdentityMatches(&r, d, e.id, e.version) { + c.degrade(src, "identity_mismatch") + continue + } + r.Installation = &model.NuGetInstallation{Path: installed, PathStatus: "resolved", Presence: "present"} + c.add(src, r, "restored_package", dir, "project") + } +} + +func (c *nugetScan) emitAssets(st *nugetAssetsState) { + a, src := st.assets, st.src + project, scope := "", "unknown" + switch { + case st.project != nil: + project, scope = st.project.project.ProjectPath, "project" + case a.restore.projectPath != "": + project = filepath.Dir(a.restore.projectPath) + } + if a.hasProject && len(a.restore.configFilePaths) > 0 { + c.config.Restore(src.Path, cmp.Or(project, filepath.Dir(filepath.Dir(src.Path))), a.restore.configFilePaths) + } + seen := map[string]bool{} + for _, t := range a.targets { + for _, pk := range t.packages { + if c.stopped(src) { + return + } + p := newNuGetPackage(pk.id) + if !nugetExact(&p, pk.version) { + p.Reasons = append(p.Reasons, "parse_error") + } + p.FrameworkAlias, p.TargetFramework, p.RuntimeIdentifier = t.alias, t.framework, t.rid + lib := a.libraries[strings.ToLower(pk.id+"/"+pk.version)] + nugetContentHash(&p, "content", lib.sha512) + // Only a target tied to a project framework decides the relation; + // an unmatched target leaves it unknown. + if direct, ok := a.direct[t.alias]; ok && a.hasProject { + p.DependencyRelation = "transitive" + if direct[p.PackageName] { + p.DependencyRelation = "direct" + } + } + c.locate(&p, a.folders, lib.sha512) + key := nugetPackageKey(p) + if seen[key] { + continue + } + seen[key] = true + c.add(src, p, "restored_package", project, scope) + } + } +} + +// locate joins a restored package to the cache scan: the first package folder +// holding it is its installation, and a matching content hash lets the +// cache's recorded download source stand as the origin. +func (c *nugetScan) locate(p *model.NuGetPackage, folders []string, hash string) { + if p.VersionNormalized == "" || len(folders) == 0 { + return + } + for _, folder := range folders { + e, ok := c.index[c.key(folder)+"\x00"+p.PackageName+"/"+p.VersionNormalized] + if !ok { + continue + } + p.Installation = &model.NuGetInstallation{Path: filepath.Join(folder, p.PackageName, p.VersionNormalized), PathStatus: "resolved", Presence: "present"} + if hash != "" && e.contentHash == hash { + p.Origin = c.markerOrigin(e.source) + } + return + } + p.Installation = &model.NuGetInstallation{Path: filepath.Join(folders[0], p.PackageName, p.VersionNormalized), PathStatus: "resolved", Presence: "unknown"} + for _, folder := range folders { + if !c.cacheDone[c.key(folder)] { + return + } + } + p.Installation.Presence = "absent" +} + +// toolStore reads a global tool store: each tool keeps its own restore graph +// beside the extracted packages, and its commands beside its shims. +func (c *nugetScan) toolStore(store, scope string) { + src := c.source("tool_store", store, "") + if src == nil { + return + } + if c.stopped(src) { + src.Presence = "unknown" + return + } + files := c.fs(store, maxNuGetMetadataBytes) + budget := maxNuGetWalkEntries + ids, more, err := files.ReadDirLimit(store, budget) + if errors.Is(err, fs.ErrNotExist) { + src.Presence = "absent" + return + } + if err != nil { + src.Presence = "unknown" + c.degrade(src, c.reason(err, store)) + return + } + if more { + c.degrade(src, "entry_limit") + } + budget -= len(ids) + shims := filepath.Dir(store) + for _, id := range ids { + if !id.IsDir() || strings.HasPrefix(id.Name(), ".") { + continue + } + if c.stopped(src) { + return + } + idDir := filepath.Join(store, id.Name()) + versions, more, err := files.ReadDirLimit(idDir, budget) + if err != nil { + c.degrade(src, c.reason(err, idDir)) + continue + } + if more { + c.degrade(src, "entry_limit") + } + budget -= len(versions) + for _, version := range versions { + if !version.IsDir() || c.stopped(src) { + continue + } + c.installedTool(src, files, idDir, id.Name(), version.Name(), shims, scope) + } + } +} +func (c *nugetScan) installedTool(src *model.NuGetSource, files executor.Executor, idDir, id, version, shims, scope string) { + verDir := filepath.Join(idDir, version) + graphs := []*nugetAssets{} + for _, name := range []string{"project.assets.json", "project.assets.ridpackage.json"} { + data, err := files.ReadFile(filepath.Join(verDir, name)) + if errors.Is(err, fs.ErrNotExist) { + if len(graphs) == 0 { + return // staged or torn-down tool + } + continue // a framework-dependent tool has no RID payload graph + } + if err != nil { + c.degrade(src, c.reason(err, verDir)) + return + } + a, err := parseNuGetAssets(data) + if err != nil { + c.parseFailure(src, err) + return + } + if a.partial { + c.degrade(src, "parse_error") + } + graphs = append(graphs, a) + } + // A RID-specific tool keeps its commands in the payload package, which the + // companion graph names; the pointer package stays the requested identity. + settings := map[string][]string{} + commandsOf := func(pk nugetTargetPackage) []string { + dir := filepath.Join(verDir, pk.id, pk.version, "tools") + if _, seen := settings[dir]; !seen { + settings[dir] = c.toolCommands(files, dir) + } + return settings[dir] + } + commands := commandsOf(nugetTargetPackage{id: id, version: version}) + if commands == nil && len(graphs) > 1 { + for _, t := range graphs[1].targets { + for _, pk := range t.packages { + if commands = commandsOf(pk); commands != nil { + break + } + } + } + } + if len(commands) == 0 { + c.degrade(src, "extraction_incomplete") + return + } + want, _ := nugetNormalizeVersion(version) + for gi, a := range graphs { + for _, t := range a.targets { + for _, pk := range t.packages { + if c.stopped(src) { + return + } + p := newNuGetPackage(pk.id) + if !nugetExact(&p, pk.version) { + p.Reasons = append(p.Reasons, "parse_error") + } + p.FrameworkAlias, p.TargetFramework, p.RuntimeIdentifier = t.alias, t.framework, t.rid + nugetContentHash(&p, "content", a.libraries[strings.ToLower(pk.id+"/"+pk.version)].sha512) + p.DependencyRelation = "transitive" + pointer := p.PackageName == strings.ToLower(id) && p.VersionNormalized == want + if pointer || gi > 0 { + p.DependencyRelation, p.ToolCommands = "direct", commandsOf(pk) + if pointer { + p.ToolCommands = commands + } + if len(p.ToolCommands) > 0 { + shim := filepath.Join(shims, p.ToolCommands[0]) + if c.goos == model.PlatformWindows { + shim += ".exe" + } + p.Installation = c.presence(&p, shim) + } + } + c.add(src, p, "installed_tool", "", scope) + } + } + } +} + +// toolCommands reads DotnetToolSettings.xml from the single framework and +// runtime folder a tool package ships. +func (c *nugetScan) toolCommands(files executor.Executor, tools string) []string { + dir := tools + for range 2 { + entries, _, err := files.ReadDirLimit(dir, 16) + if err != nil { + return nil + } + i := slices.IndexFunc(entries, func(e fs.DirEntry) bool { return e.IsDir() }) + if i < 0 { + return nil + } + dir = filepath.Join(dir, entries[i].Name()) + } + data, err := files.ReadFile(filepath.Join(dir, "DotnetToolSettings.xml")) + if err != nil { + return nil + } + commands, err := parseNuGetToolSettings(data) + if err != nil { + return nil + } + return commands +} + +// presence records whether a path exists, charging read failures to the package. +func (c *nugetScan) presence(p *model.NuGetPackage, path string) *model.NuGetInstallation { + out := &model.NuGetInstallation{Path: path, PathStatus: "resolved", Presence: "unknown"} + _, err := c.fs(path, 0).Stat(path) + switch { + case err == nil: + out.Presence = "present" + case errors.Is(err, fs.ErrNotExist): + out.Presence = "absent" + default: + out.Presence = "unreadable" + p.Reasons = append(p.Reasons, c.reason(err, path)) + } + return out +} + +func (c *nugetScan) emitToolManifest(f nugetFound) { + src := c.source("tool_manifest", f.path, f.root) + if src == nil { + return + } + data, ok := c.read(src, false) + if !ok { + return + } + entries, partial, err := parseNuGetToolManifest(data) + if err != nil { + c.parseFailure(src, err) + return + } + if partial { + c.degrade(src, "parse_error") + } + dir := filepath.Dir(f.path) + if filepath.Base(dir) == ".config" { + dir = filepath.Dir(dir) + } + project := model.NuGetProject{ManifestSourceID: src.SourceID, ManifestPath: f.path, ProjectPath: dir, ProjectKind: "tool_manifest", TargetFrameworks: []string{}, SelectionStatus: "resolved"} + if !c.charge(src, project) { + return + } + c.inv.Projects = append(c.inv.Projects, project) + c.config.Ambient(dir) + for _, e := range entries { + if c.stopped(src) { + return + } + p := newNuGetPackage(e.id) + if !nugetExact(&p, e.version) { + p.RequestedVersion = e.version + } + p.DependencyRelation, p.ToolCommands = "direct", e.commands + evidence := "declared_requirement" + if r := c.resolvedTool(e, src.SourceID); r != nil { + evidence = "installed_tool" + p.TargetFramework, p.RuntimeIdentifier = r.framework, r.rid + p.Installation = c.presence(&p, r.executable) + } + c.add(src, p, evidence, dir, "project") + } +} + +// resolvedTool matches a manifest entry against the SDK's resolver cache, +// which records the executable a local tool last resolved to. +func (c *nugetScan) resolvedTool(e nugetToolEntry, parent string) *nugetResolverEntry { + path := filepath.Join(c.config.CLIHome, ".dotnet", "toolResolverCache", "1", strings.ToLower(e.id)) + key := c.key(path) + entries, ok := c.resolver[key] + if !ok { + c.resolver[key] = nil + if _, err := c.fs(path, 0).Stat(path); err != nil { + return nil + } + src := c.source("tool_resolver_cache", path, parent) + if src == nil { + return nil + } + data, ok := c.read(src, true) + if !ok { + return nil + } + var partial bool + var err error + if entries, partial, err = parseNuGetResolverCache(data); err != nil { + c.parseFailure(src, err) + return nil + } + if partial { + c.degrade(src, "parse_error") + } + c.resolver[key] = entries + } + want, _ := nugetNormalizeVersion(e.version) + for i := range entries { + r := &entries[i] + if got, _ := nugetNormalizeVersion(r.version); got == want && want != "" && slices.Contains(e.commands, r.name) && filepath.IsAbs(r.executable) { + return r + } + } + return nil +} + +func (c *nugetScan) templates() { + src := c.source("template_receipt", filepath.Join(c.config.CLIHome, ".templateengine", "packages.json"), "") + if src == nil { + return + } + data, ok := c.read(src, true) + if !ok { + return + } + entries, partial, err := parseNuGetTemplates(data) + if err != nil { + c.parseFailure(src, err) + return + } + if partial { + c.degrade(src, "parse_error") + } + for _, e := range entries { + if c.stopped(src) { + return + } + p := newNuGetPackage(e.id) + if !nugetExact(&p, e.version) { + p.RequestedVersion = e.version + } + p.DependencyRelation = "direct" + if e.source != "" { + p.Origin = c.markerOrigin(e.source) + } + if mount := strings.TrimPrefix(e.mount, "file://"); filepath.IsAbs(mount) { + p.Installation = c.presence(&p, filepath.Clean(mount)) + // The receipt is a claim; the archive's nuspec is the evidence. + // Without it nothing is proven installed, and prior evidence + // must stay stale rather than retire. + var reason string + switch p.Installation.Presence { + case "absent": + continue + case "unreadable": + reason = p.Reasons[len(p.Reasons)-1] + case "present": + var d nugetPackageDir + var data []byte + data, reason = c.archiveNuspec(c.fs(mount, maxNuGetArchiveBytes), mount) + if reason == "" { + if d.id, d.version, err = parseNuGetNuspec(data); err != nil { + reason = "parse_error" + } + } + if reason == "" && !nugetIdentityMatches(&p, d, e.id, e.version) { + reason = "identity_mismatch" + } + } + if reason != "" { + c.degrade(src, reason) + continue + } + } else { + c.degrade(src, "path_unresolved") + continue + } + c.add(src, p, "installed_template", "", "user") + } +} + +func (c *nugetScan) emitDeps(f nugetFound) { + src := c.source("binary_metadata", f.path, f.root) + if src == nil { + return + } + data, ok := c.read(src, false) + if !ok { + return + } + entries, partial, err := parseNuGetDeps(data) + if err != nil { + c.parseFailure(src, err) + return + } + if partial { + c.degrade(src, "parse_error") + } + project, scope := c.owner(filepath.Dir(f.path)) + for _, e := range entries { + if c.stopped(src) { + return + } + p := newNuGetPackage(e.id) + if !nugetExact(&p, e.version) { + p.Reasons = append(p.Reasons, "parse_error") + } + p.TargetFramework, p.RuntimeIdentifier = e.framework, e.rid + nugetContentHash(&p, "content", e.sha512) + c.add(src, p, "binary_dependency", project, scope) + } +} + +// emitScript records a source file only once a package directive proves it +// is a file-based app or script; ordinary source files leave no trace. +func (c *nugetScan) emitScript(f nugetFound) { + // Failed probes must mark discovery partial even before a directive is known. + probe := &model.NuGetSource{Path: f.path, ParentSourceID: f.root} + data, ok := c.read(probe, false) + if !ok { + return + } + kind, directives := "file_based", parseNuGetCSharpDirectives(data) + if strings.EqualFold(filepath.Ext(f.path), ".fsx") { + kind, directives = "fsharp_script", parseNuGetFSharpDirectives(data) + } + if len(directives) == 0 { + return + } + src := c.source("script_manifest", f.path, f.root) + if src == nil { + return + } + dir := filepath.Dir(f.path) + project := model.NuGetProject{ManifestSourceID: src.SourceID, ManifestPath: f.path, ProjectPath: dir, ProjectKind: kind, TargetFrameworks: []string{}, SelectionStatus: "resolved"} + if !c.charge(src, project) { + return + } + c.inv.Projects = append(c.inv.Projects, project) + c.config.Ambient(dir) + for _, d := range directives { + item := nugetItem{kind: "PackageReference", id: d.id, version: d.version} + if d.kind == "sdk" { + item.kind = "msbuild_sdk" + } + c.declared(src, dir, item, nil) + } +} + +func nugetPackageKey(p model.NuGetPackage) string { b, _ := json.Marshal(p); return string(b) } +func (c *nugetScan) add(src *model.NuGetSource, p model.NuGetPackage, evidence, project, scope string) { + p.SourceID, p.SourcePath, p.Evidence, p.ProjectPath, p.Scope = src.SourceID, src.Path, evidence, project, scope + for i := range p.RecordedChecksums { + h := &p.RecordedChecksums[i] + h.SourceID, h.SourcePath, h.SourceKind = src.SourceID, src.Path, src.Kind + } + slices.Sort(p.Reasons) + p.Reasons = slices.Compact(p.Reasons) + if c.charge(src, p) { + c.inv.Packages = append(c.inv.Packages, p) + } +} +func (c *nugetScan) finish() { + for _, src := range c.sources { + if c.byID[src.SourceID] == nil { + continue + } + if parent := c.byID[src.ParentSourceID]; parent != nil { + parent.DiscoveredSources = append(parent.DiscoveredSources, src.SourceID) + } else { + src.ParentSourceID = "" + } + } + for _, src := range c.sources { + if c.byID[src.SourceID] == nil { + continue + } + slices.Sort(src.Reasons) + slices.Sort(src.DiscoveredSources) + src.DiscoveredSources = slices.Compact(src.DiscoveredSources) + c.inv.Sources = append(c.inv.Sources, *src) + } + slices.SortFunc(c.inv.Sources, func(a, b model.NuGetSource) int { + return strings.Compare(a.Path+"\x00"+a.Kind, b.Path+"\x00"+b.Kind) + }) + slices.SortFunc(c.inv.Projects, func(a, b model.NuGetProject) int { return strings.Compare(a.ManifestPath, b.ManifestPath) }) + slices.SortStableFunc(c.inv.Packages, func(a, b model.NuGetPackage) int { + return cmp.Or(strings.Compare(a.SourcePath, b.SourcePath), strings.Compare(a.Evidence, b.Evidence), strings.Compare(a.PackageName, b.PackageName), + strings.Compare(a.VersionNormalized, b.VersionNormalized), strings.Compare(a.RequestedVersion, b.RequestedVersion), strings.Compare(a.FrameworkAlias, b.FrameworkAlias), + strings.Compare(a.TargetFramework, b.TargetFramework), strings.Compare(a.RuntimeIdentifier, b.RuntimeIdentifier), strings.Compare(a.DeclarationKind, b.DeclarationKind)) + }) + slices.Sort(c.inv.Reasons) +} diff --git a/internal/detector/nugetscan_test.go b/internal/detector/nugetscan_test.go new file mode 100644 index 00000000..3fbfae8b --- /dev/null +++ b/internal/detector/nugetscan_test.go @@ -0,0 +1,1053 @@ +package detector + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/detector/configaudit" + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" + "github.com/step-security/dev-machine-guard/internal/progress" +) + +// Valid base64 SHA-512 values: 64 zero or 0x41 bytes. +const ( + nugetHashA = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + nugetHashB = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQQ==" +) + +func nugetTestScanner(t *testing.T, home string) (*NuGetScanner, *composerTrap) { + t.Helper() + x := &composerTrap{goTrap: &goTrap{Executor: executor.NewReal(), t: t, current: goTestTarget(home)}, env: map[string]string{}, denied: map[string]error{}, reads: map[string]int{}, readErrors: map[string]error{}} + s := NewNuGetScanner(x, progress.NewNoop()) + s.protection = func(string, *bool) (func(string) string, func(string) bool) { + return func(string) string { return "" }, func(string) bool { return false } + } + return s, x +} + +// nugetJSONPath escapes native paths embedded in receipt fixtures. +func nugetJSONPath(path string) string { + data, _ := json.Marshal(path) + return string(data[1 : len(data)-1]) +} + +func nugetTestToolName(name string) string { + if runtime.GOOS == model.PlatformWindows { + return name + ".exe" + } + return name +} + +func nugetNuspec(id, version string) string { + return `` + id + `` + version + `` +} + +func nugetArchive(t *testing.T, nuspec string) string { + t.Helper() + var buf bytes.Buffer + w := zip.NewWriter(&buf) + f, err := w.Create("Archived.nuspec") + if err != nil { + t.Fatal(err) + } + _, _ = f.Write([]byte(nuspec)) + if err := w.Close(); err != nil { + t.Fatal(err) + } + return buf.String() +} + +// nugetWriteFixture lays out every evidence kind under home: a central-managed +// project with restore graph and lock, a legacy project, caches with each +// marker shape, global and local tools, a template receipt, a runtime +// manifest and file-based scripts. Secrets are canaries that must never appear. +func nugetWriteFixture(t *testing.T, home, root string) { + t.Helper() + app := filepath.Join(root, "app") + packages := filepath.Join(home, ".nuget", "packages") + userConfigDir := filepath.Join(home, ".nuget", "NuGet") + if runtime.GOOS == model.PlatformWindows { + userConfigDir = filepath.Join(home, "AppData", "Roaming", "NuGet") + } + goWrite(t, filepath.Join(root, "Directory.Build.props"), `net8.0`, 0o600) + goWrite(t, filepath.Join(root, "Directory.Packages.props"), `trueall`, 0o600) + goWrite(t, filepath.Join(app, "App.csproj"), ` + + net8.0;net9.0 + false + true + $(UnknownProperty)/packages + + + + + + + + + + + +`, 0o600) + goWrite(t, filepath.Join(app, "Program.cs"), "#:package Ignored@1.0.0\nSystem.Console.WriteLine();\n", 0o600) + goWrite(t, filepath.Join(app, "packages.lock.json"), `{"version":3,"net8.0":{"framework":".NETCoreApp,Version=v8.0","dependencies":{"Newtonsoft.Json":{"type":"Direct","requested":"[13.0.1, )","resolved":"13.0.1","contentHash":"`+nugetHashA+`"},"Transitive.Dep":{"type":"Transitive","resolved":"2.0.0","contentHash":"not-base64"},"App":{"type":"Project"}}}}`, 0o600) + goWrite(t, filepath.Join(app, "obj", "project.assets.json"), `{"version":3,"targets":{"net8.0":{"Newtonsoft.Json/13.0.1":{"type":"package"},"Transitive.Dep/2.0.0":{"type":"package"},"App/1.0.0":{"type":"project"}},"net8.0/linux-x64":{"Newtonsoft.Json/13.0.1":{"type":"package"}}},"libraries":{"Newtonsoft.Json/13.0.1":{"sha512":"`+nugetHashA+`","type":"package","path":"newtonsoft.json/13.0.1"},"Transitive.Dep/2.0.0":{"sha512":"`+nugetHashB+`","type":"package","path":"transitive.dep/2.0.0"},"App/1.0.0":{"type":"project"}},"packageFolders":{"`+nugetJSONPath(packages)+`":{}},"project":{"restore":{"projectPath":"`+nugetJSONPath(filepath.Join(app, "App.csproj"))+`","packagesPath":"`+nugetJSONPath(packages)+`","configFilePaths":["`+nugetJSONPath(filepath.Join(app, "NuGet.Config"))+`","`+nugetJSONPath(filepath.Join(root, "NuGet.Config"))+`"]},"frameworks":{"net8.0":{"targetAlias":"net8.0","framework":".NETCoreApp,Version=v8.0","dependencies":{"Newtonsoft.Json":{"version":"[13.0.1, )"}}}}}}`, 0o600) + goWrite(t, filepath.Join(app, "bin", "Debug", "net8.0", "App.deps.json"), `{"runtimeTarget":{"name":".NETCoreApp,Version=v8.0"},"targets":{},"libraries":{"App/1.0.0":{"type":"project"},"Newtonsoft.Json/13.0.1":{"type":"package","sha512":"sha512-`+nugetHashA+`","path":"newtonsoft.json/13.0.1"}}}`, 0o600) + goWrite(t, filepath.Join(app, "NuGet.Config"), ` + + + + + + + + + + +`, 0o600) + legacy := filepath.Join(root, "legacy") + goWrite(t, filepath.Join(legacy, "packages.config"), ``, 0o600) + goWrite(t, filepath.Join(legacy, "packages", "Newtonsoft.Json.13.0.1", "Newtonsoft.Json.13.0.1.nuspec"), nugetNuspec("Newtonsoft.Json", "13.0.1"), 0o600) + goMkdir(t, filepath.Join(legacy, "packages", "Empty.Legacy.1.0.0")) + goWrite(t, filepath.Join(root, "tools", ".config", "dotnet-tools.json"), `{"version":1,"isRoot":true,"tools":{"dotnet-format":{"version":"8.0.0","commands":["dotnet-format"]},"unresolved-tool":{"version":"1.0.0","commands":["unresolved"]}}}`, 0o600) + goWrite(t, filepath.Join(root, "scripts", "app.cs"), "#:sdk Microsoft.NET.Sdk.Web\n#:sdk Aspire.AppHost.Sdk@9.0.0\n#:package Humanizer@2.14.1\nSystem.Console.WriteLine();\n", 0o600) + goWrite(t, filepath.Join(root, "scripts", "script.fsx"), "#r \"nuget: FSharp.Data, 6.3.0\"\nlet x = 1\n", 0o600) + goWrite(t, filepath.Join(root, "scripts", "plain.cs"), "System.Console.WriteLine();\n", 0o600) + goWrite(t, filepath.Join(root, "scripts", "NuGet.Config"), ``, 0o600) + goWrite(t, filepath.Join(home, "scriptcache", "example.cached", "1.0.0", "example.cached.nuspec"), nugetNuspec("Example.Cached", "1.0.0"), 0o600) + goWrite(t, filepath.Join(home, "scriptcache", "example.cached", "1.0.0", ".nupkg.metadata"), `{"version":2,"contentHash":"`+nugetHashB+`","source":"https://api.nuget.org/v3/index.json"}`, 0o600) + goWrite(t, filepath.Join(root, "orphan", "obj", "project.assets.json"), `{"version":3,"targets":{"net8.0":{"Orphaned/1.0.0":{"type":"package"}}},"libraries":{"Orphaned/1.0.0":{"sha512":"`+nugetHashB+`","type":"package","path":"orphaned/1.0.0"}},"packageFolders":{"`+nugetJSONPath(packages)+`":{}},"project":{"restore":{"projectPath":"`+nugetJSONPath(filepath.Join(root, "gone", "Gone.csproj"))+`"}}}`, 0o600) + // Pack output beside a project is still a project; an extracted package is not. + goWrite(t, filepath.Join(root, "packed", "Packed.1.0.0.nupkg"), "", 0o600) + goWrite(t, filepath.Join(root, "packed", "Packed.csproj"), `pkgs`, 0o600) + goWrite(t, filepath.Join(root, "packed", "Directory.Build.targets"), `false`, 0o600) + goWrite(t, filepath.Join(root, "packed", "pkgs", "custom.pkg", "1.0.0", "custom.pkg.nuspec"), nugetNuspec("Custom.Pkg", "1.0.0"), 0o600) + goWrite(t, filepath.Join(root, "packed", "pkgs", "custom.pkg", "1.0.0", ".nupkg.metadata"), `{"version":1,"contentHash":"`+nugetHashA+`"}`, 0o600) + // A shared build file: its reference stays unparsed (coverage partial), and + // its relative cache path belongs to the consuming project, not the file. + goWrite(t, filepath.Join(root, "shared", "Directory.Build.props"), `packages$(MSBuildThisFileDirectory)locks/shared.lock.json`, 0o600) + goWrite(t, filepath.Join(root, "shared", "lib", "Lib.csproj"), `net8.0`, 0o600) + goWrite(t, filepath.Join(root, "shared", "lib", "packages", "shared.cached", "1.0.0", "shared.cached.nuspec"), nugetNuspec("Shared.Cached", "1.0.0"), 0o600) + goWrite(t, filepath.Join(root, "shared", "lib", "packages", "shared.cached", "1.0.0", ".nupkg.metadata"), `{"version":1,"contentHash":"`+nugetHashA+`"}`, 0o600) + // An unreadable consumer of the central file leaves its shared declarations unproven. + goWrite(t, filepath.Join(root, "corrupt", "Corrupt.csproj"), ``, 0o600) + goWrite(t, filepath.Join(root, "extracted", "Extracted.1.0.0.nupkg"), "", 0o600) + goWrite(t, filepath.Join(root, "extracted", "content", "Sample.csproj"), ``, 0o600) + goWrite(t, filepath.Join(packages, "newtonsoft.json", "13.0.1", "newtonsoft.json.nuspec"), nugetNuspec("Newtonsoft.Json", "13.0.1"), 0o600) + goWrite(t, filepath.Join(packages, "newtonsoft.json", "13.0.1", ".nupkg.metadata"), `{"version":2,"contentHash":"`+nugetHashA+`","source":"https://user:CANARY_PASSWORD@api.nuget.org/v3/index.json?CANARY_QUERY"}`, 0o600) + goWrite(t, filepath.Join(packages, "newtonsoft.json", "13.0.1", "newtonsoft.json.13.0.1.nupkg.sha512"), nugetHashB+"\n", 0o600) + goWrite(t, filepath.Join(packages, "transitive.dep", "2.0.0", "transitive.dep.nuspec"), nugetNuspec("Transitive.Dep", "2.0.0"), 0o600) + goWrite(t, filepath.Join(packages, "transitive.dep", "2.0.0", ".nupkg.metadata"), `{"version":2,"contentHash":"`+nugetHashB+`","source":"`+nugetJSONPath(filepath.Join(home, "feed"))+`"}`, 0o600) + goWrite(t, filepath.Join(packages, "archived", "2.0.0", "archived.2.0.0.nupkg"), nugetArchive(t, nugetNuspec("Archived", "2.0.0")), 0o600) + goWrite(t, filepath.Join(packages, "archived", "2.0.0", ".nupkg.metadata"), `{"version":1,"contentHash":"`+nugetHashB+`"}`, 0o600) + goWrite(t, filepath.Join(packages, "mismatch", "1.0.0", "mismatch.nuspec"), nugetNuspec("Other", "1.0.0"), 0o600) + goWrite(t, filepath.Join(packages, "bare", "1.0.0", "bare.nuspec"), nugetNuspec("Bare", "1.0.0"), 0o600) + goWrite(t, filepath.Join(userConfigDir, "NuGet.Config"), ``, 0o600) + goWrite(t, filepath.Join(userConfigDir, "config", "signers.config"), ``, 0o600) + store := filepath.Join(home, ".dotnet", "tools", ".store") + goWrite(t, filepath.Join(store, "dotnet-format", "8.0.0", "project.assets.json"), `{"version":3,"targets":{".NETCoreApp,Version=v8.0/any":{"dotnet-format/8.0.0":{"type":"package"},"Humanizer/2.14.1":{"type":"package"}}},"libraries":{"dotnet-format/8.0.0":{"sha512":"`+nugetHashA+`","type":"package","path":"dotnet-format/8.0.0"},"Humanizer/2.14.1":{"sha512":"`+nugetHashB+`","type":"package","path":"humanizer/2.14.1"}},"packageFolders":{"`+nugetJSONPath(store)+`":{}}}`, 0o600) + goWrite(t, filepath.Join(store, "dotnet-format", "8.0.0", "dotnet-format", "8.0.0", "tools", "net8.0", "any", "DotnetToolSettings.xml"), ``, 0o600) + goWrite(t, filepath.Join(store, ".stage", "ignored"), "", 0o600) + goWrite(t, filepath.Join(home, ".dotnet", "tools", nugetTestToolName("dotnet-format")), "#!/bin/sh\n", 0o700) + // A RID-specific tool: the SDK 10 pointer receipt plus the payload receipt + // that names the platform package holding the commands. + goWrite(t, filepath.Join(store, "example.ridcli", "1.0.0", "project.assets.json"), `{"version":3,"targets":{"net10.0/osx-arm64":{"Example.RidCli/1.0.0":{"type":"package"}}},"libraries":{},"packageFolders":{"`+nugetJSONPath(store)+`":{}}}`, 0o600) + goWrite(t, filepath.Join(store, "example.ridcli", "1.0.0", "project.assets.ridpackage.json"), `{"version":3,"targets":{"net10.0/osx-arm64":{"Example.RidCli.osx-arm64/1.0.0":{"type":"package"}}},"libraries":{},"packageFolders":{"`+nugetJSONPath(store)+`":{}}}`, 0o600) + goWrite(t, filepath.Join(store, "example.ridcli", "1.0.0", "Example.RidCli.osx-arm64", "1.0.0", "tools", "net10.0", "osx-arm64", "DotnetToolSettings.xml"), ``, 0o600) + goWrite(t, filepath.Join(home, ".dotnet", "tools", nugetTestToolName("ridcli")), "#!/bin/sh\n", 0o700) + goWrite(t, filepath.Join(home, ".dotnet", "toolResolverCache", "1", "dotnet-format"), `[{"Version":"8.0.0","TargetFramework":"net8.0","RuntimeIdentifier":"any","Name":"dotnet-format","PathToExecutable":"`+nugetJSONPath(filepath.Join(packages, "dotnet-format", "8.0.0", "tools", "net8.0", "any", "dotnet-format.dll"))+`"}]`, 0o600) + goWrite(t, filepath.Join(home, ".templateengine", "packages", "Example.Templates.1.0.0.nupkg"), nugetArchive(t, nugetNuspec("Example.Templates", "1.0.0")), 0o600) + goWrite(t, filepath.Join(home, ".templateengine", "packages", "Broken.Templates.1.0.0.nupkg"), "", 0o600) + goWrite(t, filepath.Join(home, ".templateengine", "packages", "Mismatch.Templates.1.0.0.nupkg"), nugetArchive(t, nugetNuspec("Other.Templates", "1.0.0")), 0o600) + goWrite(t, filepath.Join(home, ".templateengine", "packages.json"), `{"Packages":[{"MountPointUri":"`+nugetJSONPath(filepath.Join(home, ".templateengine", "packages", "Example.Templates.1.0.0.nupkg"))+`","Details":{"PackageId":"Example.Templates","Version":"1.0.0","NuGetSource":"https://api.nuget.org/v3/index.json"}},{"MountPointUri":"`+nugetJSONPath(filepath.Join(home, ".templateengine", "packages", "Broken.Templates.1.0.0.nupkg"))+`","Details":{"PackageId":"Broken.Templates","Version":"1.0.0","NuGetSource":""}},{"MountPointUri":"`+nugetJSONPath(filepath.Join(home, ".templateengine", "packages", "Mismatch.Templates.1.0.0.nupkg"))+`","Details":{"PackageId":"Mismatch.Templates","Version":"1.0.0","NuGetSource":""}}]}`, 0o600) +} + +func nugetFixtureEnv(x *composerTrap) { + x.env["HTTPS_PROXY"] = "https://user:CANARY_PASSWORD@proxy.example.invalid:8443/?CANARY_QUERY" + x.env["NO_PROXY"] = "localhost,.example.invalid" + x.env["NuGetPackageSourceCredentials_internal"] = "Username=CANARY_USER;Password=CANARY_PASSWORD" + x.env["NUGET_PLUGIN_PATHS"] = "/opt/CANARY_PLUGIN" +} + +func nugetAssertRefs(t *testing.T, inv *model.NuGetInventory, audit *model.NuGetConfigAudit) { + t.Helper() + ids := map[string]bool{} + for _, s := range inv.Sources { + if ids[s.SourceID] { + t.Error("duplicate source") + } + ids[s.SourceID] = true + } + ref := func(id string) { + if !ids[id] { + t.Errorf("dangling inventory source %s", id) + } + } + for _, s := range inv.Sources { + if s.ParentSourceID != "" { + ref(s.ParentSourceID) + } + for _, id := range s.DiscoveredSources { + ref(id) + } + } + for _, p := range inv.Packages { + ref(p.SourceID) + for _, hash := range p.RecordedChecksums { + ref(hash.SourceID) + } + } + for _, p := range inv.Projects { + ref(p.ManifestSourceID) + } + files := map[string]bool{} + for _, f := range audit.Files { + files[f.SourceID] = true + } + for _, c := range audit.Contexts { + for _, id := range c.ConfigSourceIDs { + if !files[id] { + t.Error("dangling config context") + } + } + } + for _, f := range audit.Findings { + if !files[f.SourceID] { + t.Error("dangling finding") + } + } + for _, v := range []any{inv, audit} { + data, _ := json.Marshal(v) + if bytes.Contains(data, []byte(":null")) { + t.Fatal("null array") + } + if bytes.Contains(data, []byte("CANARY_")) { + t.Fatal("output contains a credential canary") + } + } +} + +func nugetSource(t *testing.T, inv *model.NuGetInventory, kind, path string) model.NuGetSource { + t.Helper() + for _, src := range inv.Sources { + if src.Kind == kind && src.Path == path { + return src + } + } + t.Fatalf("no %s source for %s", kind, path) + return model.NuGetSource{} +} + +func nugetPackages(inv *model.NuGetInventory, evidence, name string) []model.NuGetPackage { + var out []model.NuGetPackage + for _, p := range inv.Packages { + if p.Evidence == evidence && (name == "" || p.PackageName == name) { + out = append(out, p) + } + } + return out +} + +func nugetProject(t *testing.T, inv *model.NuGetInventory, manifest string) model.NuGetProject { + t.Helper() + for _, p := range inv.Projects { + if p.ManifestPath == manifest { + return p + } + } + t.Fatalf("no project for %s", manifest) + return model.NuGetProject{} +} + +func TestNuGetScanFixtureEvidence(t *testing.T) { + home := goTestHome(t) + root := filepath.Join(home, "code") + nugetWriteFixture(t, home, root) + scanner, x := nugetTestScanner(t, home) + nugetFixtureEnv(x) + inv, audit := scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + nugetAssertRefs(t, inv, audit) + app := filepath.Join(root, "app") + packages := filepath.Join(home, ".nuget", "packages") + + project := nugetProject(t, inv, filepath.Join(app, "App.csproj")) + if !slices.Equal(project.TargetFrameworks, []string{"net8.0", "net9.0"}) || project.AssetsPath != filepath.Join(app, "obj", "project.assets.json") || project.LockfilePath != filepath.Join(app, "packages.lock.json") || project.SelectionStatus != "resolved" { + t.Errorf("project = %+v", project) + } + declared := map[string]model.NuGetPackage{} + for _, p := range nugetPackages(inv, "declared_requirement", "") { + if p.ProjectPath == app { + declared[p.PackageName] = p + } + } + if p := declared["newtonsoft.json"]; p.RequestedVersion != "13.0.1" || p.DeclarationKind != "package_reference" || p.PrivateAssets != "all" || p.VersionStatus != "unknown" || p.DependencyRelation != "direct" { + t.Errorf("package reference = %+v", p) + } + if p := declared["central"]; p.RequestedVersion != "1.0.0" { + t.Errorf("central version not applied: %+v", p) + } + if p := declared["missing"]; p.RequestedVersion != "" || !slices.Contains(p.Reasons, "context_unresolved") { + t.Errorf("missing version = %+v", p) + } + if p := declared["expr"]; p.RequestedVersion != "" || !slices.Contains(p.Reasons, "context_unresolved") { + t.Errorf("property expression leaked: %+v", p) + } + if p := declared["downloaded"]; p.Version != "" || p.VersionStatus != "unknown" || p.RequestedVersion != "[1.0.0]" || p.DeclarationKind != "package_download" { + t.Errorf("package download = %+v", p) + } + if p := declared["conditional"]; !p.Conditional || p.RequestedVersion != "1.0.0-Beta" { + t.Errorf("conditional = %+v", p) + } + if _, ok := declared["microsoft.net.sdk"]; ok { + t.Error("SDK without an explicit version recorded as a declaration") + } + if p := declared["analyzer"]; p.DeclarationKind != "global_package_reference" || p.SourcePath != filepath.Join(root, "Directory.Packages.props") || p.RequestedVersion != "2.0.0" { + t.Errorf("global package reference = %+v", p) + } + if _, ok := declared["ignored"]; ok { + t.Error("source file beside a project treated as a file-based app") + } + packed := nugetProject(t, inv, filepath.Join(root, "packed", "Packed.csproj")) + if !slices.Equal(packed.TargetFrameworks, []string{"net8.0"}) || packed.SelectionStatus != "resolved" { + t.Errorf("Directory.Build.props not layered under packed project: %+v", packed) + } + source := func(path string) model.NuGetSource { + for _, s := range inv.Sources { + if s.Path == path { + return s + } + } + t.Fatalf("no source for %s", path) + return model.NuGetSource{} + } + // An unparsed shared reference is partial coverage for the file and its consumer, never absence. + if lib := nugetProject(t, inv, filepath.Join(root, "shared", "lib", "Lib.csproj")); lib.SelectionStatus != "partial" || lib.LockfilePath != "" { + t.Errorf("consumer of a deferred shared reference = %+v", lib) + } + if s := source(filepath.Join(root, "shared", "lib", "Lib.csproj")); s.Status != "partial" || !slices.Equal(s.Reasons, []string{"unsupported_format"}) { + t.Errorf("consumer manifest still proves absence of a moved reference: %+v", s) + } + if s := source(filepath.Join(root, "shared", "Directory.Build.props")); s.Status != "partial" || !slices.Equal(s.Reasons, []string{"unsupported_format"}) { + t.Errorf("shared build file = %+v", s) + } + if _, ok := declared["shared.analyzer"]; ok { + t.Error("imported PackageReference emitted without an evaluator") + } + // A consumer that cannot be read may still consume the central file's declarations. + if s := source(filepath.Join(root, "Directory.Packages.props")); s.Status != "partial" || !slices.Equal(s.Reasons, []string{"parse_error"}) { + t.Errorf("central file with an unreadable consumer = %+v", s) + } + + locked := nugetPackages(inv, "locked_package", "") + if len(locked) != 2 { + t.Fatalf("locked = %+v", locked) + } + if p := locked[0]; p.PackageName != "newtonsoft.json" || p.Version != "13.0.1" || p.LockDependencyType != "Direct" || p.DependencyRelation != "direct" || p.FrameworkAlias != "net8.0" || p.ChecksumStatus != "recorded" || p.RecordedChecksums[0].Kind != "content" || p.RecordedChecksums[0].SourceKind != "lockfile" { + t.Errorf("locked direct = %+v", p) + } + if p := locked[1]; p.ChecksumStatus != "partial" || !slices.Contains(p.Reasons, "checksum_invalid") || p.DependencyRelation != "transitive" { + t.Errorf("locked transitive = %+v", p) + } + + restored := nugetPackages(inv, "restored_package", "newtonsoft.json") + var fromAssets []model.NuGetPackage + for _, p := range restored { + if p.SourcePath == project.AssetsPath { + fromAssets = append(fromAssets, p) + } + } + if len(fromAssets) != 2 { + t.Fatalf("restored newtonsoft rows = %+v", restored) + } + for _, p := range fromAssets { + if p.Scope != "project" || p.ProjectPath != app || p.DependencyRelation != "direct" || p.Origin.Kind != "feed" || p.Origin.URL != "https://api.nuget.org/v3/index.json" || p.Installation == nil || p.Installation.Presence != "present" || p.TargetFramework != ".NETCoreApp,Version=v8.0" { + t.Errorf("restored = %+v origin = %+v installation = %+v", p, p.Origin, p.Installation) + } + } + if fromAssets[0].RuntimeIdentifier != "" || fromAssets[1].RuntimeIdentifier != "linux-x64" { + t.Errorf("runtime identifiers = %q %q", fromAssets[0].RuntimeIdentifier, fromAssets[1].RuntimeIdentifier) + } + if p := nugetPackages(inv, "restored_package", "transitive.dep"); len(p) != 1 || p[0].DependencyRelation != "transitive" || p[0].Origin.Kind != "local" || p[0].Origin.Path != filepath.Join(home, "feed") { + t.Errorf("transitive restored = %+v", p) + } + if p := nugetPackages(inv, "restored_package", "orphaned"); len(p) != 1 || p[0].Scope != "unknown" || p[0].ProjectPath != filepath.Join(root, "gone") || p[0].DependencyRelation != "unknown" || p[0].Installation == nil || p[0].Installation.Presence != "unknown" { + t.Errorf("orphan restored = %+v", p) + } + for _, name := range []string{"absent.legacy", "empty.legacy"} { + if p := nugetPackages(inv, "restored_package", name); len(p) != 0 { + t.Errorf("%s reported as restored without a readable package", name) + } + } + legacy := nugetProject(t, inv, filepath.Join(root, "legacy", "packages.config")) + if legacy.ProjectKind != "legacy" || legacy.PackagesPath != filepath.Join(root, "legacy", "packages") { + t.Errorf("legacy project = %+v", legacy) + } + if src := nugetSource(t, inv, "legacy_manifest", legacy.ManifestPath); !slices.Equal(src.Reasons, []string{"extraction_incomplete"}) { + t.Errorf("empty legacy folder not reported: %+v", src) + } + var legacyRestored []model.NuGetPackage + for _, p := range nugetPackages(inv, "restored_package", "newtonsoft.json") { + if p.SourcePath == legacy.ManifestPath { + legacyRestored = append(legacyRestored, p) + } + } + if len(legacyRestored) != 1 || legacyRestored[0].Installation.Path != filepath.Join(legacy.PackagesPath, "Newtonsoft.Json.13.0.1") || legacyRestored[0].DevelopmentDependency == nil { + t.Errorf("legacy restored = %+v", legacyRestored) + } + + cache := nugetSource(t, inv, "package_cache", packages) + if cache.Status != "partial" || !slices.Equal(cache.Reasons, []string{"identity_mismatch"}) { + t.Errorf("cache source = %+v", cache) + } + cached := map[string]model.NuGetPackage{} + for _, p := range nugetPackages(inv, "cached_package", "") { + cached[p.PackageName] = p + } + if len(cached) != 7 { + t.Errorf("cached = %+v", cached) + } + // A shared relative RestorePackagesPath resolves beside the consuming project. + if p := cached["shared.cached"]; p.Scope != "custom" || p.SourcePath != filepath.Join(root, "shared", "lib", "packages") { + t.Errorf("cached shared = %+v", p) + } + // A cache named only by a script's adjacent NuGet.Config is still read. + if p := cached["example.cached"]; p.Scope != "custom" || p.SourcePath != filepath.Join(home, "scriptcache") || p.Origin.Kind != "feed" { + t.Errorf("cached script = %+v", p) + } + if p := cached["custom.pkg"]; p.Scope != "custom" || p.SourcePath != filepath.Join(root, "packed", "pkgs") || p.ChecksumStatus != "recorded" { + t.Errorf("project RestorePackagesPath cache = %+v", p) + } + if p := cached["newtonsoft.json"]; p.Scope != "user" || len(p.RecordedChecksums) != 2 || p.RecordedChecksums[0].Kind != "content" || p.RecordedChecksums[1].Kind != "archive" || p.Origin.Kind != "feed" { + t.Errorf("cached newtonsoft = %+v", p) + } + if p := cached["archived"]; p.Version != "2.0.0" || p.Origin.Kind != "unknown" || p.ChecksumStatus != "recorded" { + t.Errorf("cached archived = %+v", p) + } + if p := cached["bare"]; !slices.Contains(p.Reasons, "extraction_incomplete") || p.ChecksumStatus != "not_recorded" { + t.Errorf("cached bare = %+v", p) + } + + tools := nugetPackages(inv, "installed_tool", "") + if len(tools) != 5 { + t.Fatalf("installed tools = %+v", tools) + } + for _, p := range tools { + switch { + case p.PackageName == "dotnet-format" && p.Scope == "user": + if !slices.Equal(p.ToolCommands, []string{"dotnet-format"}) || p.Installation == nil || p.Installation.Presence != "present" || p.Installation.Path != filepath.Join(home, ".dotnet", "tools", nugetTestToolName("dotnet-format")) || p.RuntimeIdentifier != "any" { + t.Errorf("global tool = %+v", p) + } + case p.PackageName == "example.ridcli", p.PackageName == "example.ridcli.osx-arm64": + if p.DependencyRelation != "direct" || !slices.Equal(p.ToolCommands, []string{"ridcli"}) || p.Installation == nil || p.Installation.Presence != "present" || p.RuntimeIdentifier != "osx-arm64" || p.FrameworkAlias != "net10.0" { + t.Errorf("rid tool = %+v", p) + } + case p.PackageName == "humanizer": + if p.DependencyRelation != "transitive" || p.ToolCommands != nil { + t.Errorf("tool dependency = %+v", p) + } + case p.PackageName == "dotnet-format" && p.Scope == "project": + if p.Installation == nil || p.Installation.Presence != "absent" || p.SourcePath != filepath.Join(root, "tools", ".config", "dotnet-tools.json") { + t.Errorf("local tool = %+v", p) + } + default: + t.Errorf("unexpected tool = %+v", p) + } + } + if p := nugetPackages(inv, "declared_requirement", "unresolved-tool"); len(p) != 1 || p[0].Version != "1.0.0" || p[0].ProjectPath != filepath.Join(root, "tools") { + t.Errorf("unresolved tool = %+v", p) + } + if p := nugetPackages(inv, "installed_template", "example.templates"); len(p) != 1 || p[0].Origin.Kind != "feed" || p[0].Installation == nil || p[0].Installation.Presence != "present" || len(p[0].Reasons) != 0 { + t.Errorf("template = %+v", p) + } + // A receipt without a readable matching archive proves nothing installed. + if p := nugetPackages(inv, "installed_template", ""); len(p) != 1 { + t.Errorf("unverified template archives accepted: %+v", p) + } + if s := source(filepath.Join(home, ".templateengine", "packages.json")); s.Status != "partial" || !slices.Equal(s.Reasons, []string{"identity_mismatch", "parse_error"}) { + t.Errorf("template receipt = %+v", s) + } + if p := nugetPackages(inv, "binary_dependency", ""); len(p) != 1 || p[0].Scope != "project" || p[0].ProjectPath != app || p[0].ChecksumStatus != "recorded" { + t.Errorf("binary dependency = %+v", p) + } + if p := nugetProject(t, inv, filepath.Join(root, "scripts", "app.cs")); p.ProjectKind != "file_based" { + t.Errorf("file-based app = %+v", p) + } + if p := nugetProject(t, inv, filepath.Join(root, "scripts", "script.fsx")); p.ProjectKind != "fsharp_script" { + t.Errorf("fsharp script = %+v", p) + } + if p := nugetPackages(inv, "declared_requirement", "humanizer"); len(p) != 1 || p[0].RequestedVersion != "2.14.1" { + t.Errorf("script package = %+v", p) + } + if p := nugetPackages(inv, "declared_requirement", "aspire.apphost.sdk"); len(p) != 1 || p[0].DeclarationKind != "msbuild_sdk" || p[0].RequestedVersion != "9.0.0" { + t.Errorf("versioned script sdk = %+v", p) + } + if p := nugetPackages(inv, "declared_requirement", "microsoft.net.sdk.web"); len(p) != 0 { + t.Error("unversioned #:sdk recorded as a package") + } + for _, src := range inv.Sources { + if strings.HasSuffix(src.Path, "plain.cs") || strings.Contains(src.Path, "extracted") { + t.Errorf("unexpected source %s", src.Path) + } + } + + codes := map[string]int{} + for _, f := range audit.Findings { + codes[f.Code]++ + } + if codes["nuget-001"] != 2 || codes["nuget-002"] != 1 || codes["nuget-003"] != 1 || codes["nuget-004"] != 2 { + t.Errorf("findings = %+v", audit.Findings) + } + settings := func(path string) map[string]string { + out := map[string]string{} + for _, f := range audit.Files { + if f.Path == path { + for _, s := range f.Settings { + out[s.Key] = s.Display + } + } + } + return out + } + scriptsConfig := configaudit.GoSourceID(goTestTarget(home).Username, "nuget_config_project", filepath.Join(root, "scripts", "NuGet.Config")) + kinds := map[string]int{} + for _, c := range audit.Contexts { + kinds[c.ContextKind]++ + if c.ContextKind == "restore" && (c.RestoreMetadataPath != project.AssetsPath || len(c.ConfigSourceIDs) != 2 || c.Status != "complete" || len(c.Reasons) != 0) { + t.Errorf("restore context = %+v", c) + } + if c.ContextKind == "ambient" && c.ProjectPath == app && (c.ConfigSourceIDs[0] != audit.Files[0].SourceID && audit.Files[0].Scope != "process" || c.Status != "complete") { + t.Errorf("ambient context = %+v", c) + } + if c.ContextKind == "ambient" && c.ProjectPath == filepath.Join(root, "scripts") && !slices.Contains(c.ConfigSourceIDs, scriptsConfig) { + t.Errorf("script context misses its adjacent config: %+v", c) + } + if c.ContextKind == "ambient" && c.ProjectPath == filepath.Join(root, "shared", "lib") && (c.Status != "partial" || !slices.Equal(c.Reasons, []string{"unsupported_format"})) { + t.Errorf("context over a deferred shared reference = %+v", c) + } + } + // One ambient context per project directory: app, corrupt, legacy, packed, shared/lib, tools, scripts. + if kinds["restore"] != 1 || kinds["ambient"] != 7 { + t.Errorf("context kinds = %v", kinds) + } + if projectSettings := settings(filepath.Join(app, "App.csproj")); projectSettings["project.NuGetAudit"] != "false" || projectSettings["project.RestoreLockedMode"] != "true" || projectSettings["project.RestorePackagesPath"] != "" || !slices.Contains(audit.Reasons, "path_unresolved") { + t.Errorf("project settings = %+v reasons = %v", projectSettings, audit.Reasons) + } + if got := settings(filepath.Join(root, "packed", "Directory.Build.targets")); got["project.NuGetAudit"] != "false" { + t.Errorf("Directory.Build.targets settings = %+v", got) + } + // Shared paths: $(MSBuildThisFileDirectory) anchors to the file; anything else to each consumer. + shared, lib := settings(filepath.Join(root, "shared", "Directory.Build.props")), settings(filepath.Join(root, "shared", "lib", "Lib.csproj")) + if shared["project.RestorePackagesPath"] != "" || shared["project.NuGetLockFilePath"] != filepath.Join(root, "shared", "locks", "shared.lock.json") { + t.Errorf("shared build file settings = %+v", shared) + } + if lib["project.RestorePackagesPath"] != filepath.Join(root, "shared", "lib", "packages") || lib["project.NuGetLockFilePath"] != filepath.Join(root, "shared", "locks", "shared.lock.json") { + t.Errorf("consumer settings = %+v", lib) + } +} + +func TestNuGetScanUnknownProcessAndDeveloper(t *testing.T) { + home := goTestHome(t) + root := filepath.Join(home, "code") + nugetWriteFixture(t, home, root) + scanner, x := nugetTestScanner(t, home) + x.current.Uid = "1001" + x.env["NUGET_PACKAGES"] = filepath.Join(home, "elsewhere") + inv, audit := scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + nugetAssertRefs(t, inv, audit) + for _, f := range audit.Files { + if f.Scope == "process" && (f.Status != "unsupported" || !slices.Contains(f.Reasons, "process_context_unverified")) { + t.Errorf("process file = %+v", f) + } + } + nugetSource(t, inv, "package_cache", filepath.Join(home, ".nuget", "packages")) + for _, c := range audit.Contexts { + if c.Status != "partial" || !slices.Contains(c.Reasons, "process_context_unverified") { + t.Errorf("unverified process left %s context complete: %+v", c.ContextKind, c) + } + } + scanner, x = nugetTestScanner(t, home) + target := goTestTarget(home) + target.Uid = "0" + if x.GOOS() == model.PlatformWindows { + target.Uid = "S-1-5-18" + } + x.current = target + inv, audit = scanner.Scan(context.Background(), target, []string{root}, nil) + if inv.Status != "partial" || !slices.Equal(inv.Reasons, []string{"user_unresolved"}) || len(inv.Sources) != 0 || audit.Status != "partial" { + t.Fatalf("service identity scanned: %+v", inv) + } +} + +func TestNuGetScanLimitsDeadlineAndGuards(t *testing.T) { + home := goTestHome(t) + root := filepath.Join(home, "code") + app := filepath.Join(root, "app") + nugetWriteFixture(t, home, root) + scanner, x := nugetTestScanner(t, home) + nugetFixtureEnv(x) + full, _ := scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + total := len(full.Sources) + len(full.Projects) + len(full.Packages) + old := maxNuGetRecords + t.Cleanup(func() { maxNuGetRecords = old }) + script := filepath.Join(root, "scripts", "app.cs") + // Only script evidence and the cache a script's config names come after + // the scripts, so everything else is charged before any script is probed. + beforeScripts := total + scriptCache := nugetSource(t, full, "package_cache", filepath.Join(home, "scriptcache")).SourceID + for _, src := range full.Sources { + if src.Kind == "script_manifest" || src.SourceID == scriptCache { + beforeScripts-- + } + } + for _, p := range full.Projects { + if p.ProjectKind == "file_based" || p.ProjectKind == "fsharp_script" { + beforeScripts-- + } + } + for _, p := range full.Packages { + if p.SourceID == scriptCache || p.SourceID == nugetSource(t, full, "script_manifest", script).SourceID || p.SourceID == nugetSource(t, full, "script_manifest", filepath.Join(root, "scripts", "script.fsx")).SourceID { + beforeScripts-- + } + } + owned := nugetOwned(full) + // Every budget boundary: an assets file queued without a record must not + // be dereferenced, an exhausted budget must stop script probes, and a + // source left complete must prove every observation it owns. + for budget := 1; budget < total; budget++ { + maxNuGetRecords = budget + x.reads = map[string]int{} + inv, audit := scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + if inv.Status != "partial" || !slices.Contains(inv.Reasons, "record_limit") || len(inv.Sources)+len(inv.Projects)+len(inv.Packages) > budget { + t.Fatalf("budget %d: status %s reasons %v", budget, inv.Status, inv.Reasons) + } + if budget <= beforeScripts && x.reads[script] > 0 { + t.Fatalf("budget %d: script read after the record budget was exhausted", budget) + } + nugetAssertComplete(t, budget, owned, inv) + nugetAssertRefs(t, inv, audit) + } + maxNuGetRecords = old + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + inv, audit := scanner.Scan(ctx, goTestTarget(home), []string{root}, nil) + nugetAssertRefs(t, inv, audit) + if inv.Status != "partial" || !slices.Contains(inv.Reasons, "deadline_exceeded") || len(inv.Packages) != 0 { + t.Fatalf("cancelled scan = %s %v %d packages", inv.Status, inv.Reasons, len(inv.Packages)) + } + // Cancellation after discovery must not read the queued scripts. + ctx, cancel = context.WithCancel(context.Background()) + x.reads, x.afterRead = map[string]int{}, func(path string) { + if path == filepath.Join(app, "obj", "project.assets.json") { + cancel() + } + } + inv, audit = scanner.Scan(ctx, goTestTarget(home), []string{root}, nil) + x.afterRead = nil + nugetAssertRefs(t, inv, audit) + if !slices.Contains(inv.Reasons, "deadline_exceeded") || x.reads[script] != 0 || len(nugetPackages(inv, "declared_requirement", "humanizer")) != 0 { + t.Fatalf("cancelled scan read a script: reasons %v reads %d", inv.Reasons, x.reads[script]) + } + + scanner.protection = func(string, *bool) (func(string) string, func(string) bool) { + return func(p string) string { + if strings.HasPrefix(p, filepath.Join(root, "legacy")) { + return "skipped_protected" + } + return "" + }, func(string) bool { return false } + } + inv, audit = scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + nugetAssertRefs(t, inv, audit) + if src := nugetSource(t, inv, "project_search_root", root); !slices.Contains(src.Reasons, "skipped_protected") { + t.Errorf("protected directory not reported: %+v", src) + } + for _, p := range inv.Projects { + if p.ProjectKind == "legacy" { + t.Error("protected legacy project scanned") + } + } + + // An archive that cannot be checked proves nothing installed. + scanner, x = nugetTestScanner(t, home) + nugetFixtureEnv(x) + x.denied[filepath.Join(home, ".templateengine", "packages", "Example.Templates.1.0.0.nupkg")] = os.ErrPermission + inv, audit = scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + nugetAssertRefs(t, inv, audit) + if p := nugetPackages(inv, "installed_template", ""); len(p) != 0 { + t.Errorf("unverified template emitted: %+v", p) + } + if src := nugetSource(t, inv, "template_receipt", filepath.Join(home, ".templateengine", "packages.json")); src.Status != "partial" || !slices.Contains(src.Reasons, "permission_denied") { + t.Errorf("receipt with an unreadable archive = %+v", src) + } + + // An unreadable script does not prove its previous package evidence gone. + for _, reason := range []string{"permission_denied", "size_limit"} { + t.Run("script_"+reason, func(t *testing.T) { + home := goTestHome(t) + root := filepath.Join(home, "code") + script := filepath.Join(root, "app.cs") + scanner, x := nugetTestScanner(t, home) + content := "#:package Example.Script@1.0.0" + if reason == "permission_denied" { + x.readErrors[script] = os.ErrPermission + } else { + content += strings.Repeat(" ", int(maxNuGetMetadataBytes)) + } + goWrite(t, script, content, 0o600) + inv, audit := scanner.Scan(t.Context(), goTestTarget(home), []string{root}, nil) + nugetAssertRefs(t, inv, audit) + if src := nugetSource(t, inv, "project_search_root", root); src.Status != "partial" || !slices.Contains(src.Reasons, reason) { + t.Errorf("unreadable script proves absence: %+v", src) + } + }) + } + + // Every output budget keeps admitted records and leaves only fully + // collected sources complete. + oldOut := maxNuGetOutputBytes + t.Cleanup(func() { maxNuGetOutputBytes = oldOut }) + empty := emptyNuGetInventory() + base, step := goJSONSize(&empty)+(256<<10), goJSONSize(full)/40 + for limit := base; ; limit += step { + if limit > base+4*goJSONSize(full) { + t.Fatal("output budget never admitted the full inventory") + } + maxNuGetOutputBytes = limit + inv, audit = scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + if !slices.Contains(inv.Reasons, "output_size_limit") { + break + } + if inv.Status != "partial" || goJSONSize(inv)+goJSONSize(audit) > limit { + t.Fatalf("limit %d: status %s size %d", limit, inv.Status, goJSONSize(inv)+goJSONSize(audit)) + } + nugetAssertComplete(t, limit, owned, inv) + nugetAssertRefs(t, inv, audit) + } +} + +// A large tree stops reading once the output budget is spent, without +// discarding the records admitted before it. +func TestNuGetScanOutputBudgetStopsCollection(t *testing.T) { + home := goTestHome(t) + root := filepath.Join(home, "code") + deps := map[string]any{} + for i := range 120 { + deps[fmt.Sprintf("Example.Library%03d", i)] = map[string]string{"type": "Direct", "requested": "[1.2.3, )", "resolved": "1.2.3", "contentHash": nugetHashA} + } + lock, _ := json.Marshal(map[string]any{"version": 1, "dependencies": map[string]any{"net8.0": deps}}) + for i := range 20 { + dir := filepath.Join(root, fmt.Sprintf("project%02d", i)) + goWrite(t, filepath.Join(dir, "App.csproj"), `net8.0`, 0o600) + goWrite(t, filepath.Join(dir, "packages.lock.json"), string(lock), 0o600) + } + scanner, x := nugetTestScanner(t, home) + full, _ := scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + if len(nugetPackages(full, "locked_package", "")) != 20*120 { + t.Fatalf("unbounded scan = %d packages", len(full.Packages)) + } + old := maxNuGetOutputBytes + maxNuGetOutputBytes = 512 << 10 + t.Cleanup(func() { maxNuGetOutputBytes = old }) + x.reads = map[string]int{} + inv, audit := scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + nugetAssertRefs(t, inv, audit) + if len(inv.Packages) == 0 || !slices.Contains(inv.Reasons, "output_size_limit") || goJSONSize(inv)+goJSONSize(audit) > maxNuGetOutputBytes { + t.Fatalf("output budget discarded bounded facts or exceeded the limit: %d packages, reasons %v", len(inv.Packages), inv.Reasons) + } + if src := nugetSource(t, inv, "project_search_root", root); src.Status != "partial" || !slices.Contains(src.Reasons, "output_size_limit") { + t.Fatalf("limited discovery root = %+v", src) + } + nugetAssertComplete(t, maxNuGetOutputBytes, nugetOwned(full), inv) + reads := 0 + for path, n := range x.reads { + if filepath.Base(path) == "packages.lock.json" { + reads += n + } + } + if reads >= 20 { + t.Fatal("continued reading lockfiles after the output budget was exhausted") + } + + // A legacy declaration that spends the budget must not lead to reading its + // installed package. + legacy := filepath.Join(home, "legacy") + goWrite(t, filepath.Join(legacy, "packages.config"), ``, 0o600) + nuspec := filepath.Join(legacy, "packages", "Newtonsoft.Json.13.0.1", "Newtonsoft.Json.13.0.1.nuspec") + goWrite(t, nuspec, nugetNuspec("Newtonsoft.Json", "13.0.1"), 0o600) + maxNuGetOutputBytes = old + x.reads = map[string]int{} + full, _ = scanner.Scan(context.Background(), goTestTarget(home), []string{legacy}, nil) + if x.reads[nuspec] == 0 || len(nugetPackages(full, "restored_package", "newtonsoft.json")) != 1 { + t.Fatalf("unbounded legacy scan = %+v", full.Packages) + } + empty := emptyNuGetInventory() + for limit := goJSONSize(&empty) + (256 << 10); ; limit += 16 { + maxNuGetOutputBytes = limit + x.reads = map[string]int{} + inv, audit = scanner.Scan(context.Background(), goTestTarget(home), []string{legacy}, nil) + nugetAssertRefs(t, inv, audit) + if !slices.Contains(inv.Reasons, "output_size_limit") { + break + } + if x.reads[nuspec] > 0 && len(nugetPackages(inv, "declared_requirement", "newtonsoft.json")) == 0 { + t.Fatalf("limit %d: nuspec read after the declaration exhausted the budget", limit) + } + } +} + +func nugetOwned(inv *model.NuGetInventory) map[string]int { + owned := map[string]int{} + for _, p := range inv.Packages { + owned[p.SourceID]++ + } + return owned +} + +// nugetAssertComplete checks the retirement contract: a source a bounded scan +// leaves complete must still hold everything it holds unbounded. +func nugetAssertComplete(t *testing.T, budget int, owned map[string]int, inv *model.NuGetInventory) { + t.Helper() + got := nugetOwned(inv) + for _, src := range inv.Sources { + if src.Status == "complete" && got[src.SourceID] != owned[src.SourceID] { + t.Fatalf("budget %d: complete %s %s holds %d of %d packages", budget, src.Kind, src.Path, got[src.SourceID], owned[src.SourceID]) + } + } +} + +// The last consumer of a central file can exhaust the budget mid-project, with +// no later consumer left to report the shared declarations unproven. +func TestNuGetScanLastConsumerBudget(t *testing.T) { + home := goTestHome(t) + root := filepath.Join(home, "code") + goWrite(t, filepath.Join(root, "Directory.Packages.props"), `true`, 0o600) + goWrite(t, filepath.Join(root, "app", "App.csproj"), `net8.0`, 0o600) + goWrite(t, filepath.Join(root, "app", "packages.lock.json"), `{"version":1,"dependencies":{"net8.0":{"Example.Locked":{"type":"Direct","requested":"[1.0.0, )","resolved":"1.0.0","contentHash":"`+nugetHashA+`"}}}}`, 0o600) + scanner, _ := nugetTestScanner(t, home) + full, _ := scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + if len(nugetPackages(full, "declared_requirement", "shared.analyzer")) != 1 { + t.Fatalf("unbounded scan = %+v", full.Packages) + } + owned := nugetOwned(full) + old := maxNuGetRecords + t.Cleanup(func() { maxNuGetRecords = old }) + for budget := 1; budget < len(full.Sources)+len(full.Projects)+len(full.Packages); budget++ { + maxNuGetRecords = budget + inv, audit := scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + nugetAssertRefs(t, inv, audit) + nugetAssertComplete(t, budget, owned, inv) + } +} + +func TestNuGetEffectiveAnchorsEachListEntry(t *testing.T) { + shared := "src" + file := &nugetProjectFile{properties: map[string]string{"RestoreFallbackFolders": "$(MSBuildThisFileDirectory)cache1;$(MSBuildThisFileDirectory)cache2;rel"}} + got := nugetEffective(nugetLayer{shared, file}).properties["RestoreFallbackFolders"] + want := filepath.Join(shared, "cache1") + ";" + filepath.Join(shared, "cache2") + ";rel" + if got != want { + t.Errorf("RestoreFallbackFolders = %q, want %q", got, want) + } +} + +func TestNuGetScanDeterministicAndRootDedupe(t *testing.T) { + home := goTestHome(t) + root := filepath.Join(home, "code") + nugetWriteFixture(t, home, root) + scanner, _ := nugetTestScanner(t, home) + first, firstAudit := scanner.Scan(context.Background(), goTestTarget(home), []string{root, root, filepath.Join(root, "app")}, nil) + second, secondAudit := scanner.Scan(context.Background(), goTestTarget(home), []string{root}, nil) + a, _ := json.Marshal([]any{first, firstAudit}) + b, _ := json.Marshal([]any{second, secondAudit}) + if !bytes.Equal(a, b) { + t.Fatal("nested or repeated roots changed the inventory") + } + if _, err := os.Stat(filepath.Join(home, ".nuget", "packages")); err != nil { + t.Fatal(err) + } +} + +func TestNuGetScanConfiguredAssets(t *testing.T) { + for _, name := range []string{"extensions", "intermediate", "artifacts", "relative", "already discovered", "denied", "unreadable props", "missing", "unresolved"} { + t.Run(name, func(t *testing.T) { + home := goTestHome(t) + root := filepath.Join(home, "code") + dir := filepath.Join(root, "app") + manifest := filepath.Join(dir, "App.csproj") + output := filepath.Join(home, "external output") + if name == "already discovered" { + output = filepath.Join(dir, "obj") + } + props := "" + output + "" + if name == "intermediate" { + props = "" + output + "" + } + if name == "artifacts" { + props = "true" + output + "" + output = filepath.Join(output, "obj", "App") + } + if name == "relative" { + props = "../../external output" + } + if name == "unresolved" { + props = "$(Unknown)/obj" + } + goWrite(t, filepath.Join(root, "Directory.Build.props"), ""+props+"", 0o600) + goWrite(t, manifest, `net8.0`, 0o600) + assetPath := filepath.Join(output, "project.assets.json") + if name != "missing" { + data, err := json.Marshal(map[string]any{ + "version": 3, + "targets": map[string]any{"net8.0": map[string]any{"Example.Widget/1.0.0": map[string]any{"type": "package"}}}, + "libraries": map[string]any{"Example.Widget/1.0.0": map[string]any{"type": "package", "path": "example.widget/1.0.0"}}, + "project": map[string]any{"restore": map[string]any{"projectPath": manifest}}, + }) + if err != nil { + t.Fatal(err) + } + goWrite(t, assetPath, string(data), 0o600) + } + scanner, x := nugetTestScanner(t, home) + if name == "denied" { + x.denied[assetPath] = os.ErrPermission + } + if name == "unreadable props" { + x.denied[filepath.Join(root, "Directory.Build.props")] = os.ErrPermission + } + inv, _ := scanner.Scan(t.Context(), goTestTarget(home), []string{root}, nil) + got := nugetPackages(inv, "restored_package", "example.widget") + if name == "missing" || name == "unresolved" || name == "denied" || name == "unreadable props" { + if len(got) != 0 { + t.Fatalf("unexpected restored facts: %+v", got) + } + source := nugetSource(t, inv, "project_manifest", manifest) + if (source.Status == "partial") != (name != "missing") { + t.Fatalf("source = %+v", source) + } + return + } + if len(got) != 1 || got[0].ProjectPath != dir || got[0].SourcePath != assetPath { + t.Fatalf("configured assets missing: %+v", got) + } + if p := nugetProject(t, inv, manifest); p.AssetsPath != assetPath { + t.Fatalf("assets path = %q", p.AssetsPath) + } + if x.reads[assetPath] != 1 { + t.Fatalf("assets read %d times", x.reads[assetPath]) + } + }) + } +} + +func TestNuGetScanIncompleteEvidence(t *testing.T) { + for _, mode := range []string{"disabled", "imported", "conditional", "expression", "central import"} { + t.Run(mode, func(t *testing.T) { + home := goTestHome(t) + root := filepath.Join(home, "code") + central := filepath.Join(root, "Directory.Packages.props") + goWrite(t, central, `true`, 0o600) + extra := `false` + switch mode { + case "imported": + extra = `` + case "conditional": + extra = `false` + case "expression": + extra = `$(EnableCPM)` + case "central import": + extra = `true` + goWrite(t, central, ``, 0o600) + } + manifest := filepath.Join(root, "app", "App.csproj") + goWrite(t, manifest, ``+extra+``, 0o600) + scanner, _ := nugetTestScanner(t, home) + inv, _ := scanner.Scan(t.Context(), goTestTarget(home), []string{root}, nil) + p := nugetPackages(inv, "declared_requirement", "example.lib") + if len(p) != 1 || p[0].RequestedVersion != "" || len(nugetPackages(inv, "declared_requirement", "example.analyzer")) != 0 { + t.Fatalf("central values applied: %+v", inv.Packages) + } + if mode != "disabled" { + for _, path := range []string{manifest, central} { + kind := "project_manifest" + if path == central { + kind = "central_versions" + } + if src := nugetSource(t, inv, kind, path); src.Status != "partial" || !slices.Contains(src.Reasons, "unsupported_format") { + t.Errorf("unevaluated source proves absence: %+v", src) + } + } + } + }) + } + for _, fallbackComplete := range []bool{false, true} { + c := &nugetScan{goos: "linux", cacheDone: map[string]bool{"/primary": true, "/fallback": fallbackComplete}} + p := model.NuGetPackage{PackageName: "example.lib", VersionNormalized: "1.0.0"} + c.locate(&p, []string{"/primary", "/fallback"}, "") + want := "unknown" + if fallbackComplete { + want = "absent" + } + if p.Installation.Presence != want { + t.Errorf("fallback complete %v: %+v", fallbackComplete, p.Installation) + } + } + for _, blocked := range []string{".nuget/NuGet/config", "code/NuGet.Config"} { + t.Run("config selection "+blocked, func(t *testing.T) { + home := goTestHome(t) + root := filepath.Join(home, "code") + goWrite(t, filepath.Join(root, "App.csproj"), ``, 0o600) + scanner, _ := nugetTestScanner(t, home) + scanner.protection = func(string, *bool) (func(string) string, func(string) bool) { + return func(path string) string { + if path == filepath.Join(home, filepath.FromSlash(blocked)) { + return "skipped_protected" + } + return "" + }, func(string) bool { return false } + } + _, audit := scanner.Scan(t.Context(), goTestTarget(home), []string{root}, nil) + if len(audit.Contexts) == 0 { + t.Fatal("no ambient context") + } + for _, c := range audit.Contexts { + if c.Status != "partial" || !slices.Contains(c.Reasons, "skipped_protected") { + t.Errorf("incomplete selection: %+v", c) + } + } + }) + } + t.Run("orphan receipts", func(t *testing.T) { + home := goTestHome(t) + root := filepath.Join(home, "code") + nugetWriteFixture(t, home, root) + scanner, x := nugetTestScanner(t, home) + nugetFixtureEnv(x) + for _, path := range []string{ + filepath.Join(home, ".dotnet", "tools", ".store", "dotnet-format", "8.0.0", "dotnet-format", "8.0.0", "tools", "net8.0", "any", "DotnetToolSettings.xml"), + filepath.Join(home, ".templateengine", "packages", "Example.Templates.1.0.0.nupkg"), + } { + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + } + inv, _ := scanner.Scan(t.Context(), goTestTarget(home), []string{root}, nil) + for _, p := range nugetPackages(inv, "installed_tool", "dotnet-format") { + if p.Scope == "user" { + t.Errorf("orphan tool installed: %+v", p) + } + } + if len(nugetPackages(inv, "installed_template", "example.templates")) != 0 { + t.Error("missing template archive installed") + } + }) +} diff --git a/internal/model/model.go b/internal/model/model.go index 4a164bfe..80ea43e9 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -1649,3 +1649,140 @@ type ComposerConfigContext struct { ConfigSourceIDs []string `json:"config_source_ids"` // highest precedence first SelectionStatus string `json:"selection_status"` // observed | partial } + +// NuGetInventorySchemaVersion versions the static .NET/NuGet sections. +const NuGetInventorySchemaVersion = 1 + +// NuGetSource shares Cargo's source ownership and coverage contract. Kinds: +// project_search_root, project_manifest, central_versions, project_assets, +// lockfile, legacy_manifest, script_manifest, package_cache, package_archive, +// tool_manifest, tool_store, tool_resolver_cache, template_receipt, +// binary_metadata. +type NuGetSource = CargoSource + +// NuGetInventory keeps declarations, locks, restored graphs, caches, tools, +// templates and build manifests as independent evidence. +type NuGetInventory struct { + SchemaVersion int `json:"schema_version"` + Status string `json:"status"` + Reasons []string `json:"reasons"` + Sources []NuGetSource `json:"sources"` + Projects []NuGetProject `json:"projects"` + Packages []NuGetPackage `json:"packages"` +} + +// NuGetProject is one declaration context. Target frameworks are the literal +// project values; an unresolvable value leaves the selection partial. +type NuGetProject struct { + ManifestSourceID string `json:"manifest_source_id"` + ManifestPath string `json:"manifest_path"` + ProjectPath string `json:"project_path"` + ProjectKind string `json:"project_kind"` // msbuild | legacy | file_based | fsharp_script | tool_manifest + TargetFrameworks []string `json:"target_frameworks"` + SelectionStatus string `json:"selection_status"` // resolved | partial | unresolved + AssetsPath string `json:"assets_path,omitempty"` + LockfilePath string `json:"lockfile_path,omitempty"` + PackagesPath string `json:"packages_path,omitempty"` // legacy packages directory +} + +// NuGetPackage is one fact owned by a single source. A declaration keeps its +// requested range; exact versions come only from resolved or receipt metadata. +type NuGetPackage struct { + SourceID string `json:"source_id"` + Evidence string `json:"evidence"` // declared_requirement | locked_package | restored_package | cached_package | installed_tool | installed_template | binary_dependency + PackageName string `json:"package_name"` // lowercase canonical ID + DisplayName string `json:"display_name,omitempty"` // ID as written + SourcePath string `json:"source_path"` + ProjectPath string `json:"project_path,omitempty"` + Scope string `json:"scope"` // project | user | custom | unknown + Reasons []string `json:"reasons"` + VersionStatus string `json:"version_status"` // known | unknown + Version string `json:"version,omitempty"` + VersionNormalized string `json:"version_normalized,omitempty"` // lowercase prerelease identity + RequestedVersion string `json:"requested_version,omitempty"` + DependencyRelation string `json:"dependency_relation"` // direct | transitive | unknown + DevelopmentDependency *bool `json:"development_dependency,omitempty"` + PrivateAssets string `json:"private_assets,omitempty"` + IncludeAssets string `json:"include_assets,omitempty"` + ExcludeAssets string `json:"exclude_assets,omitempty"` + FrameworkAlias string `json:"framework_alias,omitempty"` + TargetFramework string `json:"target_framework,omitempty"` + RuntimeIdentifier string `json:"runtime_identifier,omitempty"` + Conditional bool `json:"conditional,omitempty"` // declared under an MSBuild Condition + AllowedVersions string `json:"allowed_versions,omitempty"` + DeclarationKind string `json:"declaration_kind,omitempty"` // package_reference | global_package_reference | package_download | msbuild_sdk + LockDependencyType string `json:"lock_dependency_type,omitempty"` // Direct | Transitive | CentralTransitive + ToolCommands []string `json:"tool_commands,omitempty"` + Origin NuGetOrigin `json:"origin"` + Installation *NuGetInstallation `json:"installation,omitempty"` + ChecksumStatus string `json:"checksum_status"` // recorded | not_recorded | partial + RecordedChecksums []NuGetRecordedChecksum `json:"recorded_checksums"` +} + +// NuGetOrigin is an observed download source, never a configured candidate. +type NuGetOrigin struct { + Kind string `json:"kind"` // feed | local | unknown + URL string `json:"url,omitempty"` + Path string `json:"path,omitempty"` +} + +type NuGetInstallation struct { + Path string `json:"path,omitempty"` + PathStatus string `json:"path_status"` // resolved | unresolved + Presence string `json:"presence"` // present | absent | unreadable | unknown | not_applicable +} + +// NuGetRecordedChecksum distinguishes NuGet's package-content hash from the +// archive byte hash; the two differ for signed packages. +type NuGetRecordedChecksum struct { + Kind string `json:"kind"` // content | archive + Algorithm string `json:"algorithm"` // sha512 + Value string `json:"value"` // base64 + SourceID string `json:"source_id"` + SourceKind string `json:"source_kind"` + SourcePath string `json:"source_path"` + Verification string `json:"verification"` // recorded +} + +// NuGetConfigAudit follows Composer's observed-source model. Credentials are +// inline in NuGet.Config, so presence flags replace a credential-file list. +// The endpoint lists and counts are derived from the read files; the backend +// recomputes them and treats the producer's values as informational. +type NuGetConfigAudit struct { + SchemaVersion int `json:"schema_version"` + Status string `json:"status"` // complete | partial + Reasons []string `json:"reasons"` + Files []NuGetConfigFile `json:"files"` + Contexts []NuGetConfigContext `json:"contexts"` + Findings []CargoConfigFinding `json:"findings"` + ObservedRegistries []string `json:"observed_registries"` + ObservedProxies []string `json:"observed_proxies"` + SourceCount int `json:"source_count"` + ObservedRegistryCount int `json:"observed_registry_count"` + ObservedProxyCount int `json:"observed_proxy_count"` + FindingCount int `json:"finding_count"` + FindingCodeCounts map[string]int `json:"finding_code_counts"` + FindingCounts map[string]int `json:"finding_counts"` // by uppercase severity +} + +// NuGetConfigFile is one configuration source. The process source has no +// path; a source that was not read carries no current settings. +type NuGetConfigFile struct { + SourceID string `json:"source_id"` + Scope string `json:"scope"` // user | machine | project | process + Path string `json:"path,omitempty"` + Status string `json:"status"` // read | not_found | unreadable | unsupported + Reasons []string `json:"reasons"` + Settings []CargoConfigSetting `json:"settings"` +} + +// NuGetConfigContext is an ambient config chain for a project directory, or +// the config input an assets file recorded for one restore. +type NuGetConfigContext struct { + ProjectPath string `json:"project_path"` + ContextKind string `json:"context_kind"` // ambient | restore + Status string `json:"status"` // complete | partial + Reasons []string `json:"reasons"` + ConfigSourceIDs []string `json:"config_source_ids"` // highest precedence first + RestoreMetadataPath string `json:"restore_metadata_path,omitempty"` +} diff --git a/internal/model/nuget_golden_test.go b/internal/model/nuget_golden_test.go new file mode 100644 index 00000000..24a5c577 --- /dev/null +++ b/internal/model/nuget_golden_test.go @@ -0,0 +1,75 @@ +package model + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "reflect" + "testing" +) + +func TestNuGetGoldenContract(t *testing.T) { + raw, err := os.ReadFile("testdata/nuget_inventory_v1_golden.json") + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(raw) + if hex.EncodeToString(digest[:]) != "2524e51e20a70c3e6663e64ef6537b8eadcddab2669756291db278816ff969c6" { + t.Fatal("shared NuGet fixture digest changed; update both producer and consumer after review") + } + var pair struct { + Inventory *NuGetInventory `json:"nuget_inventory"` + Audit *NuGetConfigAudit `json:"nuget_config_audit"` + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&pair); err != nil { + t.Fatal(err) + } + if pair.Inventory == nil || pair.Audit == nil { + t.Fatal("missing section") + } + encoded, err := json.Marshal(pair) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(decodeGeneric(t, encoded), decodeGeneric(t, raw)) { + t.Fatal("wire round-trip dropped a field") + } + seen := map[string]bool{} + for _, s := range pair.Inventory.Sources { + seen["status:"+s.Status] = true + } + for _, p := range pair.Inventory.Projects { + seen["project:"+p.ProjectKind] = true + } + for _, p := range pair.Inventory.Packages { + seen["scope:"+p.Scope], seen["evidence:"+p.Evidence], seen["checksum:"+p.ChecksumStatus], seen["origin:"+p.Origin.Kind] = true, true, true, true + if p.Installation != nil { + seen["presence:"+p.Installation.Presence] = true + } + for _, h := range p.RecordedChecksums { + seen["hash:"+h.Kind] = true + } + } + for _, c := range pair.Audit.Contexts { + seen["context:"+c.ContextKind] = true + } + for _, f := range pair.Audit.Findings { + seen["finding:"+f.Code] = true + } + for _, key := range []string{ + "scope:project", "scope:user", "scope:unknown", "status:partial", + "evidence:declared_requirement", "evidence:locked_package", "evidence:restored_package", "evidence:cached_package", "evidence:installed_tool", "evidence:installed_template", "evidence:binary_dependency", + "project:msbuild", "project:legacy", "project:tool_manifest", "project:file_based", "project:fsharp_script", + "checksum:recorded", "checksum:partial", "checksum:not_recorded", "hash:content", "hash:archive", + "origin:feed", "origin:local", "origin:unknown", "presence:present", "presence:absent", + "context:ambient", "context:restore", "finding:nuget-001", "finding:nuget-002", "finding:nuget-003", "finding:nuget-004", + } { + if !seen[key] { + t.Errorf("fixture does not exercise %s", key) + } + } +} diff --git a/internal/model/testdata/nuget_inventory_v1_golden.json b/internal/model/testdata/nuget_inventory_v1_golden.json new file mode 100644 index 00000000..d3ab8f83 --- /dev/null +++ b/internal/model/testdata/nuget_inventory_v1_golden.json @@ -0,0 +1,1949 @@ +{ + "nuget_inventory": { + "schema_version": 1, + "status": "partial", + "reasons": [ + "extraction_incomplete", + "identity_mismatch", + "parse_error", + "unsupported_format" + ], + "sources": [ + { + "source_id": "0d258940b9b972749b5096ac27f95c918caa3add68148acb498367c7f79d5459", + "kind": "tool_resolver_cache", + "path": "/home/nuget-fixture/.dotnet/toolResolverCache/1/dotnet-format", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "86c941b94b7ab177ccf1f71efa6a7a053136a703a88f54a2eee236d383857a38", + "discovered_sources": [] + }, + { + "source_id": "43584ade62cc60751c07b96b9c611936a2fbe96ae612c24ce267acbda82cf43b", + "kind": "tool_store", + "path": "/home/nuget-fixture/.dotnet/tools/.store", + "status": "complete", + "presence": "present", + "reasons": [], + "discovered_sources": [] + }, + { + "source_id": "bcea6f7bf5a2fb5c94003c1bd5c6f2a82f02734fdec38b70ea61a8215e54928e", + "kind": "package_cache", + "path": "/home/nuget-fixture/.nuget/packages", + "status": "partial", + "presence": "present", + "reasons": [ + "identity_mismatch" + ], + "discovered_sources": [] + }, + { + "source_id": "5a3ded00d027b9122c9179fa1f6f78fea867dd9d35e0685cf81184f1c78f1446", + "kind": "template_receipt", + "path": "/home/nuget-fixture/.templateengine/packages.json", + "status": "partial", + "presence": "present", + "reasons": [ + "identity_mismatch", + "parse_error" + ], + "discovered_sources": [] + }, + { + "source_id": "a31a72f421f8475524be51da5aac31dd9b8b2f02674100148b7668f670a55e62", + "kind": "project_search_root", + "path": "/home/nuget-fixture/code", + "status": "partial", + "presence": "present", + "reasons": [ + "extraction_incomplete", + "parse_error", + "unsupported_format" + ], + "discovered_sources": [ + "05064db026ced43d8448f34e3663e237dbc0fd5d86cd37d8c8ab3b6942f9cb98", + "0839d33369a3fc0557731ae42b1eeac316b2fb7cca6e12a51545cae2a21e96a9", + "0cc348bd4757a43d2e06484fb268483a82e2b48378e88f7363c1025c71f947e9", + "225d26cce13d3498f29cc3d33e5d2c2c5fff4e8f354d81a574e013bd082116fc", + "3f8136d1da57f27422120bd65b638f81004778b83d769b60a5d56b83f259044b", + "435d1b01466c277cd0040a6668f4326530bb200145dc5983609c378afb59d45c", + "4e0f306320e361cb23cd72be4885cb355682774d7d4b81bd7b74521898204afa", + "501e6958c4d6c2b4b1591c6627c0e0cce7d7e0749a1ef5d97cd8cb2ca303072d", + "6714d919342a424550302dc38f6f849ad9149fdfaa4e0a4dfd6b2b232d0f30b2", + "6992712c7cdd4cda764068bcc84dd166bd7013c8290ba1a814b10749f300271b", + "69f96068a256c169c9892b8223b32c79143c6b305978faf0b808f2d088085bcd", + "86c941b94b7ab177ccf1f71efa6a7a053136a703a88f54a2eee236d383857a38", + "a02f746bfb84ed3e568054de46fa5f411adee8c8979a58bdf119511af451609d", + "c154d48d919a0d1572a65749164ebdacd71899753012f1fbeeb0f80c1289e32e", + "ea86ac8fc5aa844f25c405f3443bd7b1eb682f2b423aad01dcc129e2eced3b61" + ] + }, + { + "source_id": "0cc348bd4757a43d2e06484fb268483a82e2b48378e88f7363c1025c71f947e9", + "kind": "project_manifest", + "path": "/home/nuget-fixture/code/Directory.Build.props", + "status": "partial", + "presence": "present", + "reasons": [ + "parse_error" + ], + "parent_source_id": "a31a72f421f8475524be51da5aac31dd9b8b2f02674100148b7668f670a55e62", + "discovered_sources": [] + }, + { + "source_id": "6992712c7cdd4cda764068bcc84dd166bd7013c8290ba1a814b10749f300271b", + "kind": "central_versions", + "path": "/home/nuget-fixture/code/Directory.Packages.props", + "status": "partial", + "presence": "present", + "reasons": [ + "parse_error" + ], + "parent_source_id": "a31a72f421f8475524be51da5aac31dd9b8b2f02674100148b7668f670a55e62", + "discovered_sources": [] + }, + { + "source_id": "501e6958c4d6c2b4b1591c6627c0e0cce7d7e0749a1ef5d97cd8cb2ca303072d", + "kind": "project_manifest", + "path": "/home/nuget-fixture/code/app/App.csproj", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "a31a72f421f8475524be51da5aac31dd9b8b2f02674100148b7668f670a55e62", + "discovered_sources": [ + "3fed0cf398dee169ed6f0894d5ac3d5b51ee2a7df065024b30117a62cf1030ab" + ] + }, + { + "source_id": "69f96068a256c169c9892b8223b32c79143c6b305978faf0b808f2d088085bcd", + "kind": "binary_metadata", + "path": "/home/nuget-fixture/code/app/bin/Debug/net8.0/App.deps.json", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "a31a72f421f8475524be51da5aac31dd9b8b2f02674100148b7668f670a55e62", + "discovered_sources": [] + }, + { + "source_id": "3f8136d1da57f27422120bd65b638f81004778b83d769b60a5d56b83f259044b", + "kind": "project_assets", + "path": "/home/nuget-fixture/code/app/obj/project.assets.json", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "a31a72f421f8475524be51da5aac31dd9b8b2f02674100148b7668f670a55e62", + "discovered_sources": [] + }, + { + "source_id": "3fed0cf398dee169ed6f0894d5ac3d5b51ee2a7df065024b30117a62cf1030ab", + "kind": "lockfile", + "path": "/home/nuget-fixture/code/app/packages.lock.json", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "501e6958c4d6c2b4b1591c6627c0e0cce7d7e0749a1ef5d97cd8cb2ca303072d", + "discovered_sources": [] + }, + { + "source_id": "ea86ac8fc5aa844f25c405f3443bd7b1eb682f2b423aad01dcc129e2eced3b61", + "kind": "project_manifest", + "path": "/home/nuget-fixture/code/corrupt/Corrupt.csproj", + "status": "partial", + "presence": "present", + "reasons": [ + "parse_error" + ], + "parent_source_id": "a31a72f421f8475524be51da5aac31dd9b8b2f02674100148b7668f670a55e62", + "discovered_sources": [] + }, + { + "source_id": "225d26cce13d3498f29cc3d33e5d2c2c5fff4e8f354d81a574e013bd082116fc", + "kind": "legacy_manifest", + "path": "/home/nuget-fixture/code/legacy/packages.config", + "status": "partial", + "presence": "present", + "reasons": [ + "extraction_incomplete" + ], + "parent_source_id": "a31a72f421f8475524be51da5aac31dd9b8b2f02674100148b7668f670a55e62", + "discovered_sources": [] + }, + { + "source_id": "435d1b01466c277cd0040a6668f4326530bb200145dc5983609c378afb59d45c", + "kind": "project_assets", + "path": "/home/nuget-fixture/code/orphan/obj/project.assets.json", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "a31a72f421f8475524be51da5aac31dd9b8b2f02674100148b7668f670a55e62", + "discovered_sources": [] + }, + { + "source_id": "0839d33369a3fc0557731ae42b1eeac316b2fb7cca6e12a51545cae2a21e96a9", + "kind": "project_manifest", + "path": "/home/nuget-fixture/code/packed/Directory.Build.targets", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "a31a72f421f8475524be51da5aac31dd9b8b2f02674100148b7668f670a55e62", + "discovered_sources": [] + }, + { + "source_id": "4e0f306320e361cb23cd72be4885cb355682774d7d4b81bd7b74521898204afa", + "kind": "project_manifest", + "path": "/home/nuget-fixture/code/packed/Packed.csproj", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "a31a72f421f8475524be51da5aac31dd9b8b2f02674100148b7668f670a55e62", + "discovered_sources": [] + }, + { + "source_id": "556704753954a077bcf021179bb3965fa0865891939d36f233121a36ae2337b1", + "kind": "package_cache", + "path": "/home/nuget-fixture/code/packed/pkgs", + "status": "complete", + "presence": "present", + "reasons": [], + "discovered_sources": [] + }, + { + "source_id": "6714d919342a424550302dc38f6f849ad9149fdfaa4e0a4dfd6b2b232d0f30b2", + "kind": "script_manifest", + "path": "/home/nuget-fixture/code/scripts/app.cs", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "a31a72f421f8475524be51da5aac31dd9b8b2f02674100148b7668f670a55e62", + "discovered_sources": [] + }, + { + "source_id": "a02f746bfb84ed3e568054de46fa5f411adee8c8979a58bdf119511af451609d", + "kind": "script_manifest", + "path": "/home/nuget-fixture/code/scripts/script.fsx", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "a31a72f421f8475524be51da5aac31dd9b8b2f02674100148b7668f670a55e62", + "discovered_sources": [] + }, + { + "source_id": "05064db026ced43d8448f34e3663e237dbc0fd5d86cd37d8c8ab3b6942f9cb98", + "kind": "project_manifest", + "path": "/home/nuget-fixture/code/shared/Directory.Build.props", + "status": "partial", + "presence": "present", + "reasons": [ + "unsupported_format" + ], + "parent_source_id": "a31a72f421f8475524be51da5aac31dd9b8b2f02674100148b7668f670a55e62", + "discovered_sources": [] + }, + { + "source_id": "c154d48d919a0d1572a65749164ebdacd71899753012f1fbeeb0f80c1289e32e", + "kind": "project_manifest", + "path": "/home/nuget-fixture/code/shared/lib/Lib.csproj", + "status": "partial", + "presence": "present", + "reasons": [ + "unsupported_format" + ], + "parent_source_id": "a31a72f421f8475524be51da5aac31dd9b8b2f02674100148b7668f670a55e62", + "discovered_sources": [] + }, + { + "source_id": "4530334636471c1b1caec6ed8b46010084d40b1fdd268fe329cbf8a2cfdf1ab3", + "kind": "package_cache", + "path": "/home/nuget-fixture/code/shared/lib/packages", + "status": "complete", + "presence": "present", + "reasons": [], + "discovered_sources": [] + }, + { + "source_id": "86c941b94b7ab177ccf1f71efa6a7a053136a703a88f54a2eee236d383857a38", + "kind": "tool_manifest", + "path": "/home/nuget-fixture/code/tools/.config/dotnet-tools.json", + "status": "complete", + "presence": "present", + "reasons": [], + "parent_source_id": "a31a72f421f8475524be51da5aac31dd9b8b2f02674100148b7668f670a55e62", + "discovered_sources": [ + "0d258940b9b972749b5096ac27f95c918caa3add68148acb498367c7f79d5459" + ] + }, + { + "source_id": "05e66b0272c94dcca8cba7b28ef0107ddd2c3d0f3f51c9fe3e6cdb9618b87298", + "kind": "package_cache", + "path": "/home/nuget-fixture/scriptcache", + "status": "complete", + "presence": "present", + "reasons": [], + "discovered_sources": [] + } + ], + "projects": [ + { + "manifest_source_id": "501e6958c4d6c2b4b1591c6627c0e0cce7d7e0749a1ef5d97cd8cb2ca303072d", + "manifest_path": "/home/nuget-fixture/code/app/App.csproj", + "project_path": "/home/nuget-fixture/code/app", + "project_kind": "msbuild", + "target_frameworks": [ + "net8.0", + "net9.0" + ], + "selection_status": "resolved", + "assets_path": "/home/nuget-fixture/code/app/obj/project.assets.json", + "lockfile_path": "/home/nuget-fixture/code/app/packages.lock.json" + }, + { + "manifest_source_id": "ea86ac8fc5aa844f25c405f3443bd7b1eb682f2b423aad01dcc129e2eced3b61", + "manifest_path": "/home/nuget-fixture/code/corrupt/Corrupt.csproj", + "project_path": "/home/nuget-fixture/code/corrupt", + "project_kind": "msbuild", + "target_frameworks": [], + "selection_status": "unresolved" + }, + { + "manifest_source_id": "225d26cce13d3498f29cc3d33e5d2c2c5fff4e8f354d81a574e013bd082116fc", + "manifest_path": "/home/nuget-fixture/code/legacy/packages.config", + "project_path": "/home/nuget-fixture/code/legacy", + "project_kind": "legacy", + "target_frameworks": [], + "selection_status": "resolved", + "packages_path": "/home/nuget-fixture/code/legacy/packages" + }, + { + "manifest_source_id": "4e0f306320e361cb23cd72be4885cb355682774d7d4b81bd7b74521898204afa", + "manifest_path": "/home/nuget-fixture/code/packed/Packed.csproj", + "project_path": "/home/nuget-fixture/code/packed", + "project_kind": "msbuild", + "target_frameworks": [ + "net8.0" + ], + "selection_status": "resolved" + }, + { + "manifest_source_id": "6714d919342a424550302dc38f6f849ad9149fdfaa4e0a4dfd6b2b232d0f30b2", + "manifest_path": "/home/nuget-fixture/code/scripts/app.cs", + "project_path": "/home/nuget-fixture/code/scripts", + "project_kind": "file_based", + "target_frameworks": [], + "selection_status": "resolved" + }, + { + "manifest_source_id": "a02f746bfb84ed3e568054de46fa5f411adee8c8979a58bdf119511af451609d", + "manifest_path": "/home/nuget-fixture/code/scripts/script.fsx", + "project_path": "/home/nuget-fixture/code/scripts", + "project_kind": "fsharp_script", + "target_frameworks": [], + "selection_status": "resolved" + }, + { + "manifest_source_id": "c154d48d919a0d1572a65749164ebdacd71899753012f1fbeeb0f80c1289e32e", + "manifest_path": "/home/nuget-fixture/code/shared/lib/Lib.csproj", + "project_path": "/home/nuget-fixture/code/shared/lib", + "project_kind": "msbuild", + "target_frameworks": [ + "net8.0" + ], + "selection_status": "partial" + }, + { + "manifest_source_id": "86c941b94b7ab177ccf1f71efa6a7a053136a703a88f54a2eee236d383857a38", + "manifest_path": "/home/nuget-fixture/code/tools/.config/dotnet-tools.json", + "project_path": "/home/nuget-fixture/code/tools", + "project_kind": "tool_manifest", + "target_frameworks": [], + "selection_status": "resolved" + } + ], + "packages": [ + { + "source_id": "43584ade62cc60751c07b96b9c611936a2fbe96ae612c24ce267acbda82cf43b", + "evidence": "installed_tool", + "package_name": "dotnet-format", + "display_name": "dotnet-format", + "source_path": "/home/nuget-fixture/.dotnet/tools/.store", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "8.0.0", + "version_normalized": "8.0.0", + "dependency_relation": "direct", + "framework_alias": ".NETCoreApp,Version=v8.0", + "target_framework": ".NETCoreApp,Version=v8.0", + "runtime_identifier": "any", + "tool_commands": [ + "dotnet-format" + ], + "origin": { + "kind": "unknown" + }, + "installation": { + "path": "/home/nuget-fixture/.dotnet/tools/dotnet-format", + "path_status": "resolved", + "presence": "present" + }, + "checksum_status": "recorded", + "recorded_checksums": [ + { + "kind": "content", + "algorithm": "sha512", + "value": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "source_id": "43584ade62cc60751c07b96b9c611936a2fbe96ae612c24ce267acbda82cf43b", + "source_kind": "tool_store", + "source_path": "/home/nuget-fixture/.dotnet/tools/.store", + "verification": "recorded" + } + ] + }, + { + "source_id": "43584ade62cc60751c07b96b9c611936a2fbe96ae612c24ce267acbda82cf43b", + "evidence": "installed_tool", + "package_name": "example.ridcli", + "display_name": "Example.RidCli", + "source_path": "/home/nuget-fixture/.dotnet/tools/.store", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "1.0.0", + "version_normalized": "1.0.0", + "dependency_relation": "direct", + "framework_alias": "net10.0", + "target_framework": "net10.0", + "runtime_identifier": "osx-arm64", + "tool_commands": [ + "ridcli" + ], + "origin": { + "kind": "unknown" + }, + "installation": { + "path": "/home/nuget-fixture/.dotnet/tools/ridcli", + "path_status": "resolved", + "presence": "present" + }, + "checksum_status": "not_recorded", + "recorded_checksums": [] + }, + { + "source_id": "43584ade62cc60751c07b96b9c611936a2fbe96ae612c24ce267acbda82cf43b", + "evidence": "installed_tool", + "package_name": "example.ridcli.osx-arm64", + "display_name": "Example.RidCli.osx-arm64", + "source_path": "/home/nuget-fixture/.dotnet/tools/.store", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "1.0.0", + "version_normalized": "1.0.0", + "dependency_relation": "direct", + "framework_alias": "net10.0", + "target_framework": "net10.0", + "runtime_identifier": "osx-arm64", + "tool_commands": [ + "ridcli" + ], + "origin": { + "kind": "unknown" + }, + "installation": { + "path": "/home/nuget-fixture/.dotnet/tools/ridcli", + "path_status": "resolved", + "presence": "present" + }, + "checksum_status": "not_recorded", + "recorded_checksums": [] + }, + { + "source_id": "43584ade62cc60751c07b96b9c611936a2fbe96ae612c24ce267acbda82cf43b", + "evidence": "installed_tool", + "package_name": "humanizer", + "display_name": "Humanizer", + "source_path": "/home/nuget-fixture/.dotnet/tools/.store", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "2.14.1", + "version_normalized": "2.14.1", + "dependency_relation": "transitive", + "framework_alias": ".NETCoreApp,Version=v8.0", + "target_framework": ".NETCoreApp,Version=v8.0", + "runtime_identifier": "any", + "origin": { + "kind": "unknown" + }, + "checksum_status": "recorded", + "recorded_checksums": [ + { + "kind": "content", + "algorithm": "sha512", + "value": "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQQ==", + "source_id": "43584ade62cc60751c07b96b9c611936a2fbe96ae612c24ce267acbda82cf43b", + "source_kind": "tool_store", + "source_path": "/home/nuget-fixture/.dotnet/tools/.store", + "verification": "recorded" + } + ] + }, + { + "source_id": "bcea6f7bf5a2fb5c94003c1bd5c6f2a82f02734fdec38b70ea61a8215e54928e", + "evidence": "cached_package", + "package_name": "archived", + "display_name": "Archived", + "source_path": "/home/nuget-fixture/.nuget/packages", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "2.0.0", + "version_normalized": "2.0.0", + "dependency_relation": "unknown", + "origin": { + "kind": "unknown" + }, + "installation": { + "path": "/home/nuget-fixture/.nuget/packages/archived/2.0.0", + "path_status": "resolved", + "presence": "present" + }, + "checksum_status": "recorded", + "recorded_checksums": [ + { + "kind": "content", + "algorithm": "sha512", + "value": "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQQ==", + "source_id": "bcea6f7bf5a2fb5c94003c1bd5c6f2a82f02734fdec38b70ea61a8215e54928e", + "source_kind": "package_cache", + "source_path": "/home/nuget-fixture/.nuget/packages", + "verification": "recorded" + } + ] + }, + { + "source_id": "bcea6f7bf5a2fb5c94003c1bd5c6f2a82f02734fdec38b70ea61a8215e54928e", + "evidence": "cached_package", + "package_name": "bare", + "display_name": "Bare", + "source_path": "/home/nuget-fixture/.nuget/packages", + "scope": "user", + "reasons": [ + "extraction_incomplete" + ], + "version_status": "known", + "version": "1.0.0", + "version_normalized": "1.0.0", + "dependency_relation": "unknown", + "origin": { + "kind": "unknown" + }, + "installation": { + "path": "/home/nuget-fixture/.nuget/packages/bare/1.0.0", + "path_status": "resolved", + "presence": "present" + }, + "checksum_status": "not_recorded", + "recorded_checksums": [] + }, + { + "source_id": "bcea6f7bf5a2fb5c94003c1bd5c6f2a82f02734fdec38b70ea61a8215e54928e", + "evidence": "cached_package", + "package_name": "newtonsoft.json", + "display_name": "Newtonsoft.Json", + "source_path": "/home/nuget-fixture/.nuget/packages", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "13.0.1", + "version_normalized": "13.0.1", + "dependency_relation": "unknown", + "origin": { + "kind": "feed", + "url": "https://api.nuget.org/v3/index.json" + }, + "installation": { + "path": "/home/nuget-fixture/.nuget/packages/newtonsoft.json/13.0.1", + "path_status": "resolved", + "presence": "present" + }, + "checksum_status": "recorded", + "recorded_checksums": [ + { + "kind": "content", + "algorithm": "sha512", + "value": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "source_id": "bcea6f7bf5a2fb5c94003c1bd5c6f2a82f02734fdec38b70ea61a8215e54928e", + "source_kind": "package_cache", + "source_path": "/home/nuget-fixture/.nuget/packages", + "verification": "recorded" + }, + { + "kind": "archive", + "algorithm": "sha512", + "value": "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQQ==", + "source_id": "bcea6f7bf5a2fb5c94003c1bd5c6f2a82f02734fdec38b70ea61a8215e54928e", + "source_kind": "package_cache", + "source_path": "/home/nuget-fixture/.nuget/packages", + "verification": "recorded" + } + ] + }, + { + "source_id": "bcea6f7bf5a2fb5c94003c1bd5c6f2a82f02734fdec38b70ea61a8215e54928e", + "evidence": "cached_package", + "package_name": "transitive.dep", + "display_name": "Transitive.Dep", + "source_path": "/home/nuget-fixture/.nuget/packages", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "2.0.0", + "version_normalized": "2.0.0", + "dependency_relation": "unknown", + "origin": { + "kind": "local", + "path": "/home/nuget-fixture/feed" + }, + "installation": { + "path": "/home/nuget-fixture/.nuget/packages/transitive.dep/2.0.0", + "path_status": "resolved", + "presence": "present" + }, + "checksum_status": "recorded", + "recorded_checksums": [ + { + "kind": "content", + "algorithm": "sha512", + "value": "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQQ==", + "source_id": "bcea6f7bf5a2fb5c94003c1bd5c6f2a82f02734fdec38b70ea61a8215e54928e", + "source_kind": "package_cache", + "source_path": "/home/nuget-fixture/.nuget/packages", + "verification": "recorded" + } + ] + }, + { + "source_id": "5a3ded00d027b9122c9179fa1f6f78fea867dd9d35e0685cf81184f1c78f1446", + "evidence": "installed_template", + "package_name": "example.templates", + "display_name": "Example.Templates", + "source_path": "/home/nuget-fixture/.templateengine/packages.json", + "scope": "user", + "reasons": [], + "version_status": "known", + "version": "1.0.0", + "version_normalized": "1.0.0", + "dependency_relation": "direct", + "origin": { + "kind": "feed", + "url": "https://api.nuget.org/v3/index.json" + }, + "installation": { + "path": "/home/nuget-fixture/.templateengine/packages/Example.Templates.1.0.0.nupkg", + "path_status": "resolved", + "presence": "present" + }, + "checksum_status": "not_recorded", + "recorded_checksums": [] + }, + { + "source_id": "6992712c7cdd4cda764068bcc84dd166bd7013c8290ba1a814b10749f300271b", + "evidence": "declared_requirement", + "package_name": "analyzer", + "display_name": "Analyzer", + "source_path": "/home/nuget-fixture/code/Directory.Packages.props", + "project_path": "/home/nuget-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "unknown", + "requested_version": "2.0.0", + "dependency_relation": "direct", + "declaration_kind": "global_package_reference", + "origin": { + "kind": "unknown" + }, + "checksum_status": "not_recorded", + "recorded_checksums": [] + }, + { + "source_id": "6992712c7cdd4cda764068bcc84dd166bd7013c8290ba1a814b10749f300271b", + "evidence": "declared_requirement", + "package_name": "analyzer", + "display_name": "Analyzer", + "source_path": "/home/nuget-fixture/code/Directory.Packages.props", + "project_path": "/home/nuget-fixture/code/packed", + "scope": "project", + "reasons": [], + "version_status": "unknown", + "requested_version": "2.0.0", + "dependency_relation": "direct", + "declaration_kind": "global_package_reference", + "origin": { + "kind": "unknown" + }, + "checksum_status": "not_recorded", + "recorded_checksums": [] + }, + { + "source_id": "6992712c7cdd4cda764068bcc84dd166bd7013c8290ba1a814b10749f300271b", + "evidence": "declared_requirement", + "package_name": "analyzer", + "display_name": "Analyzer", + "source_path": "/home/nuget-fixture/code/Directory.Packages.props", + "project_path": "/home/nuget-fixture/code/shared/lib", + "scope": "project", + "reasons": [], + "version_status": "unknown", + "requested_version": "2.0.0", + "dependency_relation": "direct", + "declaration_kind": "global_package_reference", + "origin": { + "kind": "unknown" + }, + "checksum_status": "not_recorded", + "recorded_checksums": [] + }, + { + "source_id": "501e6958c4d6c2b4b1591c6627c0e0cce7d7e0749a1ef5d97cd8cb2ca303072d", + "evidence": "declared_requirement", + "package_name": "central", + "display_name": "Central", + "source_path": "/home/nuget-fixture/code/app/App.csproj", + "project_path": "/home/nuget-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "unknown", + "requested_version": "1.0.0", + "dependency_relation": "direct", + "declaration_kind": "package_reference", + "origin": { + "kind": "unknown" + }, + "checksum_status": "not_recorded", + "recorded_checksums": [] + }, + { + "source_id": "501e6958c4d6c2b4b1591c6627c0e0cce7d7e0749a1ef5d97cd8cb2ca303072d", + "evidence": "declared_requirement", + "package_name": "conditional", + "display_name": "Conditional", + "source_path": "/home/nuget-fixture/code/app/App.csproj", + "project_path": "/home/nuget-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "unknown", + "requested_version": "1.0.0-Beta", + "dependency_relation": "direct", + "conditional": true, + "declaration_kind": "package_reference", + "origin": { + "kind": "unknown" + }, + "checksum_status": "not_recorded", + "recorded_checksums": [] + }, + { + "source_id": "501e6958c4d6c2b4b1591c6627c0e0cce7d7e0749a1ef5d97cd8cb2ca303072d", + "evidence": "declared_requirement", + "package_name": "downloaded", + "display_name": "Downloaded", + "source_path": "/home/nuget-fixture/code/app/App.csproj", + "project_path": "/home/nuget-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "unknown", + "requested_version": "[1.0.0]", + "dependency_relation": "direct", + "declaration_kind": "package_download", + "origin": { + "kind": "unknown" + }, + "checksum_status": "not_recorded", + "recorded_checksums": [] + }, + { + "source_id": "501e6958c4d6c2b4b1591c6627c0e0cce7d7e0749a1ef5d97cd8cb2ca303072d", + "evidence": "declared_requirement", + "package_name": "expr", + "display_name": "Expr", + "source_path": "/home/nuget-fixture/code/app/App.csproj", + "project_path": "/home/nuget-fixture/code/app", + "scope": "project", + "reasons": [ + "context_unresolved" + ], + "version_status": "unknown", + "dependency_relation": "direct", + "declaration_kind": "package_reference", + "origin": { + "kind": "unknown" + }, + "checksum_status": "not_recorded", + "recorded_checksums": [] + }, + { + "source_id": "501e6958c4d6c2b4b1591c6627c0e0cce7d7e0749a1ef5d97cd8cb2ca303072d", + "evidence": "declared_requirement", + "package_name": "missing", + "display_name": "Missing", + "source_path": "/home/nuget-fixture/code/app/App.csproj", + "project_path": "/home/nuget-fixture/code/app", + "scope": "project", + "reasons": [ + "context_unresolved" + ], + "version_status": "unknown", + "dependency_relation": "direct", + "declaration_kind": "package_reference", + "origin": { + "kind": "unknown" + }, + "checksum_status": "not_recorded", + "recorded_checksums": [] + }, + { + "source_id": "501e6958c4d6c2b4b1591c6627c0e0cce7d7e0749a1ef5d97cd8cb2ca303072d", + "evidence": "declared_requirement", + "package_name": "newtonsoft.json", + "display_name": "Newtonsoft.Json", + "source_path": "/home/nuget-fixture/code/app/App.csproj", + "project_path": "/home/nuget-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "unknown", + "requested_version": "13.0.1", + "dependency_relation": "direct", + "private_assets": "all", + "declaration_kind": "package_reference", + "origin": { + "kind": "unknown" + }, + "checksum_status": "not_recorded", + "recorded_checksums": [] + }, + { + "source_id": "69f96068a256c169c9892b8223b32c79143c6b305978faf0b808f2d088085bcd", + "evidence": "binary_dependency", + "package_name": "newtonsoft.json", + "display_name": "Newtonsoft.Json", + "source_path": "/home/nuget-fixture/code/app/bin/Debug/net8.0/App.deps.json", + "project_path": "/home/nuget-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "13.0.1", + "version_normalized": "13.0.1", + "dependency_relation": "unknown", + "target_framework": ".NETCoreApp,Version=v8.0", + "origin": { + "kind": "unknown" + }, + "checksum_status": "recorded", + "recorded_checksums": [ + { + "kind": "content", + "algorithm": "sha512", + "value": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "source_id": "69f96068a256c169c9892b8223b32c79143c6b305978faf0b808f2d088085bcd", + "source_kind": "binary_metadata", + "source_path": "/home/nuget-fixture/code/app/bin/Debug/net8.0/App.deps.json", + "verification": "recorded" + } + ] + }, + { + "source_id": "3f8136d1da57f27422120bd65b638f81004778b83d769b60a5d56b83f259044b", + "evidence": "restored_package", + "package_name": "newtonsoft.json", + "display_name": "Newtonsoft.Json", + "source_path": "/home/nuget-fixture/code/app/obj/project.assets.json", + "project_path": "/home/nuget-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "13.0.1", + "version_normalized": "13.0.1", + "dependency_relation": "direct", + "framework_alias": "net8.0", + "target_framework": ".NETCoreApp,Version=v8.0", + "origin": { + "kind": "feed", + "url": "https://api.nuget.org/v3/index.json" + }, + "installation": { + "path": "/home/nuget-fixture/.nuget/packages/newtonsoft.json/13.0.1", + "path_status": "resolved", + "presence": "present" + }, + "checksum_status": "recorded", + "recorded_checksums": [ + { + "kind": "content", + "algorithm": "sha512", + "value": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "source_id": "3f8136d1da57f27422120bd65b638f81004778b83d769b60a5d56b83f259044b", + "source_kind": "project_assets", + "source_path": "/home/nuget-fixture/code/app/obj/project.assets.json", + "verification": "recorded" + } + ] + }, + { + "source_id": "3f8136d1da57f27422120bd65b638f81004778b83d769b60a5d56b83f259044b", + "evidence": "restored_package", + "package_name": "newtonsoft.json", + "display_name": "Newtonsoft.Json", + "source_path": "/home/nuget-fixture/code/app/obj/project.assets.json", + "project_path": "/home/nuget-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "13.0.1", + "version_normalized": "13.0.1", + "dependency_relation": "direct", + "framework_alias": "net8.0", + "target_framework": ".NETCoreApp,Version=v8.0", + "runtime_identifier": "linux-x64", + "origin": { + "kind": "feed", + "url": "https://api.nuget.org/v3/index.json" + }, + "installation": { + "path": "/home/nuget-fixture/.nuget/packages/newtonsoft.json/13.0.1", + "path_status": "resolved", + "presence": "present" + }, + "checksum_status": "recorded", + "recorded_checksums": [ + { + "kind": "content", + "algorithm": "sha512", + "value": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "source_id": "3f8136d1da57f27422120bd65b638f81004778b83d769b60a5d56b83f259044b", + "source_kind": "project_assets", + "source_path": "/home/nuget-fixture/code/app/obj/project.assets.json", + "verification": "recorded" + } + ] + }, + { + "source_id": "3f8136d1da57f27422120bd65b638f81004778b83d769b60a5d56b83f259044b", + "evidence": "restored_package", + "package_name": "transitive.dep", + "display_name": "Transitive.Dep", + "source_path": "/home/nuget-fixture/code/app/obj/project.assets.json", + "project_path": "/home/nuget-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "2.0.0", + "version_normalized": "2.0.0", + "dependency_relation": "transitive", + "framework_alias": "net8.0", + "target_framework": ".NETCoreApp,Version=v8.0", + "origin": { + "kind": "local", + "path": "/home/nuget-fixture/feed" + }, + "installation": { + "path": "/home/nuget-fixture/.nuget/packages/transitive.dep/2.0.0", + "path_status": "resolved", + "presence": "present" + }, + "checksum_status": "recorded", + "recorded_checksums": [ + { + "kind": "content", + "algorithm": "sha512", + "value": "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQQ==", + "source_id": "3f8136d1da57f27422120bd65b638f81004778b83d769b60a5d56b83f259044b", + "source_kind": "project_assets", + "source_path": "/home/nuget-fixture/code/app/obj/project.assets.json", + "verification": "recorded" + } + ] + }, + { + "source_id": "3fed0cf398dee169ed6f0894d5ac3d5b51ee2a7df065024b30117a62cf1030ab", + "evidence": "locked_package", + "package_name": "newtonsoft.json", + "display_name": "Newtonsoft.Json", + "source_path": "/home/nuget-fixture/code/app/packages.lock.json", + "project_path": "/home/nuget-fixture/code/app", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "13.0.1", + "version_normalized": "13.0.1", + "requested_version": "[13.0.1, )", + "dependency_relation": "direct", + "framework_alias": "net8.0", + "target_framework": ".NETCoreApp,Version=v8.0", + "lock_dependency_type": "Direct", + "origin": { + "kind": "unknown" + }, + "checksum_status": "recorded", + "recorded_checksums": [ + { + "kind": "content", + "algorithm": "sha512", + "value": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "source_id": "3fed0cf398dee169ed6f0894d5ac3d5b51ee2a7df065024b30117a62cf1030ab", + "source_kind": "lockfile", + "source_path": "/home/nuget-fixture/code/app/packages.lock.json", + "verification": "recorded" + } + ] + }, + { + "source_id": "3fed0cf398dee169ed6f0894d5ac3d5b51ee2a7df065024b30117a62cf1030ab", + "evidence": "locked_package", + "package_name": "transitive.dep", + "display_name": "Transitive.Dep", + "source_path": "/home/nuget-fixture/code/app/packages.lock.json", + "project_path": "/home/nuget-fixture/code/app", + "scope": "project", + "reasons": [ + "checksum_invalid" + ], + "version_status": "known", + "version": "2.0.0", + "version_normalized": "2.0.0", + "dependency_relation": "transitive", + "framework_alias": "net8.0", + "target_framework": ".NETCoreApp,Version=v8.0", + "lock_dependency_type": "Transitive", + "origin": { + "kind": "unknown" + }, + "checksum_status": "partial", + "recorded_checksums": [] + }, + { + "source_id": "225d26cce13d3498f29cc3d33e5d2c2c5fff4e8f354d81a574e013bd082116fc", + "evidence": "declared_requirement", + "package_name": "absent.legacy", + "display_name": "Absent.Legacy", + "source_path": "/home/nuget-fixture/code/legacy/packages.config", + "project_path": "/home/nuget-fixture/code/legacy", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "1.0.0", + "version_normalized": "1.0.0", + "dependency_relation": "unknown", + "origin": { + "kind": "unknown" + }, + "checksum_status": "not_recorded", + "recorded_checksums": [] + }, + { + "source_id": "225d26cce13d3498f29cc3d33e5d2c2c5fff4e8f354d81a574e013bd082116fc", + "evidence": "declared_requirement", + "package_name": "empty.legacy", + "display_name": "Empty.Legacy", + "source_path": "/home/nuget-fixture/code/legacy/packages.config", + "project_path": "/home/nuget-fixture/code/legacy", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "1.0.0", + "version_normalized": "1.0.0", + "dependency_relation": "unknown", + "origin": { + "kind": "unknown" + }, + "checksum_status": "not_recorded", + "recorded_checksums": [] + }, + { + "source_id": "225d26cce13d3498f29cc3d33e5d2c2c5fff4e8f354d81a574e013bd082116fc", + "evidence": "declared_requirement", + "package_name": "newtonsoft.json", + "display_name": "Newtonsoft.Json", + "source_path": "/home/nuget-fixture/code/legacy/packages.config", + "project_path": "/home/nuget-fixture/code/legacy", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "13.0.1", + "version_normalized": "13.0.1", + "dependency_relation": "unknown", + "development_dependency": true, + "target_framework": "net472", + "origin": { + "kind": "unknown" + }, + "checksum_status": "not_recorded", + "recorded_checksums": [] + }, + { + "source_id": "225d26cce13d3498f29cc3d33e5d2c2c5fff4e8f354d81a574e013bd082116fc", + "evidence": "restored_package", + "package_name": "newtonsoft.json", + "display_name": "Newtonsoft.Json", + "source_path": "/home/nuget-fixture/code/legacy/packages.config", + "project_path": "/home/nuget-fixture/code/legacy", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "13.0.1", + "version_normalized": "13.0.1", + "dependency_relation": "unknown", + "development_dependency": true, + "target_framework": "net472", + "origin": { + "kind": "unknown" + }, + "installation": { + "path": "/home/nuget-fixture/code/legacy/packages/Newtonsoft.Json.13.0.1", + "path_status": "resolved", + "presence": "present" + }, + "checksum_status": "not_recorded", + "recorded_checksums": [] + }, + { + "source_id": "435d1b01466c277cd0040a6668f4326530bb200145dc5983609c378afb59d45c", + "evidence": "restored_package", + "package_name": "orphaned", + "display_name": "Orphaned", + "source_path": "/home/nuget-fixture/code/orphan/obj/project.assets.json", + "project_path": "/home/nuget-fixture/code/gone", + "scope": "unknown", + "reasons": [], + "version_status": "known", + "version": "1.0.0", + "version_normalized": "1.0.0", + "dependency_relation": "unknown", + "target_framework": "net8.0", + "origin": { + "kind": "unknown" + }, + "installation": { + "path": "/home/nuget-fixture/.nuget/packages/orphaned/1.0.0", + "path_status": "resolved", + "presence": "unknown" + }, + "checksum_status": "recorded", + "recorded_checksums": [ + { + "kind": "content", + "algorithm": "sha512", + "value": "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQQ==", + "source_id": "435d1b01466c277cd0040a6668f4326530bb200145dc5983609c378afb59d45c", + "source_kind": "project_assets", + "source_path": "/home/nuget-fixture/code/orphan/obj/project.assets.json", + "verification": "recorded" + } + ] + }, + { + "source_id": "556704753954a077bcf021179bb3965fa0865891939d36f233121a36ae2337b1", + "evidence": "cached_package", + "package_name": "custom.pkg", + "display_name": "Custom.Pkg", + "source_path": "/home/nuget-fixture/code/packed/pkgs", + "scope": "custom", + "reasons": [], + "version_status": "known", + "version": "1.0.0", + "version_normalized": "1.0.0", + "dependency_relation": "unknown", + "origin": { + "kind": "unknown" + }, + "installation": { + "path": "/home/nuget-fixture/code/packed/pkgs/custom.pkg/1.0.0", + "path_status": "resolved", + "presence": "present" + }, + "checksum_status": "recorded", + "recorded_checksums": [ + { + "kind": "content", + "algorithm": "sha512", + "value": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "source_id": "556704753954a077bcf021179bb3965fa0865891939d36f233121a36ae2337b1", + "source_kind": "package_cache", + "source_path": "/home/nuget-fixture/code/packed/pkgs", + "verification": "recorded" + } + ] + }, + { + "source_id": "6714d919342a424550302dc38f6f849ad9149fdfaa4e0a4dfd6b2b232d0f30b2", + "evidence": "declared_requirement", + "package_name": "aspire.apphost.sdk", + "display_name": "Aspire.AppHost.Sdk", + "source_path": "/home/nuget-fixture/code/scripts/app.cs", + "project_path": "/home/nuget-fixture/code/scripts", + "scope": "project", + "reasons": [], + "version_status": "unknown", + "requested_version": "9.0.0", + "dependency_relation": "direct", + "declaration_kind": "msbuild_sdk", + "origin": { + "kind": "unknown" + }, + "checksum_status": "not_recorded", + "recorded_checksums": [] + }, + { + "source_id": "6714d919342a424550302dc38f6f849ad9149fdfaa4e0a4dfd6b2b232d0f30b2", + "evidence": "declared_requirement", + "package_name": "humanizer", + "display_name": "Humanizer", + "source_path": "/home/nuget-fixture/code/scripts/app.cs", + "project_path": "/home/nuget-fixture/code/scripts", + "scope": "project", + "reasons": [], + "version_status": "unknown", + "requested_version": "2.14.1", + "dependency_relation": "direct", + "declaration_kind": "package_reference", + "origin": { + "kind": "unknown" + }, + "checksum_status": "not_recorded", + "recorded_checksums": [] + }, + { + "source_id": "a02f746bfb84ed3e568054de46fa5f411adee8c8979a58bdf119511af451609d", + "evidence": "declared_requirement", + "package_name": "fsharp.data", + "display_name": "FSharp.Data", + "source_path": "/home/nuget-fixture/code/scripts/script.fsx", + "project_path": "/home/nuget-fixture/code/scripts", + "scope": "project", + "reasons": [], + "version_status": "unknown", + "requested_version": "6.3.0", + "dependency_relation": "direct", + "declaration_kind": "package_reference", + "origin": { + "kind": "unknown" + }, + "checksum_status": "not_recorded", + "recorded_checksums": [] + }, + { + "source_id": "4530334636471c1b1caec6ed8b46010084d40b1fdd268fe329cbf8a2cfdf1ab3", + "evidence": "cached_package", + "package_name": "shared.cached", + "display_name": "Shared.Cached", + "source_path": "/home/nuget-fixture/code/shared/lib/packages", + "scope": "custom", + "reasons": [], + "version_status": "known", + "version": "1.0.0", + "version_normalized": "1.0.0", + "dependency_relation": "unknown", + "origin": { + "kind": "unknown" + }, + "installation": { + "path": "/home/nuget-fixture/code/shared/lib/packages/shared.cached/1.0.0", + "path_status": "resolved", + "presence": "present" + }, + "checksum_status": "recorded", + "recorded_checksums": [ + { + "kind": "content", + "algorithm": "sha512", + "value": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "source_id": "4530334636471c1b1caec6ed8b46010084d40b1fdd268fe329cbf8a2cfdf1ab3", + "source_kind": "package_cache", + "source_path": "/home/nuget-fixture/code/shared/lib/packages", + "verification": "recorded" + } + ] + }, + { + "source_id": "86c941b94b7ab177ccf1f71efa6a7a053136a703a88f54a2eee236d383857a38", + "evidence": "declared_requirement", + "package_name": "unresolved-tool", + "display_name": "unresolved-tool", + "source_path": "/home/nuget-fixture/code/tools/.config/dotnet-tools.json", + "project_path": "/home/nuget-fixture/code/tools", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "1.0.0", + "version_normalized": "1.0.0", + "dependency_relation": "direct", + "tool_commands": [ + "unresolved" + ], + "origin": { + "kind": "unknown" + }, + "checksum_status": "not_recorded", + "recorded_checksums": [] + }, + { + "source_id": "86c941b94b7ab177ccf1f71efa6a7a053136a703a88f54a2eee236d383857a38", + "evidence": "installed_tool", + "package_name": "dotnet-format", + "display_name": "dotnet-format", + "source_path": "/home/nuget-fixture/code/tools/.config/dotnet-tools.json", + "project_path": "/home/nuget-fixture/code/tools", + "scope": "project", + "reasons": [], + "version_status": "known", + "version": "8.0.0", + "version_normalized": "8.0.0", + "dependency_relation": "direct", + "target_framework": "net8.0", + "runtime_identifier": "any", + "tool_commands": [ + "dotnet-format" + ], + "origin": { + "kind": "unknown" + }, + "installation": { + "path": "/home/nuget-fixture/.nuget/packages/dotnet-format/8.0.0/tools/net8.0/any/dotnet-format.dll", + "path_status": "resolved", + "presence": "absent" + }, + "checksum_status": "not_recorded", + "recorded_checksums": [] + }, + { + "source_id": "05e66b0272c94dcca8cba7b28ef0107ddd2c3d0f3f51c9fe3e6cdb9618b87298", + "evidence": "cached_package", + "package_name": "example.cached", + "display_name": "Example.Cached", + "source_path": "/home/nuget-fixture/scriptcache", + "scope": "custom", + "reasons": [], + "version_status": "known", + "version": "1.0.0", + "version_normalized": "1.0.0", + "dependency_relation": "unknown", + "origin": { + "kind": "feed", + "url": "https://api.nuget.org/v3/index.json" + }, + "installation": { + "path": "/home/nuget-fixture/scriptcache/example.cached/1.0.0", + "path_status": "resolved", + "presence": "present" + }, + "checksum_status": "recorded", + "recorded_checksums": [ + { + "kind": "content", + "algorithm": "sha512", + "value": "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQQ==", + "source_id": "05e66b0272c94dcca8cba7b28ef0107ddd2c3d0f3f51c9fe3e6cdb9618b87298", + "source_kind": "package_cache", + "source_path": "/home/nuget-fixture/scriptcache", + "verification": "recorded" + } + ] + } + ] + }, + "nuget_config_audit": { + "schema_version": 1, + "status": "partial", + "reasons": [ + "parse_error", + "path_unresolved", + "unsupported_format" + ], + "files": [ + { + "source_id": "c548c514e555ab7ad8184f1a99f0a1dd9f716e0a99c4522b1d30c72575a1c2c7", + "scope": "process", + "status": "read", + "reasons": [], + "settings": [ + { + "key": "auth_configured", + "display": "true", + "source_id": "c548c514e555ab7ad8184f1a99f0a1dd9f716e0a99c4522b1d30c72575a1c2c7", + "redacted": true + }, + { + "key": "credential_provider_configured", + "display": "true", + "source_id": "c548c514e555ab7ad8184f1a99f0a1dd9f716e0a99c4522b1d30c72575a1c2c7", + "redacted": true + }, + { + "key": "environment.HTTPS_PROXY", + "display": "https://proxy.example.invalid:8443/", + "source_id": "c548c514e555ab7ad8184f1a99f0a1dd9f716e0a99c4522b1d30c72575a1c2c7", + "redacted": true + }, + { + "key": "environment.NO_PROXY", + "display": "localhost,.example.invalid", + "source_id": "c548c514e555ab7ad8184f1a99f0a1dd9f716e0a99c4522b1d30c72575a1c2c7", + "redacted": false + } + ] + }, + { + "source_id": "f604bd9c0bd21a5d23c165aefc86bcb3aba096e83889328cc9dfd582b87cae37", + "scope": "project", + "path": "/home/nuget-fixture/code/Directory.Build.props", + "status": "read", + "reasons": [], + "settings": [] + }, + { + "source_id": "20460fb6f0bcc17ab6f5fe831dfc04ad159a4dc671bcc97a7d15ee6c981a328c", + "scope": "project", + "path": "/home/nuget-fixture/code/Directory.Packages.props", + "status": "read", + "reasons": [], + "settings": [ + { + "key": "project.NuGetAuditMode", + "display": "all", + "source_id": "20460fb6f0bcc17ab6f5fe831dfc04ad159a4dc671bcc97a7d15ee6c981a328c", + "redacted": false + } + ] + }, + { + "source_id": "94267beb213533a15ab10ddad091180719ffa4a53e0b6f1b2a0fa4cf49ca77e0", + "scope": "project", + "path": "/home/nuget-fixture/code/NuGet.Config", + "status": "not_found", + "reasons": [], + "settings": [] + }, + { + "source_id": "82c4627d6e7f65e5f6da17289e3c6e037f024d6d5067af0120c929b31772329c", + "scope": "project", + "path": "/home/nuget-fixture/code/app/App.csproj", + "status": "read", + "reasons": [], + "settings": [ + { + "key": "project.NuGetAudit", + "display": "false", + "source_id": "82c4627d6e7f65e5f6da17289e3c6e037f024d6d5067af0120c929b31772329c", + "redacted": false + }, + { + "key": "project.RestoreLockedMode", + "display": "true", + "source_id": "82c4627d6e7f65e5f6da17289e3c6e037f024d6d5067af0120c929b31772329c", + "redacted": false + } + ] + }, + { + "source_id": "27606572bce70151c8d35868893281ebe1d15a0612e9380a8f5210555ebb1476", + "scope": "project", + "path": "/home/nuget-fixture/code/app/NuGet.Config", + "status": "read", + "reasons": [], + "settings": [ + { + "key": "auth_configured", + "display": "true", + "source_id": "27606572bce70151c8d35868893281ebe1d15a0612e9380a8f5210555ebb1476", + "redacted": true + }, + { + "key": "config.http_proxy", + "display": "http://proxy.example.invalid:8080", + "source_id": "27606572bce70151c8d35868893281ebe1d15a0612e9380a8f5210555ebb1476", + "redacted": true + }, + { + "key": "config.maxHttpRequestsPerSource", + "display": "8", + "source_id": "27606572bce70151c8d35868893281ebe1d15a0612e9380a8f5210555ebb1476", + "redacted": false + }, + { + "key": "config.signatureValidationMode", + "display": "require", + "source_id": "27606572bce70151c8d35868893281ebe1d15a0612e9380a8f5210555ebb1476", + "redacted": false + }, + { + "key": "packageSourceMapping.0.pattern", + "display": "Example.*", + "source_id": "27606572bce70151c8d35868893281ebe1d15a0612e9380a8f5210555ebb1476", + "redacted": false + }, + { + "key": "packageSourceMapping.0.source", + "display": "local", + "source_id": "27606572bce70151c8d35868893281ebe1d15a0612e9380a8f5210555ebb1476", + "redacted": false + }, + { + "key": "packageSources.0.enabled", + "display": "true", + "source_id": "27606572bce70151c8d35868893281ebe1d15a0612e9380a8f5210555ebb1476", + "redacted": false + }, + { + "key": "packageSources.0.name", + "display": "[redacted]", + "source_id": "27606572bce70151c8d35868893281ebe1d15a0612e9380a8f5210555ebb1476", + "redacted": true + }, + { + "key": "packageSources.0.url", + "display": "http://feed.example.invalid/v3/index.json", + "source_id": "27606572bce70151c8d35868893281ebe1d15a0612e9380a8f5210555ebb1476", + "redacted": true + }, + { + "key": "packageSources.1.enabled", + "display": "true", + "source_id": "27606572bce70151c8d35868893281ebe1d15a0612e9380a8f5210555ebb1476", + "redacted": false + }, + { + "key": "packageSources.1.name", + "display": "local", + "source_id": "27606572bce70151c8d35868893281ebe1d15a0612e9380a8f5210555ebb1476", + "redacted": false + }, + { + "key": "packageSources.1.url", + "display": "/home/nuget-fixture/code/feed", + "source_id": "27606572bce70151c8d35868893281ebe1d15a0612e9380a8f5210555ebb1476", + "redacted": false + }, + { + "key": "packageSources.2.enabled", + "display": "false", + "source_id": "27606572bce70151c8d35868893281ebe1d15a0612e9380a8f5210555ebb1476", + "redacted": false + }, + { + "key": "packageSources.2.name", + "display": "off", + "source_id": "27606572bce70151c8d35868893281ebe1d15a0612e9380a8f5210555ebb1476", + "redacted": false + }, + { + "key": "packageSources.2.url", + "display": "https://off.example.invalid/v3/index.json", + "source_id": "27606572bce70151c8d35868893281ebe1d15a0612e9380a8f5210555ebb1476", + "redacted": false + }, + { + "key": "packageSources.3.enabled", + "display": "false", + "source_id": "27606572bce70151c8d35868893281ebe1d15a0612e9380a8f5210555ebb1476", + "redacted": false + }, + { + "key": "packageSources.3.name", + "display": "elsewhere", + "source_id": "27606572bce70151c8d35868893281ebe1d15a0612e9380a8f5210555ebb1476", + "redacted": false + } + ] + }, + { + "source_id": "8f2cbcb6c43f5ef71f0d04e71c6f5ef9e6f6627f8902523f435252ac37256a3a", + "scope": "project", + "path": "/home/nuget-fixture/code/corrupt/Corrupt.csproj", + "status": "unreadable", + "reasons": [ + "parse_error" + ], + "settings": [] + }, + { + "source_id": "a28a43f5f03a3d1837ae4f36bfd4d52a314a2973614d77444a53cd979b9ffc2d", + "scope": "project", + "path": "/home/nuget-fixture/code/packed/Directory.Build.targets", + "status": "read", + "reasons": [], + "settings": [ + { + "key": "project.NuGetAudit", + "display": "false", + "source_id": "a28a43f5f03a3d1837ae4f36bfd4d52a314a2973614d77444a53cd979b9ffc2d", + "redacted": false + } + ] + }, + { + "source_id": "74cc57eac33420982dec7005d23d8d94ef125d108df3276c0423aef5065377d1", + "scope": "project", + "path": "/home/nuget-fixture/code/packed/Packed.csproj", + "status": "read", + "reasons": [], + "settings": [ + { + "key": "project.RestorePackagesPath", + "display": "/home/nuget-fixture/code/packed/pkgs", + "source_id": "74cc57eac33420982dec7005d23d8d94ef125d108df3276c0423aef5065377d1", + "redacted": false + } + ] + }, + { + "source_id": "075e7943ada49731d442ff7c291618e3906bc241408f57c9aec3de5ed4c7e705", + "scope": "project", + "path": "/home/nuget-fixture/code/scripts/NuGet.Config", + "status": "read", + "reasons": [], + "settings": [ + { + "key": "config.globalPackagesFolder", + "display": "/home/nuget-fixture/scriptcache", + "source_id": "075e7943ada49731d442ff7c291618e3906bc241408f57c9aec3de5ed4c7e705", + "redacted": false + }, + { + "key": "packageSources.0.enabled", + "display": "true", + "source_id": "075e7943ada49731d442ff7c291618e3906bc241408f57c9aec3de5ed4c7e705", + "redacted": false + }, + { + "key": "packageSources.0.name", + "display": "scripts", + "source_id": "075e7943ada49731d442ff7c291618e3906bc241408f57c9aec3de5ed4c7e705", + "redacted": false + }, + { + "key": "packageSources.0.url", + "display": "http://scripts.example.invalid/v3/index.json", + "source_id": "075e7943ada49731d442ff7c291618e3906bc241408f57c9aec3de5ed4c7e705", + "redacted": false + } + ] + }, + { + "source_id": "4e56b435199afe1d9230c899dac29915441744967527e210c74c026de6494be0", + "scope": "project", + "path": "/home/nuget-fixture/code/shared/Directory.Build.props", + "status": "read", + "reasons": [ + "unsupported_format" + ], + "settings": [ + { + "key": "project.NuGetLockFilePath", + "display": "/home/nuget-fixture/code/shared/locks/shared.lock.json", + "source_id": "4e56b435199afe1d9230c899dac29915441744967527e210c74c026de6494be0", + "redacted": false + } + ] + }, + { + "source_id": "5d1f6faa6be4b08ebed30e6e0694184a479749a4547736e2bce62467c2fa887e", + "scope": "project", + "path": "/home/nuget-fixture/code/shared/lib/Lib.csproj", + "status": "read", + "reasons": [ + "unsupported_format" + ], + "settings": [ + { + "key": "project.NuGetLockFilePath", + "display": "/home/nuget-fixture/code/shared/locks/shared.lock.json", + "source_id": "5d1f6faa6be4b08ebed30e6e0694184a479749a4547736e2bce62467c2fa887e", + "redacted": false + }, + { + "key": "project.RestorePackagesPath", + "display": "/home/nuget-fixture/code/shared/lib/packages", + "source_id": "5d1f6faa6be4b08ebed30e6e0694184a479749a4547736e2bce62467c2fa887e", + "redacted": false + } + ] + }, + { + "source_id": "04360056a7b33b2872e0d1619c90c59d48cc07861c4568012e68b03bbdbbda3a", + "scope": "user", + "path": "/home/nuget-fixture/.nuget/NuGet/NuGet.Config", + "status": "read", + "reasons": [], + "settings": [ + { + "key": "auth_configured", + "display": "true", + "source_id": "04360056a7b33b2872e0d1619c90c59d48cc07861c4568012e68b03bbdbbda3a", + "redacted": true + }, + { + "key": "packageSources.0.enabled", + "display": "true", + "source_id": "04360056a7b33b2872e0d1619c90c59d48cc07861c4568012e68b03bbdbbda3a", + "redacted": false + }, + { + "key": "packageSources.0.name", + "display": "nuget.org", + "source_id": "04360056a7b33b2872e0d1619c90c59d48cc07861c4568012e68b03bbdbbda3a", + "redacted": false + }, + { + "key": "packageSources.0.url", + "display": "https://api.nuget.org/v3/index.json", + "source_id": "04360056a7b33b2872e0d1619c90c59d48cc07861c4568012e68b03bbdbbda3a", + "redacted": false + }, + { + "key": "packageSources.1.allowInsecureConnections", + "display": "false", + "source_id": "04360056a7b33b2872e0d1619c90c59d48cc07861c4568012e68b03bbdbbda3a", + "redacted": false + }, + { + "key": "packageSources.1.disableTLSCertificateValidation", + "display": "true", + "source_id": "04360056a7b33b2872e0d1619c90c59d48cc07861c4568012e68b03bbdbbda3a", + "redacted": false + }, + { + "key": "packageSources.1.enabled", + "display": "true", + "source_id": "04360056a7b33b2872e0d1619c90c59d48cc07861c4568012e68b03bbdbbda3a", + "redacted": false + }, + { + "key": "packageSources.1.name", + "display": "lab", + "source_id": "04360056a7b33b2872e0d1619c90c59d48cc07861c4568012e68b03bbdbbda3a", + "redacted": false + }, + { + "key": "packageSources.1.url", + "display": "https://lab.example.invalid/v3/index.json", + "source_id": "04360056a7b33b2872e0d1619c90c59d48cc07861c4568012e68b03bbdbbda3a", + "redacted": false + } + ] + }, + { + "source_id": "09907e6aa357451e827d9a2ee8bb54314972a215ffcd5b3e526052f8a263ee0d", + "scope": "user", + "path": "/home/nuget-fixture/.nuget/NuGet/config/signers.config", + "status": "read", + "reasons": [], + "settings": [ + { + "key": "trustedSigners.0.allowUntrustedRoot", + "display": "true", + "source_id": "09907e6aa357451e827d9a2ee8bb54314972a215ffcd5b3e526052f8a263ee0d", + "redacted": false + }, + { + "key": "trustedSigners.0.type", + "display": "author", + "source_id": "09907e6aa357451e827d9a2ee8bb54314972a215ffcd5b3e526052f8a263ee0d", + "redacted": false + }, + { + "key": "trustedSigners.1.allowUntrustedRoot", + "display": "false", + "source_id": "09907e6aa357451e827d9a2ee8bb54314972a215ffcd5b3e526052f8a263ee0d", + "redacted": false + }, + { + "key": "trustedSigners.1.type", + "display": "repository", + "source_id": "09907e6aa357451e827d9a2ee8bb54314972a215ffcd5b3e526052f8a263ee0d", + "redacted": false + } + ] + } + ], + "contexts": [ + { + "project_path": "/home/nuget-fixture/code/app", + "context_kind": "ambient", + "status": "complete", + "reasons": [], + "config_source_ids": [ + "c548c514e555ab7ad8184f1a99f0a1dd9f716e0a99c4522b1d30c72575a1c2c7", + "82c4627d6e7f65e5f6da17289e3c6e037f024d6d5067af0120c929b31772329c", + "f604bd9c0bd21a5d23c165aefc86bcb3aba096e83889328cc9dfd582b87cae37", + "20460fb6f0bcc17ab6f5fe831dfc04ad159a4dc671bcc97a7d15ee6c981a328c", + "27606572bce70151c8d35868893281ebe1d15a0612e9380a8f5210555ebb1476", + "04360056a7b33b2872e0d1619c90c59d48cc07861c4568012e68b03bbdbbda3a", + "09907e6aa357451e827d9a2ee8bb54314972a215ffcd5b3e526052f8a263ee0d" + ] + }, + { + "project_path": "/home/nuget-fixture/code/app", + "context_kind": "restore", + "status": "complete", + "reasons": [], + "config_source_ids": [ + "27606572bce70151c8d35868893281ebe1d15a0612e9380a8f5210555ebb1476", + "94267beb213533a15ab10ddad091180719ffa4a53e0b6f1b2a0fa4cf49ca77e0" + ], + "restore_metadata_path": "/home/nuget-fixture/code/app/obj/project.assets.json" + }, + { + "project_path": "/home/nuget-fixture/code/corrupt", + "context_kind": "ambient", + "status": "partial", + "reasons": [ + "parse_error" + ], + "config_source_ids": [ + "c548c514e555ab7ad8184f1a99f0a1dd9f716e0a99c4522b1d30c72575a1c2c7", + "8f2cbcb6c43f5ef71f0d04e71c6f5ef9e6f6627f8902523f435252ac37256a3a", + "f604bd9c0bd21a5d23c165aefc86bcb3aba096e83889328cc9dfd582b87cae37", + "20460fb6f0bcc17ab6f5fe831dfc04ad159a4dc671bcc97a7d15ee6c981a328c", + "04360056a7b33b2872e0d1619c90c59d48cc07861c4568012e68b03bbdbbda3a", + "09907e6aa357451e827d9a2ee8bb54314972a215ffcd5b3e526052f8a263ee0d" + ] + }, + { + "project_path": "/home/nuget-fixture/code/legacy", + "context_kind": "ambient", + "status": "complete", + "reasons": [], + "config_source_ids": [ + "c548c514e555ab7ad8184f1a99f0a1dd9f716e0a99c4522b1d30c72575a1c2c7", + "04360056a7b33b2872e0d1619c90c59d48cc07861c4568012e68b03bbdbbda3a", + "09907e6aa357451e827d9a2ee8bb54314972a215ffcd5b3e526052f8a263ee0d" + ] + }, + { + "project_path": "/home/nuget-fixture/code/packed", + "context_kind": "ambient", + "status": "complete", + "reasons": [], + "config_source_ids": [ + "c548c514e555ab7ad8184f1a99f0a1dd9f716e0a99c4522b1d30c72575a1c2c7", + "74cc57eac33420982dec7005d23d8d94ef125d108df3276c0423aef5065377d1", + "f604bd9c0bd21a5d23c165aefc86bcb3aba096e83889328cc9dfd582b87cae37", + "20460fb6f0bcc17ab6f5fe831dfc04ad159a4dc671bcc97a7d15ee6c981a328c", + "a28a43f5f03a3d1837ae4f36bfd4d52a314a2973614d77444a53cd979b9ffc2d", + "04360056a7b33b2872e0d1619c90c59d48cc07861c4568012e68b03bbdbbda3a", + "09907e6aa357451e827d9a2ee8bb54314972a215ffcd5b3e526052f8a263ee0d" + ] + }, + { + "project_path": "/home/nuget-fixture/code/scripts", + "context_kind": "ambient", + "status": "complete", + "reasons": [], + "config_source_ids": [ + "c548c514e555ab7ad8184f1a99f0a1dd9f716e0a99c4522b1d30c72575a1c2c7", + "075e7943ada49731d442ff7c291618e3906bc241408f57c9aec3de5ed4c7e705", + "04360056a7b33b2872e0d1619c90c59d48cc07861c4568012e68b03bbdbbda3a", + "09907e6aa357451e827d9a2ee8bb54314972a215ffcd5b3e526052f8a263ee0d" + ] + }, + { + "project_path": "/home/nuget-fixture/code/shared/lib", + "context_kind": "ambient", + "status": "partial", + "reasons": [ + "unsupported_format" + ], + "config_source_ids": [ + "c548c514e555ab7ad8184f1a99f0a1dd9f716e0a99c4522b1d30c72575a1c2c7", + "5d1f6faa6be4b08ebed30e6e0694184a479749a4547736e2bce62467c2fa887e", + "4e56b435199afe1d9230c899dac29915441744967527e210c74c026de6494be0", + "20460fb6f0bcc17ab6f5fe831dfc04ad159a4dc671bcc97a7d15ee6c981a328c", + "04360056a7b33b2872e0d1619c90c59d48cc07861c4568012e68b03bbdbbda3a", + "09907e6aa357451e827d9a2ee8bb54314972a215ffcd5b3e526052f8a263ee0d" + ] + }, + { + "project_path": "/home/nuget-fixture/code/tools", + "context_kind": "ambient", + "status": "complete", + "reasons": [], + "config_source_ids": [ + "c548c514e555ab7ad8184f1a99f0a1dd9f716e0a99c4522b1d30c72575a1c2c7", + "04360056a7b33b2872e0d1619c90c59d48cc07861c4568012e68b03bbdbbda3a", + "09907e6aa357451e827d9a2ee8bb54314972a215ffcd5b3e526052f8a263ee0d" + ] + } + ], + "findings": [ + { + "code": "nuget-001", + "severity": "MEDIUM", + "source_id": "075e7943ada49731d442ff7c291618e3906bc241408f57c9aec3de5ed4c7e705", + "key": "packageSources.0.url", + "detail": "NuGet package source uses plaintext HTTP." + }, + { + "code": "nuget-001", + "severity": "MEDIUM", + "source_id": "27606572bce70151c8d35868893281ebe1d15a0612e9380a8f5210555ebb1476", + "key": "packageSources.0.url", + "detail": "NuGet package source uses plaintext HTTP." + }, + { + "code": "nuget-002", + "severity": "HIGH", + "source_id": "04360056a7b33b2872e0d1619c90c59d48cc07861c4568012e68b03bbdbbda3a", + "key": "packageSources.1.disableTLSCertificateValidation", + "detail": "NuGet package source disables TLS certificate validation." + }, + { + "code": "nuget-003", + "severity": "MEDIUM", + "source_id": "27606572bce70151c8d35868893281ebe1d15a0612e9380a8f5210555ebb1476", + "key": "auth_configured", + "detail": "NuGet configuration stores a literal cleartext password." + }, + { + "code": "nuget-004", + "severity": "MEDIUM", + "source_id": "82c4627d6e7f65e5f6da17289e3c6e037f024d6d5067af0120c929b31772329c", + "key": "project.NuGetAudit", + "detail": "NuGet audit is disabled for the project." + }, + { + "code": "nuget-004", + "severity": "MEDIUM", + "source_id": "a28a43f5f03a3d1837ae4f36bfd4d52a314a2973614d77444a53cd979b9ffc2d", + "key": "project.NuGetAudit", + "detail": "NuGet audit is disabled for the project." + } + ], + "observed_registries": [ + "/home/nuget-fixture/code/feed", + "http://feed.example.invalid/v3/index.json", + "http://scripts.example.invalid/v3/index.json", + "https://api.nuget.org/v3/index.json", + "https://lab.example.invalid/v3/index.json", + "https://off.example.invalid/v3/index.json" + ], + "observed_proxies": [ + "http://proxy.example.invalid:8080", + "https://proxy.example.invalid:8443/" + ], + "source_count": 12, + "observed_registry_count": 6, + "observed_proxy_count": 2, + "finding_count": 6, + "finding_code_counts": { + "nuget-001": 2, + "nuget-002": 1, + "nuget-003": 1, + "nuget-004": 2 + }, + "finding_counts": { + "HIGH": 1, + "MEDIUM": 5 + } + } +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index be23d481..32d130b2 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -134,6 +134,8 @@ type Payload struct { CargoConfigAudit *model.CargoConfigAudit `json:"cargo_config_audit,omitempty"` ComposerInventory *model.ComposerInventory `json:"composer_inventory,omitempty"` ComposerConfigAudit *model.ComposerConfigAudit `json:"composer_config_audit,omitempty"` + NuGetInventory *model.NuGetInventory `json:"nuget_inventory,omitempty"` + NuGetConfigAudit *model.NuGetConfigAudit `json:"nuget_config_audit,omitempty"` ExecutionLogs *ExecutionLogs `json:"execution_logs,omitempty"` PerformanceMetrics *PerformanceMetrics `json:"performance_metrics,omitempty"` @@ -1148,6 +1150,14 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err endPhase(phaseCtx, phaseCancel, tracker, log, "composer_scan") postPhase() + // NuGet metadata uses the raw executor and the same developer as Go/Cargo. + phaseCtx, phaseCancel = startPhase(ctx, tracker, "nuget_scan") + log.Progress("Collecting .NET packages and NuGet configuration...") + nugetInventory, nugetConfigAudit := detector.NewNuGetScanner(exec, log).Scan(phaseCtx, browserTarget, searchDirs, cfg.IncludeNetworkVolumes) + log.Progress(" .NET: %d projects, %d package facts (inventory %s, config %s)", len(nugetInventory.Projects), len(nugetInventory.Packages), nugetInventory.Status, nugetConfigAudit.Status) + endPhase(phaseCtx, phaseCancel, tracker, log, "nuget_scan") + postPhase() + // npm + pip configuration audits — surface-only inventory of every // .npmrc and pip.conf on the host, plus the merged effective views // each tool would resolve. We use the user-aware executor so npm and @@ -1313,6 +1323,8 @@ func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) (err err CargoConfigAudit: cargoConfigAudit, ComposerInventory: composerInventory, ComposerConfigAudit: composerConfigAudit, + NuGetInventory: nugetInventory, + NuGetConfigAudit: nugetConfigAudit, ExecutionLogs: &ExecutionLogs{ OutputBase64: execLogsBase64, diff --git a/internal/telemetry/telemetry_out_test.go b/internal/telemetry/telemetry_out_test.go index 708d7deb..75f5548c 100644 --- a/internal/telemetry/telemetry_out_test.go +++ b/internal/telemetry/telemetry_out_test.go @@ -196,3 +196,34 @@ func TestPayloadComposerSections(t *testing.T) { } } } + +// TestPayloadNuGetSections: the NuGet golden decodes strictly into Payload, and a +// payload whose NuGet phase never ran carries neither key. +func TestPayloadNuGetSections(t *testing.T) { + raw, err := os.ReadFile("../model/testdata/nuget_inventory_v1_golden.json") + if err != nil { + t.Fatal(err) + } + var payload Payload + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&payload); err != nil { + t.Fatalf("NuGet golden does not fit Payload: %v", err) + } + if payload.NuGetInventory == nil || payload.NuGetConfigAudit == nil { + t.Fatal("NuGet sections not decoded into Payload") + } + data, err := json.Marshal(&Payload{}) + if err != nil { + t.Fatal(err) + } + var empty map[string]json.RawMessage + if err := json.Unmarshal(data, &empty); err != nil { + t.Fatal(err) + } + for _, key := range []string{"nuget_inventory", "nuget_config_audit"} { + if _, exists := empty[key]; exists { + t.Errorf("unrun section %s was emitted", key) + } + } +}