Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions internal/detector/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ func (d *ExtensionDetector) Detect(ctx context.Context, searchDirs []string, ide
// Eclipse plugins — use detected IDE install paths for accurate discovery
results = append(results, d.DetectEclipsePlugins(ctx, ides)...)

// Classic Visual Studio extensions (extension.vsixmanifest format, Windows-only)
results = append(results, d.DetectVisualStudioExtensions(ctx)...)

return results
}

Expand Down
4 changes: 4 additions & 0 deletions internal/detector/ide.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,10 @@ func (d *IDEDetector) Detect(ctx context.Context) []model.IDE {
}
}

// Classic Visual Studio (Windows-only): discovered via VS setup instance
// data, not a fixed install path, so it isn't part of ideDefinitions.
results = append(results, d.detectVisualStudio()...)

return results
}

Expand Down
113 changes: 113 additions & 0 deletions internal/detector/visualstudio.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package detector

import (
"encoding/json"
"path/filepath"
"strings"

"github.com/step-security/dev-machine-guard/internal/executor"
"github.com/step-security/dev-machine-guard/internal/model"
)

// Classic Visual Studio is discovered very differently from the editor IDEs in
// ideDefinitions: there is no single fixed install path or app bundle. The
// authoritative source is the VS setup instance data under
// %PROGRAMDATA%\Microsoft\VisualStudio\Packages\_Instances\<id>\state.json,
// which records the install path and version for each installed instance
// (multiple editions/years can coexist). This file holds the shared instance
// discovery used by both IDE detection and extension scanning.

// vsInstance is a discovered classic Visual Studio installation.
type vsInstance struct {
InstallPath string
Version string
}

// discoverVisualStudioInstances finds installed classic Visual Studio instances
// (Windows). The authoritative source is the VS setup instance data, with a
// Program Files glob as a fallback. Returns nil on non-Windows hosts (the
// Windows env vars it reads are empty there).
func discoverVisualStudioInstances(exec executor.Executor) []vsInstance {
var instances []vsInstance
seen := make(map[string]bool)
add := func(inst vsInstance) {
if inst.InstallPath == "" {
return
}
key := strings.ToLower(filepath.Clean(inst.InstallPath))
if seen[key] {
return
}
seen[key] = true
instances = append(instances, inst)
}

// Primary: VS setup instance data — %PROGRAMDATA%\Microsoft\VisualStudio\Packages\_Instances\<id>\state.json
if programData := exec.Getenv("PROGRAMDATA"); programData != "" {
pattern := filepath.Join(programData, "Microsoft", "VisualStudio", "Packages", "_Instances", "*", "state.json")
matches, _ := exec.Glob(pattern)
for _, stateFile := range matches {
add(readVSInstanceState(exec, stateFile))
}
}

// Fallback: well-known install locations. VS 2017/2019 are 32-bit (x86).
for _, base := range []string{exec.Getenv("PROGRAMFILES"), exec.Getenv("PROGRAMFILES(X86)")} {
if base == "" {
continue
}
// e.g. C:\Program Files\Microsoft Visual Studio\2022\Community
pattern := filepath.Join(base, "Microsoft Visual Studio", "*", "*")
matches, _ := exec.Glob(pattern)
for _, m := range matches {
if exec.DirExists(m) {
add(vsInstance{InstallPath: m})
}
}
}

return instances
}

// readVSInstanceState reads installationPath and installationVersion from a VS
// setup state.json.
func readVSInstanceState(exec executor.Executor, stateFile string) vsInstance {
data, err := exec.ReadFile(stateFile)
if err != nil {
return vsInstance{}
}
var state struct {
InstallationPath string `json:"installationPath"`
InstallationVersion string `json:"installationVersion"`
}
if err := json.Unmarshal(data, &state); err != nil {
return vsInstance{}
}
return vsInstance{InstallPath: state.InstallationPath, Version: state.InstallationVersion}
}

// detectVisualStudio reports installed classic Visual Studio instances as IDEs.
// Windows-only. VS is discovered via setup instance data rather than a fixed
// install path, so it isn't part of ideDefinitions. Each installed instance
// (e.g. different editions or years) is reported separately.
func (d *IDEDetector) detectVisualStudio() []model.IDE {
if d.exec.GOOS() != model.PlatformWindows {
return nil
}

var results []model.IDE
for _, inst := range discoverVisualStudioInstances(d.exec) {
version := inst.Version
if version == "" {
version = "unknown"
}
results = append(results, model.IDE{
IDEType: "visual_studio",
Version: version,
InstallPath: inst.InstallPath,
Vendor: "Microsoft",
IsInstalled: true,
})
}
return results
}
202 changes: 202 additions & 0 deletions internal/detector/visualstudio_extensions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package detector

import (
"context"
"encoding/xml"
"path/filepath"
"strings"

"github.com/step-security/dev-machine-guard/internal/model"
)

// Classic Visual Studio (Community/Professional/Enterprise) stores extensions
// very differently from VS Code: instead of a "publisher.name-version"
// directory, each extension lives in a randomly named folder containing an
// extension.vsixmanifest XML file that carries its identity. This detector is
// Windows-only and reads those manifests directly.
//
// Locations scanned:
// - Per-user (user_installed): %LOCALAPPDATA%\Microsoft\VisualStudio\<ver>_<id>\Extensions\<rand>\
// - All-users (bundled): %PROGRAMDATA%\Microsoft\VisualStudio\<ver>\Extensions\<rand>\
// - Install-dir built-ins (bundled): <VSInstallDir>\Common7\IDE\Extensions\...
//
// Built-ins are tagged "bundled" so the existing Windows default filter
// (model.FilterUserInstalledExtensions) hides them unless
// --include-bundled-plugins is set, mirroring Eclipse/JetBrains behavior.

// vsixIdentity holds the <Identity> attributes common to both VSIX schemas.
type vsixIdentity struct {
ID string `xml:"Id,attr"`
Version string `xml:"Version,attr"`
Publisher string `xml:"Publisher,attr"`
}

// vsixManifest captures the fields we read from an extension.vsixmanifest.
// It tolerates both VSIX schema versions; encoding/xml matches local element
// names regardless of the differing root element and XML namespace:
// - v2 (2011): <PackageManifest><Metadata><Identity .../><DisplayName/></Metadata></PackageManifest>
// - v1 (2010): <Vsix><Identity .../><Name/><Author/></Vsix>
type vsixManifest struct {
// VSIX v2 (2011): Identity is nested under <Metadata>.
Metadata struct {
Identity vsixIdentity `xml:"Identity"`
DisplayName string `xml:"DisplayName"`
} `xml:"Metadata"`
// VSIX v1 (2010): Identity is a direct child of the root <Vsix> element.
Identity vsixIdentity `xml:"Identity"`
Name string `xml:"Name"`
Author string `xml:"Author"`
}

// toExtension normalizes a parsed manifest into a model.Extension, or nil if
// it lacks the minimum identity (Id + Version).
func (m *vsixManifest) toExtension() *model.Extension {
// VSIX v2 nests Identity under <Metadata>; v1 has it as a direct child.
identity := m.Metadata.Identity
if identity.ID == "" {
identity = m.Identity
}
if identity.ID == "" || identity.Version == "" {
return nil
}

publisher := identity.Publisher
if publisher == "" {
publisher = m.Author // v1 carries the publisher in <Author>
}

name := m.Metadata.DisplayName
if name == "" {
name = m.Name // v1 uses <Name>
}
if name == "" {
name = identity.ID
}

return &model.Extension{
ID: identity.ID,
Name: name,
Version: identity.Version,
Publisher: publisher,
IDEType: "visual_studio",
}
}

// DetectVisualStudioExtensions discovers classic Visual Studio extensions.
// Windows-only; returns nil on other platforms.
func (d *ExtensionDetector) DetectVisualStudioExtensions(_ context.Context) []model.Extension {
if d.exec.GOOS() != model.PlatformWindows {
return nil
}

seen := make(map[string]bool)
var results []model.Extension

collect := func(roots []string, source string) {
for _, root := range roots {
for _, ext := range d.collectVSExtensionsFromDir(root, source) {
key := strings.ToLower(ext.ID) + "@" + ext.Version
if seen[key] {
continue
}
seen[key] = true
results = append(results, ext)
}
}
}

// User-installed first so a user copy wins over a bundled duplicate.
collect(d.visualStudioUserExtensionRoots(), "user_installed")
collect(d.visualStudioBundledExtensionRoots(), "bundled")

return results
}

// visualStudioUserExtensionRoots returns the per-user "Extensions" directories,
// one per installed VS instance.
func (d *ExtensionDetector) visualStudioUserExtensionRoots() []string {
localAppData := d.exec.Getenv("LOCALAPPDATA")
if localAppData == "" {
return nil
}
// Each instance is %LOCALAPPDATA%\Microsoft\VisualStudio\<major>.<minor>_<instanceId>\Extensions
pattern := filepath.Join(localAppData, "Microsoft", "VisualStudio", "*", "Extensions")
matches, _ := d.exec.Glob(pattern)
return matches
}

// visualStudioBundledExtensionRoots returns the all-users / install-dir
// "Extensions" directories. Everything found under these is treated as bundled.
func (d *ExtensionDetector) visualStudioBundledExtensionRoots() []string {
var roots []string

// Install-dir built-ins: <VSInstallDir>\Common7\IDE\Extensions
for _, inst := range discoverVisualStudioInstances(d.exec) {
roots = append(roots, filepath.Join(inst.InstallPath, "Common7", "IDE", "Extensions"))
}

// All-users VSIX: %PROGRAMDATA%\Microsoft\VisualStudio\<ver>\Extensions
if programData := d.exec.Getenv("PROGRAMDATA"); programData != "" {
pattern := filepath.Join(programData, "Microsoft", "VisualStudio", "*", "Extensions")
matches, _ := d.exec.Glob(pattern)
roots = append(roots, matches...)
}

return roots
}

// collectVSExtensionsFromDir scans an "Extensions" root directory. Each
// immediate subdirectory is one installed extension containing an
// extension.vsixmanifest.
func (d *ExtensionDetector) collectVSExtensionsFromDir(extRoot, source string) []model.Extension {
if !d.exec.DirExists(extRoot) {
return nil
}

entries, err := d.exec.ReadDir(extRoot)
if err != nil {
return nil
}

var results []model.Extension
for _, entry := range entries {
if !entry.IsDir() {
continue
}
extPath := filepath.Join(extRoot, entry.Name())
ext := d.parseVSIXManifestDir(extPath)
if ext == nil {
continue
}

ext.Source = source
ext.InstallPath = extPath
if info, err := d.exec.Stat(extPath); err == nil {
ext.InstallDate = info.ModTime().Unix()
}

results = append(results, *ext)
}

return results
}

// parseVSIXManifestDir reads and parses <extPath>/extension.vsixmanifest.
func (d *ExtensionDetector) parseVSIXManifestDir(extPath string) *model.Extension {
manifestPath := filepath.Join(extPath, "extension.vsixmanifest")
data, err := d.exec.ReadFile(manifestPath)
if err != nil {
return nil
}
return parseVSIXManifest(data)
}

// parseVSIXManifest parses extension.vsixmanifest XML bytes into a
// model.Extension, or nil if the content is malformed or lacks an identity.
func parseVSIXManifest(data []byte) *model.Extension {
var m vsixManifest
if err := xml.Unmarshal(data, &m); err != nil {
return nil
}
return m.toExtension()
}
Loading
Loading