diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 76437fb..080fc0b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -10,9 +10,9 @@ body: - type: input id: version attributes: - label: Script Version - description: "Run: ./stepsecurity-dev-machine-guard.sh --version" - placeholder: "1.8.1" + label: Version + description: "Run: ./stepsecurity-dev-machine-guard --version" + placeholder: "1.9.0" validations: required: true - type: input @@ -28,7 +28,7 @@ body: attributes: label: Command Run description: The exact command you ran - placeholder: "./stepsecurity-dev-machine-guard.sh --json" + placeholder: "./stepsecurity-dev-machine-guard --json" validations: required: true - type: textarea diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 12ef8a7..6372866 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -11,10 +11,11 @@ ## Testing - [ ] Tested on macOS (version: ___) -- [ ] Script runs without errors: `./stepsecurity-dev-machine-guard.sh --verbose` -- [ ] JSON output is valid: `./stepsecurity-dev-machine-guard.sh --json | python3 -m json.tool` +- [ ] Binary runs without errors: `./stepsecurity-dev-machine-guard --verbose` +- [ ] JSON output is valid: `./stepsecurity-dev-machine-guard --json | python3 -m json.tool` - [ ] No secrets or credentials included -- [ ] ShellCheck passes (if script was modified) +- [ ] Lint passes: `make lint` +- [ ] Tests pass: `make test` ## Related Issues diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d2a3d3..ff0be71 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,11 +9,10 @@ jobs: release: name: Build, Sign & Release runs-on: ubuntu-latest - environment: release permissions: - contents: write # create tag, release, and upload assets - id-token: write # Sigstore OIDC keyless signing - attestations: write # SLSA build provenance + contents: write # create tag, release, and upload assets + id-token: write # OIDC token for cosign keyless signing and build provenance + attestations: write # SLSA build provenance steps: - name: Harden the runner (Audit all outbound calls) @@ -23,13 +22,15 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 - - name: Extract version from script + - name: Extract version from source id: version run: | - version=$(grep -m1 '^AGENT_VERSION=' stepsecurity-dev-machine-guard.sh | sed 's/AGENT_VERSION="//;s/"//') + version=$(grep -m1 'Version.*=' internal/buildinfo/version.go | sed 's/.*"\(.*\)".*/\1/') if [ -z "$version" ]; then - echo "::error::Could not extract AGENT_VERSION from script" + echo "::error::Could not extract Version from internal/buildinfo/version.go" exit 1 fi tag="v${version}" @@ -40,52 +41,98 @@ jobs: - name: Check tag does not already exist run: | if git rev-parse "refs/tags/${{ steps.version.outputs.tag }}" >/dev/null 2>&1; then - echo "::error::Tag ${{ steps.version.outputs.tag }} already exists. Bump AGENT_VERSION in the script before releasing." + echo "::error::Tag ${{ steps.version.outputs.tag }} already exists. Bump Version in internal/buildinfo/version.go before releasing." exit 1 fi + - name: Create tag + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}" + git push origin "${{ steps.version.outputs.tag }}" + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Install cosign uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 - - name: Sign script with Sigstore (keyless) + - name: Prepare release artifacts for signing run: | - cosign sign-blob stepsecurity-dev-machine-guard.sh \ - --bundle stepsecurity-dev-machine-guard.sh.bundle \ - --yes + # Copy binaries to match the exact names users download from the release. + # GoReleaser uploads as name_template (e.g. stepsecurity-dev-machine-guard_darwin_amd64) + # but keeps them in build subdirs locally. We copy to dist/ with release names + # so cosign signs the same bytes users verify against. + AMD64_SRC=$(find dist -type f -name 'stepsecurity-dev-machine-guard' -path '*darwin_amd64*' | head -1) + ARM64_SRC=$(find dist -type f -name 'stepsecurity-dev-machine-guard' -path '*darwin_arm64*' | head -1) - - name: Verify signature + for label in "amd64:${AMD64_SRC}" "arm64:${ARM64_SRC}"; do + name="${label%%:*}" + path="${label#*:}" + if [ -z "$path" ] || [ ! -f "$path" ]; then + echo "::error::Binary not found for ${name}" + find dist -type f + exit 1 + fi + done + + cp "$AMD64_SRC" dist/stepsecurity-dev-machine-guard_darwin_amd64 + cp "$ARM64_SRC" dist/stepsecurity-dev-machine-guard_darwin_arm64 + echo "Prepared release artifacts for signing" + + - name: Sign artifacts with Sigstore (keyless) run: | - cosign verify-blob stepsecurity-dev-machine-guard.sh \ - --bundle stepsecurity-dev-machine-guard.sh.bundle \ - --certificate-identity-regexp "github.com/step-security/dev-machine-guard" \ - --certificate-oidc-issuer "https://token.actions.githubusercontent.com" + cosign sign-blob dist/stepsecurity-dev-machine-guard_darwin_amd64 \ + --bundle dist/stepsecurity-dev-machine-guard_darwin_amd64.bundle --yes + cosign sign-blob dist/stepsecurity-dev-machine-guard_darwin_arm64 \ + --bundle dist/stepsecurity-dev-machine-guard_darwin_arm64.bundle --yes + cosign sign-blob stepsecurity-dev-machine-guard.sh \ + --bundle dist/stepsecurity-dev-machine-guard.sh.bundle --yes - name: Generate checksums run: | - sha256sum stepsecurity-dev-machine-guard.sh > checksums.txt - sha256sum stepsecurity-dev-machine-guard.sh.bundle >> checksums.txt - echo "Checksums:" - cat checksums.txt + # Separate checksum file for cosign-signed artifacts (script + bundles). + # GoReleaser already generates checksums for the Go binaries in its own SHA256SUMS file. + sha256sum dist/stepsecurity-dev-machine-guard_darwin_amd64 > dist/cosign-checksums.txt + sha256sum dist/stepsecurity-dev-machine-guard_darwin_arm64 >> dist/cosign-checksums.txt + sha256sum stepsecurity-dev-machine-guard.sh >> dist/cosign-checksums.txt - - name: Create tag + - name: Upload signature bundles and checksums to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}" - git push origin "${{ steps.version.outputs.tag }}" + gh release upload "${{ steps.version.outputs.tag }}" \ + dist/stepsecurity-dev-machine-guard_darwin_amd64.bundle \ + dist/stepsecurity-dev-machine-guard_darwin_arm64.bundle \ + dist/stepsecurity-dev-machine-guard.sh.bundle \ + dist/cosign-checksums.txt \ + --clobber - - name: Create GitHub Release - uses: step-security/action-gh-release@d45511d7589f080cf54961ff056b9705a74fd160 # v2.5.0 - with: - tag_name: ${{ steps.version.outputs.tag }} - name: ${{ steps.version.outputs.tag }} - generate_release_notes: true - files: | - stepsecurity-dev-machine-guard.sh - stepsecurity-dev-machine-guard.sh.bundle - checksums.txt + - name: Mark release as immutable (not a draft, not a prerelease) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release edit "${{ steps.version.outputs.tag }}" \ + --draft=false \ + --prerelease=false \ + --latest - name: Attest build provenance uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: - subject-path: stepsecurity-dev-machine-guard.sh + subject-path: | + dist/stepsecurity-dev-machine-guard_darwin_amd64 + dist/stepsecurity-dev-machine-guard_darwin_arm64 + stepsecurity-dev-machine-guard.sh diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..8bec676 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,44 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + lint: + name: Lint + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: golangci/golangci-lint-action@v6 + with: + version: latest + + test: + name: Test + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: make test + + smoke: + name: Smoke Tests + runs-on: macos-latest + needs: test + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: make smoke diff --git a/.gitignore b/.gitignore index 5a7cd7e..e50978c 100644 --- a/.gitignore +++ b/.gitignore @@ -17,5 +17,9 @@ !docs/**/*.html !images/**/*.html +# Go build artifacts +/stepsecurity-dev-machine-guard +dist/ + # Temporary files todo-remove/ diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..74747e1 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,34 @@ +version: 2 +project_name: stepsecurity-dev-machine-guard + +builds: + - id: stepsecurity-dev-machine-guard + main: ./cmd/stepsecurity-dev-machine-guard + binary: stepsecurity-dev-machine-guard + goos: + - darwin + goarch: + - amd64 + - arm64 + mod_timestamp: "{{ .CommitTimestamp }}" + flags: + - -trimpath + ldflags: + - -s -w + - -X github.com/step-security/dev-machine-guard/internal/buildinfo.GitCommit={{.FullCommit}} + - -X github.com/step-security/dev-machine-guard/internal/buildinfo.ReleaseTag={{.Tag}} + - -X github.com/step-security/dev-machine-guard/internal/buildinfo.ReleaseBranch={{.Branch}} + env: + - CGO_ENABLED=0 + +archives: + - format: binary + name_template: "{{ .Binary }}_{{ .Os }}_{{ .Arch }}" + +checksum: + name_template: "{{ .ProjectName }}_{{ .Version }}_SHA256SUMS" + algorithm: sha256 + +release: + extra_files: + - glob: stepsecurity-dev-machine-guard.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ae7f08..d4a2343 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 See [VERSIONING.md](VERSIONING.md) for why the version starts at 1.8.1. +## [1.9.0] - 2026-04-03 + +Migrated from shell script to a compiled Go binary. All existing scanning features, detection logic, CLI flags, output formats, and enterprise telemetry are preserved — this release changes the implementation, not the functionality. + +### Added +- **Go binary**: Single compiled binary (`stepsecurity-dev-machine-guard`) replaces the shell script. Zero external dependencies, no runtime required. +- **`configure` / `configure show` commands**: Interactive setup and display of enterprise credentials, search directories, and preferences. Saved to `~/.stepsecurity/config.json`. + ## [1.8.2] - 2026-03-17 ### Added @@ -44,5 +52,6 @@ First open-source release. The scanning engine was previously an internal enterp - Execution log capture and base64 encoding - Instance locking to prevent concurrent runs +[1.9.0]: https://github.com/step-security/dev-machine-guard/compare/v1.8.2...v1.9.0 [1.8.2]: https://github.com/step-security/dev-machine-guard/compare/v1.8.1...v1.8.2 [1.8.1]: https://github.com/step-security/dev-machine-guard/releases/tag/v1.8.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b2ce56c..99b5e56 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,21 +9,15 @@ Thank you for your interest in contributing! Dev Machine Guard is an open-source To add detection for a new AI tool, IDE, or framework: 1. Open an issue using the [Feature Request](.github/ISSUE_TEMPLATE/feature_request.yml) template, or -2. Submit a PR modifying `stepsecurity-dev-machine-guard.sh` +2. Submit a PR modifying the appropriate detector in `internal/detector/` **How to add a new IDE/desktop app:** -Find the `detect_ide_installations()` function and add an entry to the `apps` array: -```bash -"App Name|type_id|Vendor|/Applications/App.app|Contents/MacOS/binary|--version" -``` +Find the IDE detector in `internal/detector/ide.go` and add an entry to the apps list. See [Adding Detections](docs/adding-detections.md) for the full guide. **How to add a new AI CLI tool:** -Find the `detect_ai_cli_tools()` function and add an entry to the `tools` array: -```bash -"tool-name|Vendor|binary1,binary2|~/.config-dir1,~/.config-dir2" -``` +Find the AI CLI detector in `internal/detector/ai_cli.go` and add an entry to the tools list. See [Adding Detections](docs/adding-detections.md) for the full guide. ### Improve Documentation @@ -37,37 +31,37 @@ Documentation lives in the `docs/` folder. Improvements, corrections, and new gu cd dev-machine-guard ``` -2. Make the script executable: +2. Build the binary: ```bash - chmod +x stepsecurity-dev-machine-guard.sh + make build ``` 3. Run locally: ```bash # Pretty output with progress messages - ./stepsecurity-dev-machine-guard.sh --verbose + ./stepsecurity-dev-machine-guard --verbose # JSON output - ./stepsecurity-dev-machine-guard.sh --json + ./stepsecurity-dev-machine-guard --json # HTML report - ./stepsecurity-dev-machine-guard.sh --html report.html + ./stepsecurity-dev-machine-guard --html report.html ``` ## Code Style -- The script must pass [ShellCheck](https://www.shellcheck.net/) (our CI runs it on every PR) -- Follow the existing code patterns (section headers, function naming, JSON construction) -- Use `print_progress` for status messages (they respect the `--verbose` flag) -- Use `print_error` for error messages (always shown) +- Go source code in `internal/` must pass `golangci-lint` (our CI runs it on every PR) +- Follow the existing code patterns (package structure, naming conventions, JSON struct tags) +- Use the `progress` package for status messages (they respect the `--verbose` flag) +- Use standard Go error handling patterns ## Pull Request Process 1. Fork the repository 2. Create a feature branch (`git checkout -b add-new-tool-detection`) -3. Make your changes -4. Test locally: `./stepsecurity-dev-machine-guard.sh --verbose` -5. Ensure ShellCheck passes: `shellcheck stepsecurity-dev-machine-guard.sh` +3. Edit Go source in `internal/` (not the legacy shell script) +4. Test locally: `./stepsecurity-dev-machine-guard --verbose` +5. Ensure lint and tests pass: `make lint && make test && make smoke` 6. Submit a PR using our [PR template](.github/pull_request_template.md) ## Reporting Issues diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3f99b0a --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +BINARY := stepsecurity-dev-machine-guard +MODULE := github.com/step-security/dev-machine-guard +VERSION := $(shell grep -m1 'Version' internal/buildinfo/version.go | sed 's/.*"//;s/".*//') +COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +BRANCH := $(shell git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") +TAG := $(shell git describe --tags --exact-match 2>/dev/null || echo "dev") +LDFLAGS := -s -w \ + -X $(MODULE)/internal/buildinfo.GitCommit=$(COMMIT) \ + -X $(MODULE)/internal/buildinfo.ReleaseTag=$(TAG) \ + -X $(MODULE)/internal/buildinfo.ReleaseBranch=$(BRANCH) + +.PHONY: build test lint clean smoke + +build: + go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/stepsecurity-dev-machine-guard + +test: + go test ./... -v -race -count=1 + +lint: + golangci-lint run ./... + +clean: + rm -f $(BINARY) + +smoke: build + bash tests/test_smoke_go.sh diff --git a/README.md b/README.md index be4e1ce..483910b 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,10 @@

+ Go CI ShellCheck CI License: Apache 2.0 - Version 1.8.1 + Version 1.9.0

@@ -26,17 +27,16 @@ Developer machines are the new attack surface. They hold high-value assets — G Developer machine attack surface — malicious extensions, rogue MCP servers, unvetted AI agents, compromised packages

- **EDR and traditional MDM solutions** monitor device posture and compliance, but they have **zero visibility** into the developer tooling layer: -| Capability | EDR / MDM | Dev Machine Guard | -|-----------------------------------|:---------:|:-----------------:| -| IDE extension audit | | Yes | -| AI agent & tool inventory | | Yes | -| MCP server config audit | | Yes | -| Node.js package scanning | | Yes | -| Device posture & compliance | Yes | | -| Malware / virus detection | Yes | | +| Capability | EDR / MDM | Dev Machine Guard | +| --------------------------- | :-------: | :---------------: | +| IDE extension audit | | Yes | +| AI agent & tool inventory | | Yes | +| MCP server config audit | | Yes | +| Node.js package scanning | | Yes | +| Device posture & compliance | Yes | | +| Malware / virus detection | Yes | | **Dev Machine Guard is complementary to EDR/MDM — not a replacement.** Deploy it alongside your existing tools via MDM (Jamf, Kandji, Intune) or run it standalone. @@ -46,46 +46,205 @@ Developer machines are the new attack surface. They hold high-value assets — G ## Quick Start +### Install from release (recommended) + +Download the latest binary for your platform from [GitHub Releases](https://github.com/step-security/dev-machine-guard/releases): + +```bash +# Apple Silicon (M1/M2/M3/M4) +curl -sSL https://github.com/step-security/dev-machine-guard/releases/latest/download/stepsecurity-dev-machine-guard_darwin_arm64 -o stepsecurity-dev-machine-guard +chmod +x stepsecurity-dev-machine-guard + +# Intel Mac +curl -sSL https://github.com/step-security/dev-machine-guard/releases/latest/download/stepsecurity-dev-machine-guard_darwin_amd64 -o stepsecurity-dev-machine-guard +chmod +x stepsecurity-dev-machine-guard + +# Run the scan +./stepsecurity-dev-machine-guard +``` + +### Build from source + ```bash -# Clone the repository git clone https://github.com/step-security/dev-machine-guard.git cd dev-machine-guard +make build +./stepsecurity-dev-machine-guard +``` -# Make the script executable -chmod +x stepsecurity-dev-machine-guard.sh +Requires Go 1.24+. The binary has zero external dependencies. -# Run the scan -./stepsecurity-dev-machine-guard.sh +## Usage + +``` +stepsecurity-dev-machine-guard [COMMAND] [OPTIONS] +``` + +### Commands + +| Command | Description | +| ---------------- | --------------------------------------------------------------- | +| _(none)_ | Run a scan (community mode, pretty output) | +| `configure` | Interactively set all settings (enterprise, scan, output) | +| `configure show` | Show current configuration (API key masked) | +| `install` | Install launchd for periodic scanning (enterprise) | +| `uninstall` | Remove launchd configuration (enterprise) | +| `send-telemetry` | Upload scan results to the StepSecurity dashboard (enterprise) | + +### Output Formats + +| Flag | Description | +| ------------- | ---------------------------------------- | +| `--pretty` | Pretty terminal output (default) | +| `--json` | JSON output to stdout | +| `--html FILE` | Self-contained HTML report saved to FILE | + +### Options + +| Flag | Description | +| ---------------------------- | ------------------------------------------------------------- | +| `--search-dirs DIR [DIR...]` | Search DIRs instead of `$HOME` (replaces default; repeatable) | +| `--enable-npm-scan` | Enable Node.js package scanning | +| `--disable-npm-scan` | Disable Node.js package scanning | +| `--verbose` | Show progress messages (suppressed by default) | +| `--color=WHEN` | Color mode: `auto` \| `always` \| `never` (default: `auto`) | +| `-v`, `--version` | Show version | +| `-h`, `--help` | Show help | + +### Examples + +```bash +# Pretty terminal output (default) +./stepsecurity-dev-machine-guard + +# JSON output +./stepsecurity-dev-machine-guard --json +./stepsecurity-dev-machine-guard --json | python3 -m json.tool # formatted +./stepsecurity-dev-machine-guard --json > scan.json # to file + +# HTML report +./stepsecurity-dev-machine-guard --html report.html + +# Verbose scan with npm packages — shows progress spinners and timing +./stepsecurity-dev-machine-guard --verbose --enable-npm-scan + +# Scan specific directories instead of $HOME +./stepsecurity-dev-machine-guard --search-dirs /Volumes/code +./stepsecurity-dev-machine-guard --search-dirs /tmp /opt # multiple dirs + +# Pipe JSON through jq to extract just AI tools +./stepsecurity-dev-machine-guard --json | jq '.ai_agents_and_tools' + +# Count IDE extensions +./stepsecurity-dev-machine-guard --json | jq '.summary.ide_extensions_count' + +# Check for MCP configs (exit 1 if any found — useful in CI) +count=$(./stepsecurity-dev-machine-guard --json | jq '.summary.mcp_configs_count') +[ "$count" -gt 0 ] && echo "MCP servers detected!" && exit 1 + +# Disable colors for piping or logging +./stepsecurity-dev-machine-guard --color=never 2>&1 | tee scan.log + +# Enterprise: configure all settings interactively +./stepsecurity-dev-machine-guard configure + +# Enterprise: view saved configuration (API key masked) +./stepsecurity-dev-machine-guard configure show + +# Enterprise: install scheduled scanning via launchd +./stepsecurity-dev-machine-guard install + +# Enterprise: one-time telemetry upload +./stepsecurity-dev-machine-guard send-telemetry + +# Enterprise: remove scheduled scanning +./stepsecurity-dev-machine-guard uninstall +``` + +## Configuration + +Run `configure` to set up enterprise credentials and default search directories: + +```bash +./stepsecurity-dev-machine-guard configure +``` + +This interactively prompts for all configurable settings: + +| Setting | Description | Default | +| ------------------ | ------------------------------------------- | --------------- | +| Customer ID | Your StepSecurity customer identifier | _(not set)_ | +| API Endpoint | StepSecurity backend URL | _(not set)_ | +| API Key | Authentication key for telemetry uploads | _(not set)_ | +| Scan Frequency | How often launchd runs scans (hours) | _(not set)_ | +| Search Directories | Comma-separated list of directories to scan | `$HOME` | +| Enable NPM Scan | Node.js package scanning | `auto` | +| Color Mode | Terminal color output | `auto` | +| Output Format | Default output format | `pretty` | +| HTML Output File | Default path for HTML reports | _(not set)_ | +| Quiet Mode | Suppress progress messages | `false` | + +View current settings: + +```bash +./stepsecurity-dev-machine-guard configure show +``` + +``` +Configuration (~/.stepsecurity/config.json): + + Customer ID: my-company + API Endpoint: https://api.stepsecurity.io + API Key: ***a1b2 + Scan Frequency: 4 hours + Search Directories: $HOME, /Volumes/code + Enable NPM Scan: auto + Color Mode: auto + Output Format: pretty + Quiet Mode: false ``` -Or run directly without cloning: +Configuration is saved to `~/.stepsecurity/config.json` with `0600` permissions (owner read/write only). + +**CLI flags always override config file values** — this matches the shell script behavior. For example, if your config has `output_format: json`, running `./stepsecurity-dev-machine-guard --pretty` uses pretty output. To clear a value during configuration, enter a single dash (`-`). + +### Verbose and Quiet Mode + +By default in community mode, progress messages (spinners, step details) are **suppressed** — you only see the final output. This keeps stdout clean for piping. ```bash -curl -sSL https://raw.githubusercontent.com/step-security/dev-machine-guard/main/stepsecurity-dev-machine-guard.sh -o stepsecurity-dev-machine-guard.sh -chmod +x stepsecurity-dev-machine-guard.sh -./stepsecurity-dev-machine-guard.sh +# Default: quiet — clean output, no progress spinners +./stepsecurity-dev-machine-guard --json > scan.json + +# Verbose: show progress spinners and step timing +./stepsecurity-dev-machine-guard --verbose + +# Save quiet=true in config so it persists across runs +./stepsecurity-dev-machine-guard configure ``` +In enterprise mode (`send-telemetry`, `install`), progress is **always shown** regardless of the quiet setting — the output is captured as execution logs and sent to the backend for debugging. + ## What It Detects See [SCAN_COVERAGE.md](SCAN_COVERAGE.md) for the full catalog of supported detections. -| Category | Examples | -|-----------------------|-------------------------------------------------------| -| IDEs & Desktop Apps | VS Code, Cursor, Windsurf, Zed, Claude, Copilot | -| AI CLI Tools | Claude Code, Codex, Gemini CLI, Kiro, Aider | -| AI Agents | Claude Cowork, OpenClaw, GPT-Engineer | -| AI Frameworks | Ollama, LM Studio, LocalAI | -| MCP Server Configs | Claude Desktop, Cursor, Windsurf, Zed, Codex | -| IDE Extensions | VS Code, Cursor | -| Node.js Packages | npm, yarn, pnpm, bun (opt-in) | +| Category | Examples | +| ------------------- | ---------------------------------------------------------------------------------------- | +| IDEs & Desktop Apps | VS Code, Cursor, Windsurf, Antigravity, Zed, Claude, Copilot | +| AI CLI Tools | Claude Code, Codex, Gemini CLI, Kiro, GitHub Copilot CLI, Aider, OpenCode | +| AI Agents | Claude Cowork, OpenClaw, ClawdBot, GPT-Engineer | +| AI Frameworks | Ollama, LM Studio, LocalAI, Text Generation WebUI | +| MCP Server Configs | Claude Desktop, Claude Code, Cursor, Windsurf, Antigravity, Zed, Open Interpreter, Codex | +| IDE Extensions | VS Code, Cursor (name, publisher, version, install date) | +| Node.js Packages | npm, yarn, pnpm, bun (opt-in) | ## Output Formats ### Pretty Terminal Output (default) ```bash -./stepsecurity-dev-machine-guard.sh +./stepsecurity-dev-machine-guard ```

@@ -95,62 +254,73 @@ See [SCAN_COVERAGE.md](SCAN_COVERAGE.md) for the full catalog of supported detec ### JSON Output ```bash -./stepsecurity-dev-machine-guard.sh --json -./stepsecurity-dev-machine-guard.sh --json | python3 -m json.tool # formatted -./stepsecurity-dev-machine-guard.sh --json > scan.json # to file +./stepsecurity-dev-machine-guard --json ``` +See [examples/sample-output.json](examples/sample-output.json) for the full schema, or [Reading Scan Results](docs/reading-scan-results.md) for the schema reference. + ### HTML Report ```bash -./stepsecurity-dev-machine-guard.sh --html report.html +./stepsecurity-dev-machine-guard --html report.html ```

HTML report with summary cards, device info, and detailed tables

-### Additional Options +## Community vs Enterprise + +| Feature | Community (Free) | Enterprise | +| ----------------------------- | :--------------: | :--------: | +| AI agent & tool inventory | Yes | Yes | +| IDE extension scanning | Yes | Yes | +| MCP server config audit | Yes | Yes | +| Pretty / JSON / HTML output | Yes | Yes | +| Node.js package scanning | Opt-in | Default on | +| Interactive configuration | Yes | Yes | +| Centralized dashboard | | Yes | +| Policy enforcement & alerting | | Yes | +| Scheduled scans via launchd | | Yes | +| Historical trends & reporting | | Yes | + +Enterprise mode requires a StepSecurity subscription. [Start a 14-day free trial](https://www.stepsecurity.io/start-free) by installing the StepSecurity GitHub App. + +### Enterprise Setup ```bash -./stepsecurity-dev-machine-guard.sh --verbose # Show progress messages -./stepsecurity-dev-machine-guard.sh --enable-npm-scan --json # Include npm packages -./stepsecurity-dev-machine-guard.sh --help # Full usage -``` +# 1. Configure credentials (interactive) +./stepsecurity-dev-machine-guard configure -## Community vs Enterprise +# 2. Install scheduled scanning via launchd +./stepsecurity-dev-machine-guard install -| Feature | Community (Free) | Enterprise | -|---------------------------------|:-------------------:|:---------------------------:| -| AI agent & tool inventory | Yes | Yes | -| IDE extension scanning | Yes | Yes | -| MCP server config audit | Yes | Yes | -| Pretty / JSON / HTML output | Yes | Yes | -| Node.js package scanning | Opt-in | Default on | -| Centralized dashboard | | Yes | -| Policy enforcement & alerting | | Yes | -| Scheduled scans via launchd | | Yes | -| Historical trends & reporting | | Yes | +# 3. Or run a one-time telemetry upload +./stepsecurity-dev-machine-guard send-telemetry -Enterprise mode requires a StepSecurity subscription. [Start a 14-day free trial](https://www.stepsecurity.io/start-free) by installing the StepSecurity GitHub App. +# 4. Uninstall scheduled scanning +./stepsecurity-dev-machine-guard uninstall +``` -**Open-source commitment:** StepSecurity enterprise customers use the exact same script from this repository. There is no separate closed-source version — all scanning capabilities are developed and maintained here in the open. Enterprise mode adds centralized infrastructure (dashboard, policy engine, alerting) on top of the same open-source scanning engine. +**Open-source commitment:** StepSecurity enterprise customers use the exact same binary from this repository. There is no separate closed-source version — all scanning capabilities are developed and maintained here in the open. Enterprise mode adds centralized infrastructure (dashboard, policy engine, alerting) on top of the same open-source scanning engine. ## How It Works

- Architecture diagram — scan sources flow through a single bash script to terminal, JSON, HTML, or StepSecurity dashboard outputs + Architecture diagram — scan sources flow through the binary to terminal, JSON, HTML, or StepSecurity dashboard outputs

-Dev Machine Guard is a lightweight bash script that scans your developer environment. Here's what it does and — importantly — what it does **not** do: +Dev Machine Guard is a single compiled binary that scans your developer environment. Here's what it does and — importantly — what it does **not** do: **What it collects:** + - Installed IDEs, AI tools, and their versions - IDE extension names, publishers, and versions - MCP server configuration (server names and commands only) - Node.js package listings (opt-in) **What it does NOT collect:** + - Source code, file contents, or project data - Secrets, credentials, API keys, or tokens - Browsing history or personal files @@ -158,25 +328,56 @@ Dev Machine Guard is a lightweight bash script that scans your developer environ **In community mode**, all data stays on your machine. Nothing is sent anywhere. -**In enterprise mode**, scan data is sent to the StepSecurity backend for centralized visibility. The script source code is fully open — you can audit exactly what is collected and transmitted. +**In enterprise mode**, scan data is sent to the StepSecurity backend for centralized visibility. The source code is fully open — you can audit exactly what is collected and transmitted. -## Why a Shell Script? +## Building from Source -Dev Machine Guard is intentionally implemented as a single bash script rather than a compiled binary in Go, Python, or another language. There are two key reasons: +```bash +# Build +make build + +# Run unit tests (with race detector) +make test + +# Run integration smoke tests +make smoke -1. **Zero-dependency deployment.** A shell script runs natively on macOS with no runtime, interpreter, or package manager required. This makes deployment across hundreds or thousands of developer machines via MDM (Jamf, Kandji, Intune) straightforward — push the script and it just works. +# Run linter +make lint + +# Clean build artifacts +make clean +``` -2. **Full transparency.** Developer machines are highly privileged environments with access to source code, credentials, and cloud infrastructure. A compiled binary is opaque — customers have to trust what it does. A readable shell script lets security teams review exactly what runs on their machines, line by line, before deploying it. No hidden telemetry, no opaque logic, no blind trust required. +### Project Structure + +``` +cmd/stepsecurity-dev-machine-guard/ # Binary entry point +internal/ +├── buildinfo/ # Version and build metadata +├── cli/ # Argument parser +├── config/ # Configuration file management and configure command +├── detector/ # All scanners (IDE, AI CLI, agents, frameworks, MCP, extensions, Node.js) +├── device/ # Device info (hostname, serial, OS version) +├── executor/ # OS abstraction interface (enables mocked unit tests) +├── launchd/ # macOS launchd install/uninstall +├── lock/ # PID-file instance locking +├── model/ # JSON struct types +├── output/ # Formatters (JSON, pretty, HTML) +├── progress/ # Progress spinner and logging +├── scan/ # Community mode orchestrator +└── telemetry/ # Enterprise mode orchestrator and S3 upload +``` ## How It Compares Dev Machine Guard is **not a replacement** for dependency scanners, vulnerability databases, or endpoint security tools. It covers a different layer — the developer tooling surface — that these tools were never designed to inspect. -| Tool Category | What It Does Well | What It Misses | -|---|---|---| -| **`npm audit` / `yarn audit`** | Flags known CVEs in declared dependencies | Has no visibility into IDEs, AI tools, MCP servers, or IDE extensions | -| **OWASP Dep-Check / Snyk / Socket** | Deep dependency vulnerability and supply-chain risk analysis | Does not scan the broader developer tooling layer (AI agents, IDE extensions, MCP configs) | -| **EDR / MDM (CrowdStrike, Jamf, Intune)** | Device posture, compliance, and malware detection | Zero visibility into developer-specific tooling like IDE extensions, MCP servers, or AI agent configurations | +| Tool Category | What It Does Well | What It Misses | +| ----------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | +| **`npm audit` / `yarn audit`** | Flags known CVEs in declared dependencies | Has no visibility into IDEs, AI tools, MCP servers, or IDE extensions | +| **OWASP Dep-Check / Snyk / Socket** | Deep dependency vulnerability and supply-chain risk analysis | Does not scan the broader developer tooling layer (AI agents, IDE extensions, MCP configs) | +| **EDR / MDM (CrowdStrike, Jamf, Intune)** | Device posture, compliance, and malware detection | Zero visibility into developer-specific tooling like IDE extensions, MCP servers, or AI agent configurations | Dev Machine Guard fills the gap by inventorying what is actually running in your developer environment. Deploy it alongside your existing security stack for complete coverage. @@ -201,6 +402,7 @@ We welcome contributions! Whether it's adding detection for a new AI tool, impro See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. **Quick contribution ideas:** + - Add a new AI tool or IDE to the detection list - Improve [documentation](docs/) - Report bugs or request features via [issues](https://github.com/step-security/dev-machine-guard/issues) diff --git a/SECURITY.md b/SECURITY.md index 835d2ee..7a85b86 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,7 +19,7 @@ We will acknowledge your report within 48 hours and provide a detailed response ## Scope This policy covers: -- The `stepsecurity-dev-machine-guard.sh` script +- The `stepsecurity-dev-machine-guard` binary and Go source code in `internal/` - The StepSecurity backend API (for enterprise mode) ## Supported Versions diff --git a/cmd/stepsecurity-dev-machine-guard/main.go b/cmd/stepsecurity-dev-machine-guard/main.go new file mode 100644 index 0000000..916d038 --- /dev/null +++ b/cmd/stepsecurity-dev-machine-guard/main.go @@ -0,0 +1,129 @@ +package main + +import ( + "fmt" + "os" + + "github.com/step-security/dev-machine-guard/internal/buildinfo" + "github.com/step-security/dev-machine-guard/internal/cli" + "github.com/step-security/dev-machine-guard/internal/config" + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/launchd" + "github.com/step-security/dev-machine-guard/internal/progress" + "github.com/step-security/dev-machine-guard/internal/scan" + "github.com/step-security/dev-machine-guard/internal/telemetry" +) + +func main() { + // Load persisted config (~/.stepsecurity/config.json) before parsing CLI + config.Load() + + cfg, err := cli.Parse(os.Args[1:]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %s\n", err) + os.Exit(1) + } + + // Apply saved config values if CLI didn't explicitly override them. + // CLI flags always win over config file values (same as the shell script). + if len(config.SearchDirs) > 0 && len(cfg.SearchDirs) == 1 && cfg.SearchDirs[0] == "$HOME" { + cfg.SearchDirs = config.SearchDirs + } + if cfg.EnableNPMScan == nil && config.EnableNPMScan != nil { + cfg.EnableNPMScan = config.EnableNPMScan + } + if cfg.ColorMode == "auto" && config.ColorMode != "" { + cfg.ColorMode = config.ColorMode + } + if !cfg.OutputFormatSet && config.OutputFormat != "" { + cfg.OutputFormat = config.OutputFormat + cfg.OutputFormatSet = true // treat saved format as explicitly set + if config.OutputFormat == "html" && cfg.HTMLOutputFile == "" && config.HTMLOutputFile != "" { + cfg.HTMLOutputFile = config.HTMLOutputFile + } + } + + exec := executor.NewReal() + quiet := !cfg.Verbose + // Apply saved quiet preference + if config.Quiet != nil && *config.Quiet { + quiet = true + } + // --verbose always overrides quiet config + if cfg.Verbose { + quiet = false + } + if cfg.OutputFormat == "json" { + quiet = true + } + // Enterprise commands (send-telemetry, install) always show progress + if cfg.Command == "send-telemetry" || cfg.Command == "install" { + quiet = false + } + log := progress.NewLogger(quiet) + + switch cfg.Command { + case "configure": + if err := config.RunConfigure(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + case "configure show": + config.ShowConfigure() + + case "send-telemetry": + if !config.IsEnterpriseMode() { + log.Error("Enterprise configuration not found. Run '%s configure' or download the script from your StepSecurity dashboard.", os.Args[0]) + os.Exit(1) + } + if err := telemetry.Run(exec, log, cfg); err != nil { + log.Error("%v", err) + os.Exit(1) + } + + case "install": + fmt.Fprintf(os.Stdout, "StepSecurity Dev Machine Guard v%s\n\n", buildinfo.Version) + if !config.IsEnterpriseMode() { + log.Error("Enterprise configuration not found. Run '%s configure' or download the script from your StepSecurity dashboard.", os.Args[0]) + os.Exit(1) + } + if err := launchd.Install(exec, log); err != nil { + log.Error("%v", err) + os.Exit(1) + } + log.Progress("Sending initial telemetry...") + fmt.Println() + if err := telemetry.Run(exec, log, cfg); err != nil { + log.Error("%v", err) + os.Exit(1) + } + + case "uninstall": + fmt.Fprintf(os.Stdout, "StepSecurity Dev Machine Guard v%s\n\n", buildinfo.Version) + if err := launchd.Uninstall(exec, log); err != nil { + log.Error("%v", err) + os.Exit(1) + } + + default: + // Community mode or auto-detect enterprise + if cfg.OutputFormatSet || cfg.HTMLOutputFile != "" { + // Output format flag was explicitly set — community mode + if err := scan.Run(exec, log, cfg); err != nil { + log.Error("%v", err) + os.Exit(1) + } + } else if config.IsEnterpriseMode() { + if err := telemetry.Run(exec, log, cfg); err != nil { + log.Error("%v", err) + os.Exit(1) + } + } else { + if err := scan.Run(exec, log, cfg); err != nil { + log.Error("%v", err) + os.Exit(1) + } + } + } +} diff --git a/docs/adding-detections.md b/docs/adding-detections.md index 92b0300..1abef5a 100644 --- a/docs/adding-detections.md +++ b/docs/adding-detections.md @@ -10,13 +10,13 @@ This guide walks you through adding new detections to Dev Machine Guard. Whether Dev Machine Guard uses array-driven detection. Each detection category has a function that iterates over a defined array of entries. To add a new detection, you add an entry to the appropriate array and (optionally) handle any special cases. -The main script file is `stepsecurity-dev-machine-guard.sh` (community mode). The enterprise script (`stepsecurity-agent-*.sh`) uses the same detection functions. +The detection code lives in the `internal/detector/` directory, with each detector category in its own `.go` file. --- ## 1. Adding a New IDE or Desktop App -### Function: `detect_ide_installations()` +### File: `internal/detector/ide.go` ### Format String @@ -35,39 +35,26 @@ The main script file is `stepsecurity-dev-machine-guard.sh` (community mode). Th ### Example: Adding a hypothetical "CodeForge" IDE -Find the `apps` array inside `detect_ide_installations()` and add: +Find the `apps` array inside the IDE detector and add: -```bash -local apps=( - # ... existing entries ... - "CodeForge|codeforge|CodeForge Inc|/Applications/CodeForge.app|Contents/MacOS/CodeForge|--version" -) -``` - -If the app stores its version in `Info.plist` instead of a CLI binary, leave the binary path and version command empty: - -```bash -"CodeForge|codeforge|CodeForge Inc|/Applications/CodeForge.app|||" +```go +{ + Name: "CodeForge", + TypeID: "codeforge", + Vendor: "CodeForge Inc", + AppPath: "/Applications/CodeForge.app", + BinaryPath: "Contents/MacOS/CodeForge", + VersionCommand: "--version", +} ``` -The scanner will automatically fall back to reading `CFBundleShortVersionString` from `Info.plist`. - -### Also update the pretty formatter - -If you want your IDE to display a friendly name in pretty output, add a case to the `format_pretty_output()` function: - -```bash -case "$ide_type" in - # ... existing cases ... - codeforge) display_name="CodeForge" ;; -esac -``` +If the app stores its version in `Info.plist` instead of a CLI binary, leave the binary path and version command empty. The scanner will automatically fall back to reading `CFBundleShortVersionString` from `Info.plist`. --- ## 2. Adding a New AI CLI Tool -### Function: `detect_ai_cli_tools()` +### File: `internal/detector/ai_cli.go` ### Format String @@ -84,11 +71,13 @@ esac ### Example: Adding a hypothetical "DevPilot" CLI -```bash -local tools=( - # ... existing entries ... - "devpilot|DevPilot Inc|devpilot,dp|~/.devpilot,~/.config/devpilot" -) +```go +{ + Name: "devpilot", + Vendor: "DevPilot Inc", + Binaries: []string{"devpilot", "dp"}, + ConfigDirs: []string{"~/.devpilot", "~/.config/devpilot"}, +} ``` The scanner will: @@ -96,24 +85,11 @@ The scanner will: 2. If found, run `devpilot --version` (or `dp --version`) to get the version 3. Check if `~/.devpilot` or `~/.config/devpilot` exists as a config directory -### Special version handling - -If the tool requires non-standard version extraction (e.g., the `--version` flag produces output that needs to be verified or parsed differently), add a case to the `case` statement inside the function: - -```bash -case "$tool_name" in - # ... existing cases ... - devpilot) - version=$(run_as_logged_in_user "$logged_in_user" "$binary_name version 2>/dev/null | head -1" || echo "unknown") - ;; -esac -``` - --- ## 3. Adding a New AI Agent -### Function: `detect_general_ai_agents()` +### File: `internal/detector/agent.go` ### Format String @@ -125,16 +101,18 @@ esac |-------|-------------| | agent-name | Unique name for the agent (e.g., `openclaw`, `gpt-engineer`) | | Vendor | The company or organization (e.g., "OpenSource", "Anthropic") | -| detection_paths | Comma-separated paths (directories or files) that indicate the agent is installed. Use `$user_home` for the home directory variable. | +| detection_paths | Comma-separated paths (directories or files) that indicate the agent is installed | | binary_names | Comma-separated binary names for version extraction | ### Example: Adding a hypothetical "AutoDev" agent -```bash -local agents=( - # ... existing entries ... - "autodev|AutoDev Inc|$user_home/.autodev|autodev" -) +```go +{ + Name: "autodev", + Vendor: "AutoDev Inc", + DetectionPaths: []string{"~/.autodev"}, + Binaries: []string{"autodev"}, +} ``` The scanner will: @@ -142,24 +120,11 @@ The scanner will: 2. If not found, check if `autodev` binary exists in PATH 3. If found either way, try to run `autodev --version` for version info -### Special case: Agents within existing apps - -The `detect_general_ai_agents()` function includes a special case for Claude Cowork (a mode within Claude Desktop). If your agent is a mode within an existing app rather than a standalone tool, add a similar special case block after the main detection loop: - -```bash -# Check for special agent modes (like Claude Cowork) -local some_app_path="/Applications/SomeApp.app" -if [ -d "$some_app_path" ]; then - # Check version to determine if agent mode is available - # ... version check logic ... -fi -``` - --- ## 4. Adding a New MCP Config Source -### Function: `collect_mcp_configs()` +### File: `internal/detector/mcp.go` ### Format String @@ -170,61 +135,53 @@ fi | Field | Description | |-------|-------------| | source_name | Unique identifier for the source (e.g., `claude_desktop`, `cursor`) | -| config_path | Full path to the config file. Use `$user_home` for the home directory variable. | +| config_path | Full path to the config file. Use `~` for the home directory. | | Vendor | The company or organization (e.g., "Anthropic", "Cursor") | ### Example: Adding a hypothetical "CodeAssist" MCP config -```bash -local config_sources=( - # ... existing entries ... - "codeassist|$user_home/.codeassist/mcp_config.json|CodeAssist Inc" -) +```go +{ + Source: "codeassist", + ConfigPath: "~/.codeassist/mcp_config.json", + Vendor: "CodeAssist Inc", +} ``` The scanner will: 1. Check if the config file exists at the specified path 2. Read the file contents -3. In enterprise mode: filter with `jq` to extract only server names and commands, then base64-encode +3. In enterprise mode: filter to extract only server names and commands, then base64-encode 4. In community mode: display the server information locally -### Handling non-JSON formats - -If the config file is not JSON (e.g., YAML or TOML), the `jq` filtering step will be skipped automatically, and the raw content will be used. The StepSecurity backend handles parsing of multiple config formats. - -### Handling JSONC (JSON with comments) - -If the tool uses JSONC (like Zed's `settings.json`), add a special case to strip comments before parsing: - -```bash -if [ "$source_name" = "codeassist" ] && [ "$perl_available" = true ]; then - json_input=$(echo "$config_content" | perl -0777 -pe 's{/\*.*?\*/}{}gs; s{//[^\n]*}{}g') -fi -``` - --- ## 5. Testing Your Changes Locally -After making changes, test locally with all three output formats: +After making changes, build and test locally with all three output formats: ```bash +# Build the binary +make build + # Pretty output with progress messages -./stepsecurity-dev-machine-guard.sh --verbose +./stepsecurity-dev-machine-guard --verbose # JSON output (validate it is well-formed) -./stepsecurity-dev-machine-guard.sh --json | python3 -m json.tool +./stepsecurity-dev-machine-guard --json | python3 -m json.tool # HTML report -./stepsecurity-dev-machine-guard.sh --html test-report.html +./stepsecurity-dev-machine-guard --html test-report.html ``` -### Run ShellCheck +### Run the linter and tests -The CI pipeline runs ShellCheck on every PR. Run it locally before submitting: +The CI pipeline runs `golangci-lint` and tests on every PR. Run them locally before submitting: ```bash -shellcheck stepsecurity-dev-machine-guard.sh +make lint +make test +make smoke ``` ### Verify your new detection appears @@ -236,7 +193,7 @@ shellcheck stepsecurity-dev-machine-guard.sh ### If you do not have the tool installed -You can still verify your format string is correct by: +You can still verify your detection is correct by: 1. Creating a test directory or dummy binary that matches the detection path 2. Running the scanner against it 3. Cleaning up after testing @@ -258,8 +215,7 @@ After adding a new detection, update the following: 2. Create a feature branch: `git checkout -b add-detection-codeforge` 3. Make your changes 4. Test locally (all three output formats) -5. Run ShellCheck +5. Run `make lint` and `make test` 6. Submit a PR using the [PR template](https://github.com/step-security/dev-machine-guard/blob/main/.github/pull_request_template.md) See [CONTRIBUTING.md](../CONTRIBUTING.md) for full contribution guidelines. - diff --git a/docs/community-mode.md b/docs/community-mode.md index 7b6f872..37c4972 100644 --- a/docs/community-mode.md +++ b/docs/community-mode.md @@ -11,16 +11,16 @@ Community mode is the free, open-source way to run Dev Machine Guard locally on ```bash git clone https://github.com/step-security/dev-machine-guard.git cd dev-machine-guard -chmod +x stepsecurity-dev-machine-guard.sh -./stepsecurity-dev-machine-guard.sh +make build +./stepsecurity-dev-machine-guard ``` -Or run directly without cloning: +Or download a pre-built binary without cloning: ```bash -curl -sSL https://raw.githubusercontent.com/step-security/dev-machine-guard/main/stepsecurity-dev-machine-guard.sh -o stepsecurity-dev-machine-guard.sh -chmod +x stepsecurity-dev-machine-guard.sh -./stepsecurity-dev-machine-guard.sh +curl -sSL https://github.com/step-security/dev-machine-guard/releases/latest/download/stepsecurity-dev-machine-guard_darwin_arm64 -o stepsecurity-dev-machine-guard +chmod +x stepsecurity-dev-machine-guard +./stepsecurity-dev-machine-guard ``` --- @@ -32,7 +32,7 @@ Dev Machine Guard supports three mutually exclusive output formats. ### Pretty Terminal Output (default) ```bash -./stepsecurity-dev-machine-guard.sh +./stepsecurity-dev-machine-guard ``` Pretty mode prints a styled, human-readable report directly to your terminal, including sections for Device information, Summary counts, AI Agents and Tools, IDEs and Desktop Apps, MCP Servers, and IDE Extensions. @@ -41,13 +41,13 @@ Pretty mode prints a styled, human-readable report directly to your terminal, in ```bash # Print JSON to stdout -./stepsecurity-dev-machine-guard.sh --json +./stepsecurity-dev-machine-guard --json # Pipe through python for formatted output -./stepsecurity-dev-machine-guard.sh --json | python3 -m json.tool +./stepsecurity-dev-machine-guard --json | python3 -m json.tool # Save to a file -./stepsecurity-dev-machine-guard.sh --json > scan.json +./stepsecurity-dev-machine-guard --json > scan.json ``` JSON mode writes a single JSON object to stdout. This is useful for scripting, piping into other tools, or storing results for later analysis. See [Reading Scan Results](reading-scan-results.md) for the full schema reference. @@ -55,7 +55,7 @@ JSON mode writes a single JSON object to stdout. This is useful for scripting, p ### HTML Report ```bash -./stepsecurity-dev-machine-guard.sh --html report.html +./stepsecurity-dev-machine-guard --html report.html ``` HTML mode generates a self-contained HTML file with a styled report. The report can be opened in any browser and is suitable for sharing with team leads or printing. @@ -82,7 +82,7 @@ HTML mode generates a self-contained HTML file with a styled report. The report ### Basic scan with pretty output ```bash -./stepsecurity-dev-machine-guard.sh +./stepsecurity-dev-machine-guard ``` Runs the scan and prints a styled report to the terminal. Progress messages are suppressed by default. @@ -90,7 +90,7 @@ Runs the scan and prints a styled report to the terminal. Progress messages are ### Verbose scan to see what is happening ```bash -./stepsecurity-dev-machine-guard.sh --verbose +./stepsecurity-dev-machine-guard --verbose ``` Same as above, but progress messages (e.g., "Detecting IDE installations...", "Found: Cursor (Cursor) v0.50.1") are printed to stderr so you can follow along. @@ -98,7 +98,7 @@ Same as above, but progress messages (e.g., "Detecting IDE installations...", "F ### JSON scan piped to jq ```bash -./stepsecurity-dev-machine-guard.sh --json | jq '.ai_agents_and_tools[] | .name' +./stepsecurity-dev-machine-guard --json | jq '.ai_agents_and_tools[] | .name' ``` Extracts the name of every detected AI tool. @@ -106,7 +106,7 @@ Extracts the name of every detected AI tool. ### JSON scan with npm packages included ```bash -./stepsecurity-dev-machine-guard.sh --json --enable-npm-scan > full-scan.json +./stepsecurity-dev-machine-guard --json --enable-npm-scan > full-scan.json ``` Produces a comprehensive JSON scan including globally installed npm/yarn/pnpm/bun packages and per-project dependency listings. @@ -114,7 +114,7 @@ Produces a comprehensive JSON scan including globally installed npm/yarn/pnpm/bu ### HTML report without colors in progress messages ```bash -./stepsecurity-dev-machine-guard.sh --html report.html --verbose --color=never +./stepsecurity-dev-machine-guard --html report.html --verbose --color=never ``` Generates an HTML report while showing progress messages without ANSI color codes (useful when piping stderr to a log file). @@ -126,7 +126,7 @@ Generates an HTML report while showing progress messages without ANSI color code In community mode: - **No data leaves your machine.** There is no backend, no API calls, no telemetry. -- The script source code is fully open. You can audit exactly what it does. +- The source code is fully open. You can audit exactly what it does. - All output is written to stdout (JSON, pretty) or to a local file (HTML). Nothing is transmitted over the network. If you need centralized visibility across a fleet of developer machines, [start a 14-day free trial](https://www.stepsecurity.io/start-free) by installing the StepSecurity GitHub App. @@ -139,4 +139,3 @@ If you need centralized visibility across a fleet of developer machines, [start - [Adding Detections](adding-detections.md) -- contribute new tool or IDE detections - [SCAN_COVERAGE.md](../SCAN_COVERAGE.md) -- full catalog of supported detections - [CONTRIBUTING.md](../CONTRIBUTING.md) -- how to contribute to the project - diff --git a/docs/reading-scan-results.md b/docs/reading-scan-results.md index c718dda..95dca5d 100644 --- a/docs/reading-scan-results.md +++ b/docs/reading-scan-results.md @@ -184,7 +184,7 @@ When you run with `--json`, the scanner outputs a single JSON object to stdout. | Field | Type | Description | |-------|------|-------------| -| `agent_version` | string | Version of the agent script | +| `agent_version` | string | Version of the scanner binary | | `scan_timestamp` | number | Unix timestamp (seconds) of the scan | | `scan_timestamp_iso` | string | ISO 8601 timestamp | | `device` | object | Device identification information | @@ -238,25 +238,25 @@ The HTML report is styled with StepSecurity branding (purple accent colors) and ### Extract all AI tool names ```bash -./stepsecurity-dev-machine-guard.sh --json | jq -r '.ai_agents_and_tools[].name' +./stepsecurity-dev-machine-guard --json | jq -r '.ai_agents_and_tools[].name' ``` ### Count extensions per IDE ```bash -./stepsecurity-dev-machine-guard.sh --json | jq '[.ide_extensions[] | .ide_type] | group_by(.) | map({(.[0]): length}) | add' +./stepsecurity-dev-machine-guard --json | jq '[.ide_extensions[] | .ide_type] | group_by(.) | map({(.[0]): length}) | add' ``` ### Check if a specific extension is installed ```bash -./stepsecurity-dev-machine-guard.sh --json | jq '.ide_extensions[] | select(.id == "ms-python.python")' +./stepsecurity-dev-machine-guard --json | jq '.ide_extensions[] | select(.id == "ms-python.python")' ``` ### Export extension list as CSV ```bash -./stepsecurity-dev-machine-guard.sh --json | jq -r '.ide_extensions[] | [.id, .version, .publisher, .ide_type] | @csv' +./stepsecurity-dev-machine-guard --json | jq -r '.ide_extensions[] | [.id, .version, .publisher, .ide_type] | @csv' ``` --- diff --git a/docs/release-process.md b/docs/release-process.md index 322a25b..73c35c8 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -8,24 +8,25 @@ This document describes how releases are created, signed, and verified. ## Overview -Releases are created via a manually triggered GitHub Actions workflow that requires approval from the `release` environment. The workflow: +Releases are created via a manually triggered GitHub Actions workflow (`workflow_dispatch`) that requires approval from the `release` environment. The workflow uses [GoReleaser](https://goreleaser.com/) to: -1. Extracts the version from `AGENT_VERSION` in the script -2. Verifies the tag does not already exist (immutability) -3. Signs the script with [Sigstore](https://www.sigstore.dev/) cosign (keyless) -4. Generates SHA256 checksums -5. Creates a Git tag and GitHub Release -6. Attaches the script, Sigstore bundle, and checksums as release assets -7. Generates SLSA build provenance attestation +1. Read the version from `internal/buildinfo/version.go` (`const Version = "1.9.0"`) +2. Verify the tag does not already exist (immutability) +3. Build platform-specific binaries with GoReleaser +4. Sign the binaries with [Sigstore](https://www.sigstore.dev/) cosign (keyless) +5. Generate SHA256 checksums +6. Create a Git tag and GitHub Release +7. Attach binaries, Sigstore bundles, and checksums as release assets +8. Generate SLSA build provenance attestation ## How to Create a Release ### 1. Bump the version -Update `AGENT_VERSION` in `stepsecurity-dev-machine-guard.sh`: +Update `Version` in `internal/buildinfo/version.go`: -```bash -AGENT_VERSION="1.9.0" +```go +const Version = "1.9.0" ``` Update the [CHANGELOG.md](../CHANGELOG.md) with a new section for the version. @@ -45,19 +46,20 @@ The workflow uses a GitHub Environment called `release` that requires approval. ### 4. Verify the release -Once approved, the workflow will create the tag, sign the script, create the GitHub Release, and upload the artifacts. Check the [Releases page](https://github.com/step-security/dev-machine-guard/releases) to confirm. +Once approved, the workflow will create the tag, build the binaries, sign them, create the GitHub Release, and upload the artifacts. Check the [Releases page](https://github.com/step-security/dev-machine-guard/releases) to confirm. --- ## Release Artifacts -Each release includes three artifacts: +Each release includes the following artifacts: | Artifact | Description | |----------|-------------| -| `stepsecurity-dev-machine-guard.sh` | The scanner script | -| `stepsecurity-dev-machine-guard.sh.bundle` | Sigstore cosign bundle (signature, certificate, and Rekor transparency log entry) | -| `checksums.txt` | SHA256 checksums of the script and bundle | +| `stepsecurity-dev-machine-guard_darwin_amd64` | macOS Intel binary | +| `stepsecurity-dev-machine-guard_darwin_arm64` | macOS Apple Silicon binary | +| `checksums.txt` | SHA256 checksums of all release artifacts | +| `*.bundle` | Sigstore cosign bundles (signature, certificate, and Rekor transparency log entry) | --- @@ -74,24 +76,24 @@ brew install cosign # Other platforms: https://docs.sigstore.dev/cosign/system_config/installation/ ``` -### Verify the script signature +### Verify the binary signature ```bash # Download the release artifacts -gh release download v1.8.1 --repo step-security/dev-machine-guard +gh release download v1.9.0 --repo step-security/dev-machine-guard -# Verify the Sigstore signature -cosign verify-blob stepsecurity-dev-machine-guard.sh \ - --bundle stepsecurity-dev-machine-guard.sh.bundle \ +# Verify the Sigstore signature (example for Apple Silicon) +cosign verify-blob stepsecurity-dev-machine-guard_darwin_arm64 \ + --bundle stepsecurity-dev-machine-guard_darwin_arm64.bundle \ --certificate-identity-regexp "github.com/step-security/dev-machine-guard" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" ``` A successful verification confirms: -- The script was signed by the `step-security/dev-machine-guard` GitHub Actions workflow +- The binary was signed by the `step-security/dev-machine-guard` GitHub Actions workflow - The signature is recorded in the [Rekor transparency log](https://search.sigstore.dev/) -- The script has not been tampered with since signing +- The binary has not been tampered with since signing ### Verify the checksum @@ -102,7 +104,7 @@ sha256sum -c checksums.txt ### Verify build provenance ```bash -gh attestation verify stepsecurity-dev-machine-guard.sh \ +gh attestation verify stepsecurity-dev-machine-guard_darwin_arm64 \ --repo step-security/dev-machine-guard ``` @@ -136,4 +138,3 @@ The release workflow requires a GitHub Environment named `release` with required - [VERSIONING.md](../VERSIONING.md) — versioning scheme - [Sigstore documentation](https://docs.sigstore.dev/) — how keyless signing works - [SLSA](https://slsa.dev/) — supply chain integrity framework - diff --git a/examples/sample-output.json b/examples/sample-output.json index e605abe..6244830 100644 --- a/examples/sample-output.json +++ b/examples/sample-output.json @@ -1,5 +1,5 @@ { - "agent_version": "1.8.2", + "agent_version": "1.9.0", "scan_timestamp": 1741305600, "scan_timestamp_iso": "2026-03-07T00:00:00Z", "device": { diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ec60e84 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/step-security/dev-machine-guard + +go 1.24 diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go new file mode 100644 index 0000000..1d5dfc0 --- /dev/null +++ b/internal/buildinfo/version.go @@ -0,0 +1,27 @@ +package buildinfo + +import "fmt" + +const ( + Version = "1.9.0" + AgentURL = "https://github.com/step-security/dev-machine-guard" +) + +// Build-time variables set via -ldflags by goreleaser or Makefile. +var ( + GitCommit string // short commit hash (Makefile) or full commit (goreleaser) + ReleaseTag string // e.g., "v1.9.0" (goreleaser only) + ReleaseBranch string // e.g., "main" (goreleaser only) +) + +// VersionString returns the display version, including the git commit if available. +func VersionString() string { + if GitCommit != "" { + short := GitCommit + if len(short) > 7 { + short = short[:7] + } + return fmt.Sprintf("%s (%s)", Version, short) + } + return Version +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..7953d37 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,158 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/step-security/dev-machine-guard/internal/buildinfo" +) + +// Config holds all parsed CLI flags. +type Config struct { + Command string // "", "install", "uninstall", "send-telemetry", "configure", "configure show" + OutputFormat string // "pretty", "json", "html" + OutputFormatSet bool // true if --pretty/--json/--html was explicitly passed (not persisted) + HTMLOutputFile string // set by --html (not persisted) + ColorMode string // "auto", "always", "never" + Verbose bool // --verbose + EnableNPMScan *bool // nil=auto, true/false=explicit + SearchDirs []string // defaults to ["$HOME"] +} + +// Parse parses CLI arguments and returns a Config. +func Parse(args []string) (*Config, error) { + cfg := &Config{ + OutputFormat: "pretty", + ColorMode: "auto", + SearchDirs: []string{"$HOME"}, + } + + searchDirsSet := false + i := 0 + + for i < len(args) { + arg := args[i] + switch { + case arg == "install" || arg == "--install": + cfg.Command = "install" + case arg == "uninstall" || arg == "--uninstall": + cfg.Command = "uninstall" + case arg == "send-telemetry" || arg == "--send-telemetry": + cfg.Command = "send-telemetry" + case arg == "configure": + // Check for "configure show" subcommand + if i+1 < len(args) && args[i+1] == "show" { + cfg.Command = "configure show" + i++ + } else { + cfg.Command = "configure" + } + case arg == "--pretty": + cfg.OutputFormat = "pretty" + cfg.OutputFormatSet = true + case arg == "--json": + cfg.OutputFormat = "json" + cfg.OutputFormatSet = true + case arg == "--html": + cfg.OutputFormat = "html" + cfg.OutputFormatSet = true + i++ + if i >= len(args) { + return nil, fmt.Errorf("--html requires a file path argument") + } + cfg.HTMLOutputFile = args[i] + case arg == "--enable-npm-scan": + v := true + cfg.EnableNPMScan = &v + case arg == "--disable-npm-scan": + v := false + cfg.EnableNPMScan = &v + case strings.HasPrefix(arg, "--color="): + mode := strings.TrimPrefix(arg, "--color=") + if mode != "auto" && mode != "always" && mode != "never" { + return nil, fmt.Errorf("invalid color mode: %s (must be auto, always, or never)", mode) + } + cfg.ColorMode = mode + case arg == "--search-dirs": + i++ + if i >= len(args) || strings.HasPrefix(args[i], "--") { + return nil, fmt.Errorf("--search-dirs requires at least one directory path argument") + } + if !searchDirsSet { + cfg.SearchDirs = nil + searchDirsSet = true + } + // Greedily consume non-flag arguments + for i < len(args) && !strings.HasPrefix(args[i], "--") { + cfg.SearchDirs = append(cfg.SearchDirs, args[i]) + i++ + } + continue // skip the i++ at the bottom + case arg == "--verbose": + cfg.Verbose = true + case arg == "-v" || arg == "--version" || arg == "version": + fmt.Fprintf(os.Stdout, "StepSecurity Dev Machine Guard v%s\n", buildinfo.VersionString()) + os.Exit(0) + case arg == "-h" || arg == "--help" || arg == "help": + printHelp() + os.Exit(0) + default: + return nil, fmt.Errorf("Unknown option: %s\nRun '%s --help' for usage information.", arg, filepath.Base(os.Args[0])) + } + i++ + } + + return cfg, nil +} + +func printHelp() { + name := filepath.Base(os.Args[0]) + fmt.Fprintf(os.Stdout, `StepSecurity Dev Machine Guard v%s + +Usage: %s [COMMAND] [OPTIONS] + +Commands: + configure Configure enterprise settings and search directories + configure show Show current configuration + install Install launchd for periodic scanning (enterprise) + uninstall Remove launchd configuration (enterprise) + send-telemetry Upload scan results to the StepSecurity dashboard (enterprise) + +Output formats (community mode, mutually exclusive): + --pretty Pretty terminal output (default) + --json JSON output to stdout + --html FILE HTML report saved to FILE + +Options: + --search-dirs DIR [DIR...] Search DIRs instead of $HOME (replaces default; repeatable) + --enable-npm-scan Enable Node.js package scanning + --disable-npm-scan Disable Node.js package scanning + --verbose Show progress messages (suppressed by default) + --color=WHEN Color mode: auto | always | never (default: auto) + -v, --version Show version + -h, --help Show this help + +Examples: + %s # Pretty terminal output + %s --json | python3 -m json.tool # Formatted JSON + %s --json > scan.json # JSON to file + %s --html report.html # HTML report + %s --verbose --enable-npm-scan # Verbose with npm scan + %s --search-dirs /Volumes/code # Search only /Volumes/code + %s --search-dirs /tmp /opt # Multiple dirs, one flag + %s --search-dirs "/path/with spaces" --search-dirs /opt # Mixed styles + %s configure # Set up enterprise config and search dirs + %s send-telemetry # Enterprise telemetry + +Configuration: + Config file: ~/.stepsecurity/config.json + Run '%s configure' to set enterprise credentials and search directories interactively. + +%s +`, buildinfo.Version, name, + name, name, name, name, name, name, name, name, + name, name, name, + buildinfo.AgentURL) +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..32cd6c5 --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,183 @@ +package cli + +import ( + "testing" +) + +func TestParse_Defaults(t *testing.T) { + cfg, err := Parse([]string{}) + if err != nil { + t.Fatal(err) + } + if cfg.OutputFormat != "pretty" { + t.Errorf("expected pretty, got %s", cfg.OutputFormat) + } + if cfg.ColorMode != "auto" { + t.Errorf("expected auto, got %s", cfg.ColorMode) + } + if cfg.Verbose { + t.Error("expected verbose=false") + } + if cfg.EnableNPMScan != nil { + t.Error("expected EnableNPMScan=nil") + } + if len(cfg.SearchDirs) != 1 || cfg.SearchDirs[0] != "$HOME" { + t.Errorf("expected [$HOME], got %v", cfg.SearchDirs) + } +} + +func TestParse_JSONFlag(t *testing.T) { + cfg, err := Parse([]string{"--json"}) + if err != nil { + t.Fatal(err) + } + if cfg.OutputFormat != "json" { + t.Errorf("expected json, got %s", cfg.OutputFormat) + } +} + +func TestParse_HTMLFlag(t *testing.T) { + cfg, err := Parse([]string{"--html", "report.html"}) + if err != nil { + t.Fatal(err) + } + if cfg.OutputFormat != "html" { + t.Errorf("expected html, got %s", cfg.OutputFormat) + } + if cfg.HTMLOutputFile != "report.html" { + t.Errorf("expected report.html, got %s", cfg.HTMLOutputFile) + } +} + +func TestParse_HTMLMissingFile(t *testing.T) { + _, err := Parse([]string{"--html"}) + if err == nil { + t.Error("expected error for --html without file") + } +} + +func TestParse_Verbose(t *testing.T) { + cfg, err := Parse([]string{"--verbose"}) + if err != nil { + t.Fatal(err) + } + if !cfg.Verbose { + t.Error("expected verbose=true") + } +} + +func TestParse_Color(t *testing.T) { + for _, mode := range []string{"auto", "always", "never"} { + cfg, err := Parse([]string{"--color=" + mode}) + if err != nil { + t.Fatal(err) + } + if cfg.ColorMode != mode { + t.Errorf("expected %s, got %s", mode, cfg.ColorMode) + } + } +} + +func TestParse_InvalidColor(t *testing.T) { + _, err := Parse([]string{"--color=invalid"}) + if err == nil { + t.Error("expected error for invalid color mode") + } +} + +func TestParse_NPMScan(t *testing.T) { + cfg, err := Parse([]string{"--enable-npm-scan"}) + if err != nil { + t.Fatal(err) + } + if cfg.EnableNPMScan == nil || !*cfg.EnableNPMScan { + t.Error("expected EnableNPMScan=true") + } + + cfg, err = Parse([]string{"--disable-npm-scan"}) + if err != nil { + t.Fatal(err) + } + if cfg.EnableNPMScan == nil || *cfg.EnableNPMScan { + t.Error("expected EnableNPMScan=false") + } +} + +func TestParse_SearchDirs(t *testing.T) { + cfg, err := Parse([]string{"--search-dirs", "/tmp", "/opt"}) + if err != nil { + t.Fatal(err) + } + if len(cfg.SearchDirs) != 2 || cfg.SearchDirs[0] != "/tmp" || cfg.SearchDirs[1] != "/opt" { + t.Errorf("expected [/tmp /opt], got %v", cfg.SearchDirs) + } +} + +func TestParse_SearchDirsMultiple(t *testing.T) { + cfg, err := Parse([]string{"--search-dirs", "/a", "/b", "--search-dirs", "/c"}) + if err != nil { + t.Fatal(err) + } + if len(cfg.SearchDirs) != 3 { + t.Errorf("expected 3 dirs, got %d: %v", len(cfg.SearchDirs), cfg.SearchDirs) + } +} + +func TestParse_SearchDirsStopsAtFlag(t *testing.T) { + cfg, err := Parse([]string{"--search-dirs", "/tmp", "--verbose"}) + if err != nil { + t.Fatal(err) + } + if len(cfg.SearchDirs) != 1 || cfg.SearchDirs[0] != "/tmp" { + t.Errorf("expected [/tmp], got %v", cfg.SearchDirs) + } + if !cfg.Verbose { + t.Error("expected verbose=true") + } +} + +func TestParse_SearchDirsMissing(t *testing.T) { + _, err := Parse([]string{"--search-dirs"}) + if err == nil { + t.Error("expected error for --search-dirs without args") + } +} + +func TestParse_ConfigureCommand(t *testing.T) { + cfg, err := Parse([]string{"configure"}) + if err != nil { + t.Fatal(err) + } + if cfg.Command != "configure" { + t.Errorf("expected configure, got %s", cfg.Command) + } +} + +func TestParse_EnterpriseCommands(t *testing.T) { + for _, cmd := range []string{"install", "uninstall", "send-telemetry"} { + cfg, err := Parse([]string{cmd}) + if err != nil { + t.Fatal(err) + } + if cfg.Command != cmd { + t.Errorf("expected %s, got %s", cmd, cfg.Command) + } + } +} + +func TestParse_UnknownOption(t *testing.T) { + _, err := Parse([]string{"--bogus"}) + if err == nil { + t.Error("expected error for unknown option") + } +} + +func TestParse_FlagCombinations(t *testing.T) { + cfg, err := Parse([]string{"--json", "--verbose", "--color=never"}) + if err != nil { + t.Fatal(err) + } + if cfg.OutputFormat != "json" || !cfg.Verbose || cfg.ColorMode != "never" { + t.Errorf("unexpected config: %+v", cfg) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..9db2440 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,366 @@ +package config + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Default placeholders (replaced by backend for enterprise installation scripts). +var ( + CustomerID = "{{CUSTOMER_ID}}" + APIEndpoint = "{{API_ENDPOINT}}" + APIKey = "{{API_KEY}}" + ScanFrequencyHours = "{{SCAN_FREQUENCY_HOURS}}" + SearchDirs []string + EnableNPMScan *bool // nil=auto + ColorMode string // "" means auto + OutputFormat string // "" means default (pretty) + HTMLOutputFile string // "" means not set + Quiet *bool // nil=default behavior +) + +// ConfigFile is the JSON structure persisted to ~/.stepsecurity/config.json. +type ConfigFile struct { + CustomerID string `json:"customer_id,omitempty"` + APIEndpoint string `json:"api_endpoint,omitempty"` + APIKey string `json:"api_key,omitempty"` + ScanFrequencyHours string `json:"scan_frequency_hours,omitempty"` + SearchDirs []string `json:"search_dirs,omitempty"` + EnableNPMScan *bool `json:"enable_npm_scan,omitempty"` + ColorMode string `json:"color_mode,omitempty"` + OutputFormat string `json:"output_format,omitempty"` + HTMLOutputFile string `json:"html_output_file,omitempty"` + Quiet *bool `json:"quiet,omitempty"` +} + +// configDir returns ~/.stepsecurity. +func configDir() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".stepsecurity") +} + +// ConfigFilePath returns the path to the config file. +func ConfigFilePath() string { + return filepath.Join(configDir(), "config.json") +} + +// Load reads the config file and applies values to the package-level variables. +// Values already set (not placeholders) are not overridden — build-time values take precedence. +func Load() { + data, err := os.ReadFile(ConfigFilePath()) + if err != nil { + return // no config file, use defaults + } + + var cfg ConfigFile + if err := json.Unmarshal(data, &cfg); err != nil { + return + } + + if cfg.CustomerID != "" && isPlaceholder(CustomerID) { + CustomerID = cfg.CustomerID + } + if cfg.APIEndpoint != "" && isPlaceholder(APIEndpoint) { + APIEndpoint = cfg.APIEndpoint + } + if cfg.APIKey != "" && isPlaceholder(APIKey) { + APIKey = cfg.APIKey + } + if cfg.ScanFrequencyHours != "" && isPlaceholder(ScanFrequencyHours) { + ScanFrequencyHours = cfg.ScanFrequencyHours + } + if len(cfg.SearchDirs) > 0 { + SearchDirs = cfg.SearchDirs + } + if cfg.EnableNPMScan != nil && EnableNPMScan == nil { + EnableNPMScan = cfg.EnableNPMScan + } + if cfg.ColorMode != "" && ColorMode == "" { + ColorMode = cfg.ColorMode + } + if cfg.OutputFormat != "" && OutputFormat == "" { + OutputFormat = cfg.OutputFormat + } + if cfg.HTMLOutputFile != "" && HTMLOutputFile == "" { + HTMLOutputFile = cfg.HTMLOutputFile + } + if cfg.Quiet != nil && Quiet == nil { + Quiet = cfg.Quiet + } +} + +// IsEnterpriseMode returns true if valid enterprise credentials are configured. +func IsEnterpriseMode() bool { + return APIKey != "" && !strings.Contains(APIKey, "{{") +} + +// RunConfigure interactively prompts for config values and saves to the config file. +func RunConfigure() error { + reader := bufio.NewReader(os.Stdin) + + // Load existing config to show current values + existing := loadExisting() + + fmt.Println("StepSecurity Dev Machine Guard — Configuration") + fmt.Println() + fmt.Println("Enter new values or press Enter to keep the current value.") + fmt.Println("To clear a value, enter a single dash (-).") + fmt.Println() + + existing.CustomerID = promptValue(reader, "Customer ID", existing.CustomerID) + existing.APIEndpoint = promptValue(reader, "API Endpoint", existing.APIEndpoint) + existing.APIKey = promptSecret(reader, "API Key", existing.APIKey) + existing.ScanFrequencyHours = promptValue(reader, "Scan Frequency (hours)", existing.ScanFrequencyHours) + + // Search dirs + currentDirs := "" + if len(existing.SearchDirs) > 0 { + currentDirs = strings.Join(existing.SearchDirs, ", ") + } + dirsInput := promptValue(reader, "Search Directories (comma-separated)", currentDirs) + if dirsInput != "" { + dirs := strings.Split(dirsInput, ",") + existing.SearchDirs = nil + for _, d := range dirs { + d = strings.TrimSpace(d) + if d != "" { + existing.SearchDirs = append(existing.SearchDirs, d) + } + } + } else { + existing.SearchDirs = nil + } + + // Enable npm scan + currentNPM := "auto" + if existing.EnableNPMScan != nil { + if *existing.EnableNPMScan { + currentNPM = "true" + } else { + currentNPM = "false" + } + } + npmInput := promptValue(reader, "Enable NPM Scan (auto/true/false)", currentNPM) + switch strings.ToLower(npmInput) { + case "true": + v := true + existing.EnableNPMScan = &v + case "false": + v := false + existing.EnableNPMScan = &v + default: + existing.EnableNPMScan = nil // auto + } + + // Color mode + currentColor := existing.ColorMode + if currentColor == "" { + currentColor = "auto" + } + colorInput := promptValue(reader, "Color Mode (auto/always/never)", currentColor) + switch strings.ToLower(colorInput) { + case "always", "never": + existing.ColorMode = strings.ToLower(colorInput) + default: + existing.ColorMode = "" // auto (omit from config) + } + + // Output format + currentFormat := existing.OutputFormat + if currentFormat == "" { + currentFormat = "pretty" + } + formatInput := promptValue(reader, "Output Format (pretty/json/html)", currentFormat) + switch strings.ToLower(formatInput) { + case "json", "html": + existing.OutputFormat = strings.ToLower(formatInput) + default: + existing.OutputFormat = "" // pretty is the default (omit from config) + } + + // HTML output file (only relevant when output_format is html) + if existing.OutputFormat == "html" { + existing.HTMLOutputFile = promptValue(reader, "HTML Output File", existing.HTMLOutputFile) + } + + // Quiet mode + currentQuiet := "false" + if existing.Quiet != nil && *existing.Quiet { + currentQuiet = "true" + } + quietInput := promptValue(reader, "Quiet Mode (true/false)", currentQuiet) + switch strings.ToLower(quietInput) { + case "true": + v := true + existing.Quiet = &v + default: + existing.Quiet = nil // false is the default (omit from config) + } + + // Save + if err := save(existing); err != nil { + return fmt.Errorf("saving configuration: %w", err) + } + + fmt.Println() + fmt.Printf("Configuration saved to %s\n", ConfigFilePath()) + return nil +} + +// promptSecret shows a masked current value but keeps the real value on Enter. +func promptSecret(reader *bufio.Reader, label, current string) string { + masked := maskSecret(current) + if masked != "(not set)" { + fmt.Printf(" %s [%s]: ", label, masked) + } else { + fmt.Printf(" %s: ", label) + } + + line, _ := reader.ReadString('\n') + line = strings.TrimSpace(line) + + if line == "-" { + return "" // clear value + } + if line == "" { + return current // keep real value + } + return line +} + +func promptValue(reader *bufio.Reader, label, current string) string { + if current != "" { + fmt.Printf(" %s [%s]: ", label, current) + } else { + fmt.Printf(" %s: ", label) + } + + line, _ := reader.ReadString('\n') + line = strings.TrimSpace(line) + + if line == "-" { + return "" // clear value + } + if line == "" { + return current // keep existing + } + return line +} + +func loadExisting() *ConfigFile { + data, err := os.ReadFile(ConfigFilePath()) + if err != nil { + return &ConfigFile{} + } + var cfg ConfigFile + if err := json.Unmarshal(data, &cfg); err != nil { + return &ConfigFile{} + } + return &cfg +} + +func save(cfg *ConfigFile) error { + dir := configDir() + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + + return os.WriteFile(ConfigFilePath(), data, 0o600) +} + +// ShowConfigure prints the current configuration to stdout. +func ShowConfigure() { + cfg := loadExisting() + + fmt.Printf("Configuration (%s):\n\n", ConfigFilePath()) + fmt.Printf(" %-24s %s\n", "Customer ID:", displayValue(cfg.CustomerID)) + fmt.Printf(" %-24s %s\n", "API Endpoint:", displayValue(cfg.APIEndpoint)) + fmt.Printf(" %-24s %s\n", "API Key:", maskSecret(cfg.APIKey)) + fmt.Printf(" %-24s %s\n", "Scan Frequency:", displayFrequency(cfg.ScanFrequencyHours)) + fmt.Printf(" %-24s %s\n", "Search Directories:", displayDirs(cfg.SearchDirs)) + fmt.Printf(" %-24s %s\n", "Enable NPM Scan:", displayNPMScan(cfg.EnableNPMScan)) + fmt.Printf(" %-24s %s\n", "Color Mode:", displayColorMode(cfg.ColorMode)) + fmt.Printf(" %-24s %s\n", "Output Format:", displayOutputFormat(cfg.OutputFormat)) + if cfg.OutputFormat == "html" { + fmt.Printf(" %-24s %s\n", "HTML Output File:", displayValue(cfg.HTMLOutputFile)) + } + fmt.Printf(" %-24s %s\n", "Quiet Mode:", displayQuiet(cfg.Quiet)) +} + +func displayValue(v string) string { + if v == "" { + return "(not set)" + } + return v +} + +func maskSecret(v string) string { + if v == "" { + return "(not set)" + } + if len(v) <= 6 { + return "***" + } + return "***" + v[len(v)-4:] +} + +func displayFrequency(v string) string { + if v == "" { + return "(not set)" + } + if v == "1" { + return v + " hour" + } + return v + " hours" +} + +func displayDirs(dirs []string) string { + if len(dirs) == 0 { + return "(not set — defaults to $HOME)" + } + return strings.Join(dirs, ", ") +} + +func displayNPMScan(v *bool) string { + if v == nil { + return "auto" + } + if *v { + return "true" + } + return "false" +} + +func displayColorMode(v string) string { + if v == "" { + return "auto" + } + return v +} + +func displayOutputFormat(v string) string { + if v == "" { + return "pretty" + } + return v +} + +func displayQuiet(v *bool) string { + if v == nil || !*v { + return "false" + } + return "true" +} + +func isPlaceholder(v string) bool { + return strings.Contains(v, "{{") +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..a24495d --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,119 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestIsEnterpriseMode_Placeholder(t *testing.T) { + APIKey = "{{API_KEY}}" + if IsEnterpriseMode() { + t.Error("placeholder should not be enterprise mode") + } +} + +func TestIsEnterpriseMode_Empty(t *testing.T) { + APIKey = "" + if IsEnterpriseMode() { + t.Error("empty should not be enterprise mode") + } +} + +func TestIsEnterpriseMode_Valid(t *testing.T) { + APIKey = "sk-test-123456" + defer func() { APIKey = "{{API_KEY}}" }() + if !IsEnterpriseMode() { + t.Error("valid API key should be enterprise mode") + } +} + +func TestIsPlaceholder(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"{{API_KEY}}", true}, + {"{{CUSTOMER_ID}}", true}, + {"real-value", false}, + {"", false}, + } + for _, tt := range tests { + if got := isPlaceholder(tt.input); got != tt.want { + t.Errorf("isPlaceholder(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestSaveAndLoad(t *testing.T) { + // Use a temp directory + tmpDir := t.TempDir() + origConfigDir := configDir + // Override configDir for test + tmpConfigPath := filepath.Join(tmpDir, "config.json") + + cfg := &ConfigFile{ + CustomerID: "test-customer", + APIEndpoint: "https://api.example.com", + APIKey: "sk-test-key", + ScanFrequencyHours: "4", + SearchDirs: []string{"/tmp", "/opt/code"}, + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(tmpConfigPath, data, 0o600); err != nil { + t.Fatal(err) + } + + // Read it back + readData, err := os.ReadFile(tmpConfigPath) + if err != nil { + t.Fatal(err) + } + + var loaded ConfigFile + if err := json.Unmarshal(readData, &loaded); err != nil { + t.Fatal(err) + } + + if loaded.CustomerID != "test-customer" { + t.Errorf("customer_id: expected test-customer, got %s", loaded.CustomerID) + } + if loaded.APIKey != "sk-test-key" { + t.Errorf("api_key: expected sk-test-key, got %s", loaded.APIKey) + } + if len(loaded.SearchDirs) != 2 { + t.Errorf("search_dirs: expected 2 dirs, got %d", len(loaded.SearchDirs)) + } + + _ = origConfigDir +} + +func TestConfigFile_JSON(t *testing.T) { + cfg := ConfigFile{ + CustomerID: "cust-123", + APIKey: "key-456", + } + + data, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + + var parsed map[string]any + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatal(err) + } + + if parsed["customer_id"] != "cust-123" { + t.Error("customer_id not serialized correctly") + } + // Empty fields should be omitted + if _, ok := parsed["api_endpoint"]; ok { + t.Error("empty api_endpoint should be omitted") + } +} diff --git a/internal/detector/agent.go b/internal/detector/agent.go new file mode 100644 index 0000000..b5efcd8 --- /dev/null +++ b/internal/detector/agent.go @@ -0,0 +1,147 @@ +package detector + +import ( + "context" + "regexp" + "strconv" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +type agentSpec struct { + Name string + Vendor string + DetectionPaths []string // relative to home dir + Binaries []string +} + +var agentDefinitions = []agentSpec{ + {"openclaw", "OpenSource", []string{".openclaw"}, []string{"openclaw"}}, + {"clawdbot", "OpenSource", []string{".clawdbot"}, []string{"clawdbot"}}, + {"moltbot", "OpenSource", []string{".moltbot"}, []string{"moltbot"}}, + {"moldbot", "OpenSource", []string{".moldbot"}, []string{"moldbot"}}, + {"gpt-engineer", "OpenSource", []string{".gpt-engineer"}, []string{"gpt-engineer"}}, +} + +// AgentDetector detects general-purpose AI agents. +type AgentDetector struct { + exec executor.Executor +} + +func NewAgentDetector(exec executor.Executor) *AgentDetector { + return &AgentDetector{exec: exec} +} + +func (d *AgentDetector) Detect(ctx context.Context, searchDirs []string) []model.AITool { + homeDir := getHomeDir(d.exec) + var results []model.AITool + + for _, spec := range agentDefinitions { + installPath, found := d.findAgent(spec, homeDir) + if !found { + continue + } + + version := d.getVersion(ctx, spec) + + results = append(results, model.AITool{ + Name: spec.Name, + Vendor: spec.Vendor, + Type: "general_agent", + Version: version, + InstallPath: installPath, + }) + } + + // Claude Cowork special case + if tool, ok := d.detectClaudeCowork(ctx); ok { + results = append(results, tool) + } + + return results +} + +func (d *AgentDetector) findAgent(spec agentSpec, homeDir string) (string, bool) { + // Check detection paths + for _, relPath := range spec.DetectionPaths { + fullPath := homeDir + "/" + relPath + if d.exec.DirExists(fullPath) || d.exec.FileExists(fullPath) { + return fullPath, true + } + } + + // Check binaries in PATH + for _, bin := range spec.Binaries { + if path, err := d.exec.LookPath(bin); err == nil { + return path, true + } + } + + return "", false +} + +func (d *AgentDetector) getVersion(ctx context.Context, spec agentSpec) string { + for _, bin := range spec.Binaries { + if _, err := d.exec.LookPath(bin); err == nil { + stdout, _, _, err := d.exec.RunWithTimeout(ctx, 10*time.Second, bin, "--version") + if err == nil { + lines := strings.SplitN(stdout, "\n", 2) + if len(lines) > 0 { + v := strings.TrimSpace(lines[0]) + if v != "" { + return v + } + } + } + } + } + return "unknown" +} + +// detectClaudeCowork checks for Claude Cowork (a mode within Claude Desktop 0.7+). +func (d *AgentDetector) detectClaudeCowork(ctx context.Context) (model.AITool, bool) { + claudePath := "/Applications/Claude.app" + if !d.exec.DirExists(claudePath) { + return model.AITool{}, false + } + + version := readPlistVersion(ctx, d.exec, claudePath+"/Contents/Info.plist") + if version == "unknown" { + return model.AITool{}, false + } + + // Check if version >= 0.7 (supports Cowork) + if !isCoworkVersion(version) { + return model.AITool{}, false + } + + return model.AITool{ + Name: "claude-cowork", + Vendor: "Anthropic", + Type: "general_agent", + Version: version, + InstallPath: claudePath, + }, true +} + +// isCoworkVersion returns true if version is 0.7+ or 1.0+. +var versionRe = regexp.MustCompile(`^(\d+)\.(\d+)`) + +func isCoworkVersion(version string) bool { + m := versionRe.FindStringSubmatch(version) + if len(m) < 3 { + return false + } + major, err1 := strconv.Atoi(m[1]) + minor, err2 := strconv.Atoi(m[2]) + if err1 != nil || err2 != nil { + return false + } + if major >= 1 { + return true + } + return major == 0 && minor >= 7 +} diff --git a/internal/detector/agent_test.go b/internal/detector/agent_test.go new file mode 100644 index 0000000..9a35bbc --- /dev/null +++ b/internal/detector/agent_test.go @@ -0,0 +1,98 @@ +package detector + +import ( + "context" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +func TestAgentDetector_FindsOpenclaw(t *testing.T) { + mock := executor.NewMock() + mock.SetDir("/Users/testuser/.openclaw") + mock.SetPath("openclaw", "/usr/local/bin/openclaw") + mock.SetCommand("0.5.2\n", "", 0, "openclaw", "--version") + + det := NewAgentDetector(mock) + results := det.Detect(context.Background(), []string{"/Users/testuser"}) + + found := false + for _, r := range results { + if r.Name == "openclaw" { + found = true + if r.Type != "general_agent" { + t.Errorf("expected general_agent, got %s", r.Type) + } + if r.InstallPath != "/Users/testuser/.openclaw" { + t.Errorf("expected /Users/testuser/.openclaw, got %s", r.InstallPath) + } + } + } + if !found { + t.Error("openclaw not found") + } +} + +func TestAgentDetector_ClaudeCowork(t *testing.T) { + mock := executor.NewMock() + mock.SetDir("/Applications/Claude.app") + mock.SetFile("/Applications/Claude.app/Contents/Info.plist", []byte{}) + mock.SetCommand("0.7.5", "", 0, "/usr/libexec/PlistBuddy", "-c", "Print :CFBundleShortVersionString", "/Applications/Claude.app/Contents/Info.plist") + + det := NewAgentDetector(mock) + results := det.Detect(context.Background(), []string{"/Users/testuser"}) + + found := false + for _, r := range results { + if r.Name == "claude-cowork" { + found = true + if r.Vendor != "Anthropic" { + t.Errorf("expected Anthropic, got %s", r.Vendor) + } + if r.Version != "0.7.5" { + t.Errorf("expected 0.7.5, got %s", r.Version) + } + } + } + if !found { + t.Error("claude-cowork not found") + } +} + +func TestAgentDetector_ClaudeCowork_OldVersion(t *testing.T) { + mock := executor.NewMock() + mock.SetDir("/Applications/Claude.app") + mock.SetFile("/Applications/Claude.app/Contents/Info.plist", []byte{}) + mock.SetCommand("0.6.9", "", 0, "/usr/libexec/PlistBuddy", "-c", "Print :CFBundleShortVersionString", "/Applications/Claude.app/Contents/Info.plist") + + det := NewAgentDetector(mock) + results := det.Detect(context.Background(), []string{"/Users/testuser"}) + + for _, r := range results { + if r.Name == "claude-cowork" { + t.Error("claude-cowork should not be detected for version 0.6.9") + } + } +} + +func TestIsCoworkVersion(t *testing.T) { + tests := []struct { + version string + want bool + }{ + {"0.7.0", true}, + {"0.7.5", true}, + {"0.9.0", true}, + {"1.0.0", true}, + {"1.5.2", true}, + {"0.6.9", false}, + {"0.1.0", false}, + {"unknown", false}, + } + for _, tt := range tests { + got := isCoworkVersion(tt.version) + if got != tt.want { + t.Errorf("isCoworkVersion(%q) = %v, want %v", tt.version, got, tt.want) + } + } +} diff --git a/internal/detector/aicli.go b/internal/detector/aicli.go new file mode 100644 index 0000000..9ae34d6 --- /dev/null +++ b/internal/detector/aicli.go @@ -0,0 +1,181 @@ +package detector + +import ( + "context" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +type cliToolSpec struct { + Name string + Vendor string + Binaries []string // binary names or paths (~ expanded at runtime) + ConfigDirs []string // config directory candidates (~ expanded) + VersionFlag string // flag to get version; defaults to "--version" + VerifyFunc func(ctx context.Context, exec executor.Executor, binary string) bool +} + +var cliToolDefinitions = []cliToolSpec{ + { + Name: "claude-code", + Vendor: "Anthropic", + Binaries: []string{"claude", "~/.claude/local/claude", "~/.local/bin/claude"}, + ConfigDirs: []string{"~/.claude"}, + }, + { + Name: "codex", + Vendor: "OpenAI", + Binaries: []string{"codex"}, + ConfigDirs: []string{"~/.codex"}, + }, + { + Name: "gemini-cli", + Vendor: "Google", + Binaries: []string{"gemini"}, + ConfigDirs: []string{"~/.gemini"}, + }, + { + Name: "amazon-q-cli", + Vendor: "Amazon", + Binaries: []string{"kiro-cli", "kiro", "q"}, + ConfigDirs: []string{"~/.q", "~/.kiro", "~/.aws/q"}, + VerifyFunc: func(ctx context.Context, exec executor.Executor, binary string) bool { + stdout, _, _, err := exec.RunWithTimeout(ctx, 10*time.Second, binary, "--version") + if err != nil { + return false + } + lower := strings.ToLower(stdout) + return strings.Contains(lower, "amazon") || strings.Contains(lower, "kiro") || strings.Contains(lower, "q developer") + }, + }, + { + Name: "github-copilot-cli", + Vendor: "Microsoft", + Binaries: []string{"copilot", "gh-copilot"}, + ConfigDirs: []string{"~/.config/github-copilot"}, + }, + { + Name: "microsoft-ai-shell", + Vendor: "Microsoft", + Binaries: []string{"aish", "ai"}, + ConfigDirs: []string{"~/.aish"}, + }, + { + Name: "aider", + Vendor: "OpenSource", + Binaries: []string{"aider"}, + ConfigDirs: []string{"~/.aider"}, + }, + { + Name: "opencode", + Vendor: "OpenSource", + Binaries: []string{"opencode", "~/.opencode/bin/opencode"}, + ConfigDirs: []string{"~/.config/opencode"}, + VersionFlag: "-v", + }, +} + +// AICLIDetector detects AI CLI tools. +type AICLIDetector struct { + exec executor.Executor +} + +func NewAICLIDetector(exec executor.Executor) *AICLIDetector { + return &AICLIDetector{exec: exec} +} + +func (d *AICLIDetector) Detect(ctx context.Context) []model.AITool { + homeDir := getHomeDir(d.exec) + var results []model.AITool + + for _, spec := range cliToolDefinitions { + binaryPath, found := d.findBinary(ctx, spec, homeDir) + if !found { + continue + } + + // Verify if needed (e.g., amazon-q-cli) + if spec.VerifyFunc != nil && !spec.VerifyFunc(ctx, d.exec, binaryPath) { + continue + } + + version := d.getVersion(ctx, spec, binaryPath) + configDir := d.findConfigDir(spec, homeDir) + + results = append(results, model.AITool{ + Name: spec.Name, + Vendor: spec.Vendor, + Type: "cli_tool", + Version: version, + BinaryPath: binaryPath, + ConfigDir: configDir, + }) + } + + return results +} + +func (d *AICLIDetector) findBinary(ctx context.Context, spec cliToolSpec, homeDir string) (string, bool) { + for _, bin := range spec.Binaries { + expanded := expandTilde(bin, homeDir) + if strings.Contains(expanded, "/") { + // Absolute/relative path - check if it exists + if d.exec.FileExists(expanded) { + return expanded, true + } + continue + } + // Search in PATH + if path, err := d.exec.LookPath(expanded); err == nil { + return path, true + } + } + return "", false +} + +func (d *AICLIDetector) getVersion(ctx context.Context, spec cliToolSpec, binaryPath string) string { + flag := "--version" + if spec.VersionFlag != "" { + flag = spec.VersionFlag + } + stdout, _, _, err := d.exec.RunWithTimeout(ctx, 10*time.Second, binaryPath, flag) + if err != nil { + return "unknown" + } + lines := strings.SplitN(stdout, "\n", 2) + if len(lines) > 0 { + v := strings.TrimSpace(lines[0]) + if v != "" { + return v + } + } + return "unknown" +} + +func (d *AICLIDetector) findConfigDir(spec cliToolSpec, homeDir string) string { + for _, dir := range spec.ConfigDirs { + expanded := expandTilde(dir, homeDir) + if d.exec.DirExists(expanded) { + return expanded + } + } + return "" +} + +func expandTilde(path, homeDir string) string { + if strings.HasPrefix(path, "~/") { + return homeDir + path[1:] + } + return path +} + +func getHomeDir(exec executor.Executor) string { + u, err := exec.CurrentUser() + if err != nil { + return "/tmp" + } + return u.HomeDir +} diff --git a/internal/detector/aicli_test.go b/internal/detector/aicli_test.go new file mode 100644 index 0000000..ba32d49 --- /dev/null +++ b/internal/detector/aicli_test.go @@ -0,0 +1,72 @@ +package detector + +import ( + "context" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +func TestAICLIDetector_FindsClaude(t *testing.T) { + mock := executor.NewMock() + mock.SetPath("claude", "/usr/local/bin/claude") + mock.SetCommand("1.0.12\n", "", 0, "/usr/local/bin/claude", "--version") + mock.SetDir("/Users/testuser/.claude") + + det := NewAICLIDetector(mock) + results := det.Detect(context.Background()) + + found := false + for _, r := range results { + if r.Name == "claude-code" { + found = true + if r.Version != "1.0.12" { + t.Errorf("expected version 1.0.12, got %s", r.Version) + } + if r.BinaryPath != "/usr/local/bin/claude" { + t.Errorf("expected /usr/local/bin/claude, got %s", r.BinaryPath) + } + if r.ConfigDir != "/Users/testuser/.claude" { + t.Errorf("expected config dir /Users/testuser/.claude, got %s", r.ConfigDir) + } + if r.Type != "cli_tool" { + t.Errorf("expected type cli_tool, got %s", r.Type) + } + } + } + if !found { + t.Error("claude-code not found in results") + } +} + +func TestAICLIDetector_NoToolsFound(t *testing.T) { + mock := executor.NewMock() + det := NewAICLIDetector(mock) + results := det.Detect(context.Background()) + + if len(results) != 0 { + t.Errorf("expected 0 tools, got %d", len(results)) + } +} + +func TestAICLIDetector_VersionUnknown(t *testing.T) { + mock := executor.NewMock() + mock.SetPath("codex", "/usr/local/bin/codex") + mock.SetCommand("", "", 1, "/usr/local/bin/codex", "--version") + + det := NewAICLIDetector(mock) + results := det.Detect(context.Background()) + + found := false + for _, r := range results { + if r.Name == "codex" { + found = true + if r.Version != "unknown" { + t.Errorf("expected unknown, got %s", r.Version) + } + } + } + if !found { + t.Error("codex not found") + } +} diff --git a/internal/detector/extension.go b/internal/detector/extension.go new file mode 100644 index 0000000..c27715a --- /dev/null +++ b/internal/detector/extension.go @@ -0,0 +1,144 @@ +package detector + +import ( + "context" + "encoding/json" + "strings" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +type ideExtensionSpec struct { + IDEName string + IDEType string + ExtDir string // relative to home (~/.vscode/extensions) +} + +var extensionDirs = []ideExtensionSpec{ + {"VS Code", "vscode", "~/.vscode/extensions"}, + {"Cursor", "openvsx", "~/.cursor/extensions"}, +} + +// ExtensionDetector collects IDE extensions. +type ExtensionDetector struct { + exec executor.Executor +} + +func NewExtensionDetector(exec executor.Executor) *ExtensionDetector { + return &ExtensionDetector{exec: exec} +} + +func (d *ExtensionDetector) Detect(_ context.Context, searchDirs []string) []model.Extension { + homeDir := getHomeDir(d.exec) + var results []model.Extension + + for _, spec := range extensionDirs { + extDir := expandTilde(spec.ExtDir, homeDir) + exts := d.collectFromDir(extDir, spec.IDEType) + results = append(results, exts...) + } + + return results +} + +func (d *ExtensionDetector) collectFromDir(extDir, ideType string) []model.Extension { + if !d.exec.DirExists(extDir) { + return nil + } + + // Load obsolete extensions + obsolete := d.loadObsolete(extDir) + + entries, err := d.exec.ReadDir(extDir) + if err != nil { + return nil + } + + var results []model.Extension + for _, entry := range entries { + if !entry.IsDir() { + continue + } + dirname := entry.Name() + + // Skip special entries + if dirname == "extensions.json" || dirname == ".obsolete" { + continue + } + + // Skip obsolete extensions + if obsolete[dirname] { + continue + } + + ext := parseExtensionDir(dirname, ideType) + if ext == nil { + continue + } + + // Get install date from directory modification time + info, err := d.exec.Stat(extDir + "/" + dirname) + if err == nil { + ext.InstallDate = info.ModTime().Unix() + } + + results = append(results, *ext) + } + + return results +} + +// parseExtensionDir parses "publisher.name-version[-platform]" directory name. +func parseExtensionDir(dirname, ideType string) *model.Extension { + // Find publisher (everything before first dot) + dotIdx := strings.Index(dirname, ".") + if dotIdx < 1 { + return nil + } + publisher := dirname[:dotIdx] + rest := dirname[dotIdx+1:] + + // Remove platform suffixes + for _, suffix := range []string{"-darwin-arm64", "-darwin-x64", "-universal", "-linux-x64", "-linux-arm64", "-win32-x64", "-win32-arm64"} { + if strings.HasSuffix(rest, suffix) { + rest = strings.TrimSuffix(rest, suffix) + break + } + } + + // Split name-version at last hyphen + lastHyphen := strings.LastIndex(rest, "-") + if lastHyphen < 1 { + return nil + } + name := rest[:lastHyphen] + version := rest[lastHyphen+1:] + + if publisher == "" || name == "" || version == "" { + return nil + } + + return &model.Extension{ + ID: publisher + "." + name, + Name: name, + Version: version, + Publisher: publisher, + IDEType: ideType, + } +} + +// loadObsolete reads the .obsolete file and returns a set of dirname -> true. +func (d *ExtensionDetector) loadObsolete(extDir string) map[string]bool { + obsoleteFile := extDir + "/.obsolete" + data, err := d.exec.ReadFile(obsoleteFile) + if err != nil { + return nil + } + + var obsoleteMap map[string]bool + if err := json.Unmarshal(data, &obsoleteMap); err != nil { + return nil + } + return obsoleteMap +} diff --git a/internal/detector/extension_test.go b/internal/detector/extension_test.go new file mode 100644 index 0000000..96bdb94 --- /dev/null +++ b/internal/detector/extension_test.go @@ -0,0 +1,82 @@ +package detector + +import ( + "testing" +) + +func TestParseExtensionDir_Valid(t *testing.T) { + tests := []struct { + dirname string + publisher string + name string + version string + }{ + {"ms-python.python-2024.22.0", "ms-python", "python", "2024.22.0"}, + {"esbenp.prettier-vscode-10.4.0", "esbenp", "prettier-vscode", "10.4.0"}, + {"dbaeumer.vscode-eslint-3.0.10", "dbaeumer", "vscode-eslint", "3.0.10"}, + } + + for _, tt := range tests { + ext := parseExtensionDir(tt.dirname, "vscode") + if ext == nil { + t.Errorf("parseExtensionDir(%q) returned nil", tt.dirname) + continue + } + if ext.Publisher != tt.publisher { + t.Errorf("publisher: expected %s, got %s", tt.publisher, ext.Publisher) + } + if ext.Name != tt.name { + t.Errorf("name: expected %s, got %s", tt.name, ext.Name) + } + if ext.Version != tt.version { + t.Errorf("version: expected %s, got %s", tt.version, ext.Version) + } + } +} + +func TestParseExtensionDir_PlatformSuffix(t *testing.T) { + ext := parseExtensionDir("ms-python.python-2024.22.0-darwin-arm64", "vscode") + if ext == nil { + t.Fatal("expected non-nil") + } + if ext.Version != "2024.22.0" { + t.Errorf("expected 2024.22.0, got %s", ext.Version) + } +} + +func TestParseExtensionDir_Universal(t *testing.T) { + ext := parseExtensionDir("ms-toolsai.jupyter-2024.5.0-universal", "vscode") + if ext == nil { + t.Fatal("expected non-nil") + } + if ext.Version != "2024.5.0" { + t.Errorf("expected 2024.5.0, got %s", ext.Version) + } +} + +func TestParseExtensionDir_Invalid(t *testing.T) { + tests := []string{ + "nopublisher", + ".nodot-prefix-1.0.0", + "pub.", + } + for _, tt := range tests { + ext := parseExtensionDir(tt, "vscode") + if ext != nil { + t.Errorf("parseExtensionDir(%q) should return nil, got %+v", tt, ext) + } + } +} + +func TestParseExtensionDir_ID(t *testing.T) { + ext := parseExtensionDir("ms-python.python-2024.22.0", "vscode") + if ext == nil { + t.Fatal("expected non-nil") + } + if ext.ID != "ms-python.python" { + t.Errorf("expected ms-python.python, got %s", ext.ID) + } + if ext.IDEType != "vscode" { + t.Errorf("expected vscode, got %s", ext.IDEType) + } +} diff --git a/internal/detector/framework.go b/internal/detector/framework.go new file mode 100644 index 0000000..5482564 --- /dev/null +++ b/internal/detector/framework.go @@ -0,0 +1,116 @@ +package detector + +import ( + "context" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +type frameworkSpec struct { + Name string + BinaryName string + ProcessName string +} + +var frameworkDefinitions = []frameworkSpec{ + {"ollama", "ollama", "ollama"}, + {"localai", "local-ai", "local-ai"}, + {"lm-studio", "lm-studio", "lm-studio"}, + {"text-generation-webui", "textgen", "textgen"}, +} + +// FrameworkDetector detects AI frameworks and runtimes. +type FrameworkDetector struct { + exec executor.Executor +} + +func NewFrameworkDetector(exec executor.Executor) *FrameworkDetector { + return &FrameworkDetector{exec: exec} +} + +func (d *FrameworkDetector) Detect(ctx context.Context) []model.AITool { + var results []model.AITool + + for _, spec := range frameworkDefinitions { + binaryPath, err := d.exec.LookPath(spec.BinaryName) + if err != nil { + continue + } + + version := d.getVersion(ctx, binaryPath) + isRunning := d.isProcessRunning(ctx, spec.ProcessName) + + results = append(results, model.AITool{ + Name: spec.Name, + Vendor: "Unknown", + Type: "framework", + Version: version, + BinaryPath: binaryPath, + IsRunning: &isRunning, + }) + } + + // LM Studio as a GUI application + if tool, ok := d.detectLMStudioApp(ctx); ok { + // Avoid duplicating if already found via binary + found := false + for _, r := range results { + if r.Name == "lm-studio" { + found = true + break + } + } + if !found { + results = append(results, tool) + } + } + + return results +} + +func (d *FrameworkDetector) getVersion(ctx context.Context, binaryPath string) string { + stdout, _, _, err := d.exec.RunWithTimeout(ctx, 10*time.Second, binaryPath, "--version") + if err != nil { + return "unknown" + } + lines := strings.SplitN(stdout, "\n", 2) + if len(lines) > 0 { + v := strings.TrimSpace(lines[0]) + if v != "" { + return v + } + } + return "unknown" +} + +func (d *FrameworkDetector) isProcessRunning(ctx context.Context, processName string) bool { + _, _, exitCode, _ := d.exec.Run(ctx, "pgrep", "-x", processName) + return exitCode == 0 +} + +func (d *FrameworkDetector) detectLMStudioApp(ctx context.Context) (model.AITool, bool) { + appPath := "/Applications/LM Studio.app" + if !d.exec.DirExists(appPath) { + return model.AITool{}, false + } + + version := readPlistVersion(ctx, d.exec, appPath+"/Contents/Info.plist") + + isRunning := false + _, _, exitCode, _ := d.exec.Run(ctx, "pgrep", "-f", "LM Studio") + if exitCode == 0 { + isRunning = true + } + + return model.AITool{ + Name: "lm-studio", + Vendor: "LM Studio", + Type: "framework", + Version: version, + BinaryPath: appPath, + IsRunning: &isRunning, + }, true +} diff --git a/internal/detector/framework_test.go b/internal/detector/framework_test.go new file mode 100644 index 0000000..8c519a9 --- /dev/null +++ b/internal/detector/framework_test.go @@ -0,0 +1,76 @@ +package detector + +import ( + "context" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +func TestFrameworkDetector_FindsOllama(t *testing.T) { + mock := executor.NewMock() + mock.SetPath("ollama", "/usr/local/bin/ollama") + mock.SetCommand("0.5.4\n", "", 0, "/usr/local/bin/ollama", "--version") + mock.SetCommand("12345\n", "", 0, "pgrep", "-x", "ollama") + + det := NewFrameworkDetector(mock) + results := det.Detect(context.Background()) + + found := false + for _, r := range results { + if r.Name == "ollama" { + found = true + if r.Type != "framework" { + t.Errorf("expected framework, got %s", r.Type) + } + if r.IsRunning == nil || !*r.IsRunning { + t.Error("expected is_running=true") + } + } + } + if !found { + t.Error("ollama not found") + } +} + +func TestFrameworkDetector_NotRunning(t *testing.T) { + mock := executor.NewMock() + mock.SetPath("ollama", "/usr/local/bin/ollama") + mock.SetCommand("0.5.4\n", "", 0, "/usr/local/bin/ollama", "--version") + mock.SetCommand("", "", 1, "pgrep", "-x", "ollama") // not running + + det := NewFrameworkDetector(mock) + results := det.Detect(context.Background()) + + for _, r := range results { + if r.Name == "ollama" { + if r.IsRunning == nil || *r.IsRunning { + t.Error("expected is_running=false") + } + } + } +} + +func TestFrameworkDetector_LMStudioApp(t *testing.T) { + mock := executor.NewMock() + mock.SetDir("/Applications/LM Studio.app") + mock.SetFile("/Applications/LM Studio.app/Contents/Info.plist", []byte{}) + mock.SetCommand("0.3.1", "", 0, "/usr/libexec/PlistBuddy", "-c", "Print :CFBundleShortVersionString", "/Applications/LM Studio.app/Contents/Info.plist") + mock.SetCommand("", "", 1, "pgrep", "-f", "LM Studio") // not running + + det := NewFrameworkDetector(mock) + results := det.Detect(context.Background()) + + found := false + for _, r := range results { + if r.Name == "lm-studio" { + found = true + if r.Version != "0.3.1" { + t.Errorf("expected 0.3.1, got %s", r.Version) + } + } + } + if !found { + t.Error("lm-studio not found") + } +} diff --git a/internal/detector/ide.go b/internal/detector/ide.go new file mode 100644 index 0000000..975eb26 --- /dev/null +++ b/internal/detector/ide.go @@ -0,0 +1,97 @@ +package detector + +import ( + "context" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +type ideSpec struct { + AppName string + IDEType string + Vendor string + AppPath string + BinaryPath string // relative to AppPath + VersionFlag string +} + +var ideDefinitions = []ideSpec{ + {"Visual Studio Code", "vscode", "Microsoft", "/Applications/Visual Studio Code.app", "Contents/Resources/app/bin/code", "--version"}, + {"Cursor", "cursor", "Cursor", "/Applications/Cursor.app", "Contents/Resources/app/bin/cursor", "--version"}, + {"Windsurf", "windsurf", "Codeium", "/Applications/Windsurf.app", "Contents/MacOS/Windsurf", "--version"}, + {"Antigravity", "antigravity", "Google", "/Applications/Antigravity.app", "Contents/MacOS/Antigravity", "--version"}, + {"Zed", "zed", "Zed", "/Applications/Zed.app", "Contents/MacOS/zed", ""}, + {"Claude", "claude_desktop", "Anthropic", "/Applications/Claude.app", "", ""}, + {"Microsoft Copilot", "microsoft_copilot_desktop", "Microsoft", "/Applications/Copilot.app", "", ""}, +} + +// IDEDetector detects installed IDEs and AI desktop apps. +type IDEDetector struct { + exec executor.Executor +} + +func NewIDEDetector(exec executor.Executor) *IDEDetector { + return &IDEDetector{exec: exec} +} + +func (d *IDEDetector) Detect(ctx context.Context) []model.IDE { + var results []model.IDE + + for _, spec := range ideDefinitions { + if !d.exec.DirExists(spec.AppPath) { + continue + } + + version := "unknown" + + // Try version from binary + if spec.BinaryPath != "" && spec.VersionFlag != "" { + binaryFull := spec.AppPath + "/" + spec.BinaryPath + if d.exec.FileExists(binaryFull) { + stdout, _, _, err := d.exec.RunWithTimeout(ctx, 10*time.Second, binaryFull, spec.VersionFlag) + if err == nil { + lines := strings.SplitN(stdout, "\n", 2) + if len(lines) > 0 { + v := strings.TrimSpace(lines[0]) + if v != "" { + version = v + } + } + } + } + } + + // Fallback: Info.plist + if version == "unknown" { + version = readPlistVersion(ctx, d.exec, spec.AppPath+"/Contents/Info.plist") + } + + results = append(results, model.IDE{ + IDEType: spec.IDEType, + Version: version, + InstallPath: spec.AppPath, + Vendor: spec.Vendor, + IsInstalled: true, + }) + } + + return results +} + +// readPlistVersion reads CFBundleShortVersionString from an Info.plist. +func readPlistVersion(ctx context.Context, exec executor.Executor, plistPath string) string { + if !exec.FileExists(plistPath) { + return "unknown" + } + stdout, _, _, err := exec.Run(ctx, "/usr/libexec/PlistBuddy", "-c", "Print :CFBundleShortVersionString", plistPath) + if err == nil { + v := strings.TrimSpace(stdout) + if v != "" { + return v + } + } + return "unknown" +} diff --git a/internal/detector/ide_test.go b/internal/detector/ide_test.go new file mode 100644 index 0000000..2178d5f --- /dev/null +++ b/internal/detector/ide_test.go @@ -0,0 +1,79 @@ +package detector + +import ( + "context" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +func TestIDEDetector_FindsVSCode(t *testing.T) { + mock := executor.NewMock() + mock.SetDir("/Applications/Visual Studio Code.app") + mock.SetFile("/Applications/Visual Studio Code.app/Contents/Info.plist", []byte{}) + mock.SetCommand("1.96.0\n", "", 0, "/usr/libexec/PlistBuddy", "-c", "Print :CFBundleShortVersionString", "/Applications/Visual Studio Code.app/Contents/Info.plist") + + det := NewIDEDetector(mock) + results := det.Detect(context.Background()) + + if len(results) != 1 { + t.Fatalf("expected 1 IDE, got %d", len(results)) + } + if results[0].IDEType != "vscode" { + t.Errorf("expected vscode, got %s", results[0].IDEType) + } + if results[0].Version != "1.96.0" { + t.Errorf("expected 1.96.0, got %s", results[0].Version) + } + if results[0].Vendor != "Microsoft" { + t.Errorf("expected Microsoft, got %s", results[0].Vendor) + } + if !results[0].IsInstalled { + t.Error("expected is_installed=true") + } +} + +func TestIDEDetector_NotInstalled(t *testing.T) { + mock := executor.NewMock() + det := NewIDEDetector(mock) + results := det.Detect(context.Background()) + + if len(results) != 0 { + t.Errorf("expected 0 IDEs, got %d", len(results)) + } +} + +func TestIDEDetector_VersionFromBinary(t *testing.T) { + mock := executor.NewMock() + mock.SetDir("/Applications/Cursor.app") + mock.SetFile("/Applications/Cursor.app/Contents/Resources/app/bin/cursor", []byte{}) + mock.SetCommand("0.50.1\n1234abcd\nx64", "", 0, "/Applications/Cursor.app/Contents/Resources/app/bin/cursor", "--version") + + det := NewIDEDetector(mock) + results := det.Detect(context.Background()) + + if len(results) != 1 { + t.Fatalf("expected 1 IDE, got %d", len(results)) + } + if results[0].Version != "0.50.1" { + t.Errorf("expected 0.50.1, got %s", results[0].Version) + } +} + +func TestIDEDetector_MultipleIDEs(t *testing.T) { + mock := executor.NewMock() + mock.SetDir("/Applications/Visual Studio Code.app") + mock.SetFile("/Applications/Visual Studio Code.app/Contents/Info.plist", []byte{}) + mock.SetCommand("1.96.0", "", 0, "/usr/libexec/PlistBuddy", "-c", "Print :CFBundleShortVersionString", "/Applications/Visual Studio Code.app/Contents/Info.plist") + + mock.SetDir("/Applications/Claude.app") + mock.SetFile("/Applications/Claude.app/Contents/Info.plist", []byte{}) + mock.SetCommand("0.7.1", "", 0, "/usr/libexec/PlistBuddy", "-c", "Print :CFBundleShortVersionString", "/Applications/Claude.app/Contents/Info.plist") + + det := NewIDEDetector(mock) + results := det.Detect(context.Background()) + + if len(results) != 2 { + t.Fatalf("expected 2 IDEs, got %d", len(results)) + } +} diff --git a/internal/detector/mcp.go b/internal/detector/mcp.go new file mode 100644 index 0000000..b6e1f6e --- /dev/null +++ b/internal/detector/mcp.go @@ -0,0 +1,204 @@ +package detector + +import ( + "context" + "encoding/base64" + "encoding/json" + "strings" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +type mcpConfigSpec struct { + SourceName string + ConfigPath string // relative to home; uses ~ prefix + Vendor string +} + +var mcpConfigDefinitions = []mcpConfigSpec{ + {"claude_desktop", "~/Library/Application Support/Claude/claude_desktop_config.json", "Anthropic"}, + {"claude_code", "~/.claude/settings.json", "Anthropic"}, + {"claude_code", "~/.claude.json", "Anthropic"}, + {"cursor", "~/.cursor/mcp.json", "Cursor"}, + {"windsurf", "~/.codeium/windsurf/mcp_config.json", "Codeium"}, + {"antigravity", "~/.gemini/antigravity/mcp_config.json", "Google"}, + {"zed", "~/.config/zed/settings.json", "Zed"}, + {"open_interpreter", "~/.config/open-interpreter/config.yaml", "OpenSource"}, + {"codex", "~/.codex/config.toml", "OpenAI"}, +} + +// MCPDetector collects MCP configuration files. +type MCPDetector struct { + exec executor.Executor +} + +func NewMCPDetector(exec executor.Executor) *MCPDetector { + return &MCPDetector{exec: exec} +} + +// Detect finds MCP configs. If enterprise is true, includes base64-encoded content. +// Returns community-mode MCPConfig structs (enterprise mode uses MCPConfigEnterprise separately). +func (d *MCPDetector) Detect(_ context.Context, userIdentity string, enterprise bool) []model.MCPConfig { + homeDir := getHomeDir(d.exec) + var results []model.MCPConfig + + for _, spec := range mcpConfigDefinitions { + configPath := expandTilde(spec.ConfigPath, homeDir) + + if !d.exec.FileExists(configPath) { + continue + } + + results = append(results, model.MCPConfig{ + ConfigSource: spec.SourceName, + ConfigPath: configPath, + Vendor: spec.Vendor, + }) + } + + return results +} + +// DetectEnterprise returns enterprise-mode MCP configs with base64 content. +func (d *MCPDetector) DetectEnterprise(_ context.Context) []model.MCPConfigEnterprise { + homeDir := getHomeDir(d.exec) + var results []model.MCPConfigEnterprise + + for _, spec := range mcpConfigDefinitions { + configPath := expandTilde(spec.ConfigPath, homeDir) + + if !d.exec.FileExists(configPath) { + continue + } + + content, err := d.exec.ReadFile(configPath) + if err != nil || len(content) == 0 { + continue + } + + // Filter JSON configs to extract only MCP-relevant fields + filteredContent := d.filterMCPContent(spec.SourceName, configPath, content) + contentBase64 := base64.StdEncoding.EncodeToString(filteredContent) + + results = append(results, model.MCPConfigEnterprise{ + ConfigSource: spec.SourceName, + ConfigPath: configPath, + Vendor: spec.Vendor, + ConfigContentBase64: contentBase64, + }) + } + + return results +} + +// filterMCPContent extracts MCP-relevant fields from a config file. +func (d *MCPDetector) filterMCPContent(sourceName, configPath string, content []byte) []byte { + if !strings.HasSuffix(configPath, ".json") { + return content // Return as-is for TOML/YAML + } + + jsonInput := content + + // Strip JSONC comments for Zed + if sourceName == "zed" { + jsonInput = stripJSONCComments(jsonInput) + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(jsonInput, &raw); err != nil { + return content // Can't parse, return as-is + } + + filtered := d.extractMCPServers(raw) + if filtered == nil { + return content + } + + out, err := json.Marshal(filtered) + if err != nil { + return content + } + return out +} + +// extractMCPServers extracts mcpServers/context_servers, keeping only command/args/serverUrl/url. +func (d *MCPDetector) extractMCPServers(raw map[string]json.RawMessage) map[string]any { + // Try mcpServers + if servers, ok := raw["mcpServers"]; ok { + return map[string]any{"mcpServers": filterServerFields(servers)} + } + // Try context_servers + if servers, ok := raw["context_servers"]; ok { + return map[string]any{"context_servers": filterServerFields(servers)} + } + return nil +} + +// filterServerFields keeps only command, args, serverUrl, url from each server entry. +func filterServerFields(serversRaw json.RawMessage) map[string]any { + var servers map[string]map[string]any + if err := json.Unmarshal(serversRaw, &servers); err != nil { + return nil + } + + result := make(map[string]any) + allowedKeys := map[string]bool{"command": true, "args": true, "serverUrl": true, "url": true} + + for name, serverConfig := range servers { + filtered := make(map[string]any) + for k, v := range serverConfig { + if allowedKeys[k] { + filtered[k] = v + } + } + result[name] = filtered + } + return result +} + +// stripJSONCComments removes // and /* */ comments from JSONC content, +// respecting quoted strings (won't strip // inside "https://..."). +func stripJSONCComments(input []byte) []byte { + var out []byte + i := 0 + for i < len(input) { + // Skip over strings — don't modify content inside quotes + if input[i] == '"' { + out = append(out, input[i]) + i++ + for i < len(input) { + out = append(out, input[i]) + if input[i] == '\\' && i+1 < len(input) { + i++ + out = append(out, input[i]) + } else if input[i] == '"' { + break + } + i++ + } + i++ + continue + } + // Block comment + if i+1 < len(input) && input[i] == '/' && input[i+1] == '*' { + i += 2 + for i+1 < len(input) && !(input[i] == '*' && input[i+1] == '/') { + i++ + } + i += 2 // skip */ + continue + } + // Line comment + if i+1 < len(input) && input[i] == '/' && input[i+1] == '/' { + i += 2 + for i < len(input) && input[i] != '\n' { + i++ + } + continue + } + out = append(out, input[i]) + i++ + } + return out +} diff --git a/internal/detector/mcp_test.go b/internal/detector/mcp_test.go new file mode 100644 index 0000000..682dd5c --- /dev/null +++ b/internal/detector/mcp_test.go @@ -0,0 +1,83 @@ +package detector + +import ( + "context" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +func TestMCPDetector_FindsConfigs(t *testing.T) { + mock := executor.NewMock() + mock.SetFile("/Users/testuser/Library/Application Support/Claude/claude_desktop_config.json", []byte(`{"mcpServers":{}}`)) + mock.SetFile("/Users/testuser/.cursor/mcp.json", []byte(`{"mcpServers":{}}`)) + + det := NewMCPDetector(mock) + results := det.Detect(context.Background(), "testuser", false) + + if len(results) != 2 { + t.Fatalf("expected 2 configs, got %d", len(results)) + } + if results[0].ConfigSource != "claude_desktop" { + t.Errorf("expected claude_desktop, got %s", results[0].ConfigSource) + } + if results[1].ConfigSource != "cursor" { + t.Errorf("expected cursor, got %s", results[1].ConfigSource) + } +} + +func TestMCPDetector_NoConfigs(t *testing.T) { + mock := executor.NewMock() + det := NewMCPDetector(mock) + results := det.Detect(context.Background(), "testuser", false) + + if len(results) != 0 { + t.Errorf("expected 0 configs, got %d", len(results)) + } +} + +func TestStripJSONCComments(t *testing.T) { + input := []byte(`{ + // This is a comment + "key": "value", /* block comment */ + "key2": "value2" +}`) + + result := stripJSONCComments(input) + if len(result) == 0 { + t.Error("expected non-empty result") + } + // Should not contain comments + if containsString(string(result), "//") || containsString(string(result), "/*") { + t.Error("comments not stripped") + } +} + +func containsString(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstr(s, substr)) +} + +func containsSubstr(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +func TestMCPDetector_Enterprise(t *testing.T) { + mock := executor.NewMock() + mock.SetFile("/Users/testuser/Library/Application Support/Claude/claude_desktop_config.json", + []byte(`{"mcpServers":{"server1":{"command":"node","args":["server.js"],"env":{"SECRET":"key"}}}}`)) + + det := NewMCPDetector(mock) + results := det.DetectEnterprise(context.Background()) + + if len(results) != 1 { + t.Fatalf("expected 1 enterprise config, got %d", len(results)) + } + if results[0].ConfigContentBase64 == "" { + t.Error("expected non-empty base64 content") + } +} diff --git a/internal/detector/nodepm.go b/internal/detector/nodepm.go new file mode 100644 index 0000000..a26402e --- /dev/null +++ b/internal/detector/nodepm.go @@ -0,0 +1,60 @@ +package detector + +import ( + "context" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +type pmSpec struct { + Name string + Binary string + VersionCmd string +} + +var packageManagers = []pmSpec{ + {"npm", "npm", "--version"}, + {"yarn", "yarn", "--version"}, + {"pnpm", "pnpm", "--version"}, + {"bun", "bun", "--version"}, +} + +// NodePMDetector detects installed Node.js package managers. +type NodePMDetector struct { + exec executor.Executor +} + +func NewNodePMDetector(exec executor.Executor) *NodePMDetector { + return &NodePMDetector{exec: exec} +} + +func (d *NodePMDetector) DetectManagers(ctx context.Context) []model.PkgManager { + var results []model.PkgManager + + for _, pm := range packageManagers { + path, err := d.exec.LookPath(pm.Binary) + if err != nil { + continue + } + + version := "unknown" + stdout, _, _, err := d.exec.RunWithTimeout(ctx, 10*time.Second, pm.Binary, pm.VersionCmd) + if err == nil { + v := strings.TrimSpace(stdout) + if v != "" { + version = v + } + } + + results = append(results, model.PkgManager{ + Name: pm.Name, + Version: version, + Path: path, + }) + } + + return results +} diff --git a/internal/detector/nodepm_test.go b/internal/detector/nodepm_test.go new file mode 100644 index 0000000..cb4687d --- /dev/null +++ b/internal/detector/nodepm_test.go @@ -0,0 +1,76 @@ +package detector + +import ( + "context" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +func TestNodePMDetector_FindsNPM(t *testing.T) { + mock := executor.NewMock() + mock.SetPath("npm", "/usr/local/bin/npm") + mock.SetCommand("10.2.0\n", "", 0, "npm", "--version") + + det := NewNodePMDetector(mock) + results := det.DetectManagers(context.Background()) + + if len(results) < 1 { + t.Fatal("expected at least 1 package manager") + } + if results[0].Name != "npm" { + t.Errorf("expected npm, got %s", results[0].Name) + } + if results[0].Version != "10.2.0" { + t.Errorf("expected 10.2.0, got %s", results[0].Version) + } +} + +func TestNodePMDetector_Multiple(t *testing.T) { + mock := executor.NewMock() + mock.SetPath("npm", "/usr/local/bin/npm") + mock.SetCommand("10.2.0\n", "", 0, "npm", "--version") + mock.SetPath("yarn", "/usr/local/bin/yarn") + mock.SetCommand("1.22.19\n", "", 0, "yarn", "--version") + + det := NewNodePMDetector(mock) + results := det.DetectManagers(context.Background()) + + if len(results) != 2 { + t.Fatalf("expected 2 package managers, got %d", len(results)) + } +} + +func TestNodePMDetector_NoneFound(t *testing.T) { + mock := executor.NewMock() + det := NewNodePMDetector(mock) + results := det.DetectManagers(context.Background()) + + if len(results) != 0 { + t.Errorf("expected 0 package managers, got %d", len(results)) + } +} + +func TestDetectProjectPM(t *testing.T) { + tests := []struct { + name string + file string + expected string + }{ + {"bun lock", "/project/bun.lock", "bun"}, + {"pnpm lock", "/project/pnpm-lock.yaml", "pnpm"}, + {"yarn lock", "/project/yarn.lock", "yarn"}, + {"npm lock", "/project/package-lock.json", "npm"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := executor.NewMock() + mock.SetFile(tt.file, []byte{}) + got := DetectProjectPM(mock, "/project") + if got != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, got) + } + }) + } +} diff --git a/internal/detector/nodeproject.go b/internal/detector/nodeproject.go new file mode 100644 index 0000000..fb9db5e --- /dev/null +++ b/internal/detector/nodeproject.go @@ -0,0 +1,84 @@ +package detector + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +const maxNodeProjects = 1000 + +// NodeProjectDetector scans for Node.js projects. +type NodeProjectDetector struct { + exec executor.Executor +} + +func NewNodeProjectDetector(exec executor.Executor) *NodeProjectDetector { + return &NodeProjectDetector{exec: exec} +} + +// CountProjects counts the number of Node.js projects found under the given directories. +// It finds package.json files (excluding node_modules) up to a limit. +func (d *NodeProjectDetector) CountProjects(_ context.Context, searchDirs []string) int { + count := 0 + for _, dir := range searchDirs { + count += d.countInDir(dir) + if count >= maxNodeProjects { + return maxNodeProjects + } + } + return count +} + +func (d *NodeProjectDetector) countInDir(dir string) int { + count := 0 + _ = filepath.WalkDir(dir, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return nil // skip inaccessible dirs + } + if entry.IsDir() { + name := entry.Name() + // Skip node_modules, hidden dirs, and other irrelevant dirs + if name == "node_modules" || name == ".git" || name == ".cache" || + strings.HasPrefix(name, ".") { + return filepath.SkipDir + } + return nil + } + if entry.Name() == "package.json" { + count++ + if count >= maxNodeProjects { + return filepath.SkipAll + } + } + return nil + }) + return count +} + +// DetectProjectPM detects which package manager a project uses based on lock files. +func DetectProjectPM(exec executor.Executor, projectDir string) string { + if strings.Contains(projectDir, "/.bun/install/") { + return "bun" + } + if exec.FileExists(filepath.Join(projectDir, "bun.lock")) || exec.FileExists(filepath.Join(projectDir, "bun.lockb")) { + return "bun" + } + if exec.FileExists(filepath.Join(projectDir, "pnpm-lock.yaml")) { + return "pnpm" + } + if exec.FileExists(filepath.Join(projectDir, "yarn.lock")) { + // Distinguish Yarn Classic from Yarn Berry + if exec.FileExists(filepath.Join(projectDir, ".yarnrc.yml")) || exec.DirExists(filepath.Join(projectDir, ".yarn", "releases")) { + return "yarn-berry" + } + return "yarn" + } + if exec.FileExists(filepath.Join(projectDir, "package-lock.json")) { + return "npm" + } + return "npm" // default +} diff --git a/internal/detector/nodescan.go b/internal/detector/nodescan.go new file mode 100644 index 0000000..141affd --- /dev/null +++ b/internal/detector/nodescan.go @@ -0,0 +1,327 @@ +package detector + +import ( + "context" + "encoding/base64" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "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" +) + +const defaultMaxProjectScanBytes = 500 * 1024 * 1024 // 500MB total limit + +// getMaxProjectScanBytes returns the size limit, overridable via +// STEPSEC_MAX_NODE_SCAN_BYTES environment variable. +func getMaxProjectScanBytes() int64 { + if v := os.Getenv("STEPSEC_MAX_NODE_SCAN_BYTES"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 { + return n + } + } + return defaultMaxProjectScanBytes +} + +// NodeScanner performs enterprise-mode node scanning (raw output, base64 encoded). +type NodeScanner struct { + exec executor.Executor + log *progress.Logger +} + +func NewNodeScanner(exec executor.Executor, log *progress.Logger) *NodeScanner { + return &NodeScanner{exec: exec, log: log} +} + +// ScanGlobalPackages runs npm/yarn/pnpm list -g and returns raw base64-encoded results. +func (s *NodeScanner) ScanGlobalPackages(ctx context.Context) []model.NodeScanResult { + var results []model.NodeScanResult + + s.log.Progress(" Checking npm global packages...") + if r, ok := s.scanNPMGlobal(ctx); ok { + results = append(results, r) + } + + s.log.Progress(" Checking yarn global packages...") + if r, ok := s.scanYarnGlobal(ctx); ok { + results = append(results, r) + } + + s.log.Progress(" Checking pnpm global packages...") + if r, ok := s.scanPnpmGlobal(ctx); ok { + results = append(results, r) + } + + return results +} + +func (s *NodeScanner) scanNPMGlobal(ctx context.Context) (model.NodeScanResult, bool) { + if _, err := s.exec.LookPath("npm"); err != nil { + return model.NodeScanResult{}, false + } + + version := s.getVersion(ctx, "npm", "--version") + prefix := s.getOutput(ctx, "npm", "config", "get", "prefix") + if prefix == "" { + return model.NodeScanResult{}, false + } + + start := time.Now() + stdout, stderr, exitCode, _ := s.exec.RunWithTimeout(ctx, 60*time.Second, "npm", "list", "-g", "--json", "--depth=3") + duration := time.Since(start).Milliseconds() + + errMsg := "" + if exitCode != 0 { + errMsg = "npm list -g command failed with exit code" + } + + return model.NodeScanResult{ + ProjectPath: prefix, + PackageManager: "npm", + PMVersion: version, + WorkingDirectory: prefix, + RawStdoutBase64: base64.StdEncoding.EncodeToString([]byte(stdout)), + RawStderrBase64: base64.StdEncoding.EncodeToString([]byte(stderr)), + Error: errMsg, + ExitCode: exitCode, + ScanDurationMs: duration, + }, true +} + +func (s *NodeScanner) scanYarnGlobal(ctx context.Context) (model.NodeScanResult, bool) { + if _, err := s.exec.LookPath("yarn"); err != nil { + return model.NodeScanResult{}, false + } + + version := s.getVersion(ctx, "yarn", "--version") + globalDir := s.getOutput(ctx, "yarn", "global", "dir") + if globalDir == "" { + return model.NodeScanResult{}, false + } + + start := time.Now() + stdout, stderr, exitCode, _ := s.exec.RunWithTimeout(ctx, 60*time.Second, "bash", "-c", "cd '"+globalDir+"' && yarn list --json --depth=0") + duration := time.Since(start).Milliseconds() + + errMsg := "" + if exitCode != 0 { + errMsg = "yarn global list command failed" + } + + return model.NodeScanResult{ + ProjectPath: globalDir, + PackageManager: "yarn", + PMVersion: version, + WorkingDirectory: globalDir, + RawStdoutBase64: base64.StdEncoding.EncodeToString([]byte(stdout)), + RawStderrBase64: base64.StdEncoding.EncodeToString([]byte(stderr)), + Error: errMsg, + ExitCode: exitCode, + ScanDurationMs: duration, + }, true +} + +func (s *NodeScanner) scanPnpmGlobal(ctx context.Context) (model.NodeScanResult, bool) { + if _, err := s.exec.LookPath("pnpm"); err != nil { + return model.NodeScanResult{}, false + } + + version := s.getVersion(ctx, "pnpm", "--version") + globalDir := s.getOutput(ctx, "pnpm", "root", "-g") + if globalDir == "" { + return model.NodeScanResult{}, false + } + globalDir = filepath.Dir(globalDir) + + start := time.Now() + stdout, stderr, exitCode, _ := s.exec.RunWithTimeout(ctx, 60*time.Second, "pnpm", "list", "-g", "--json", "--depth=3") + duration := time.Since(start).Milliseconds() + + errMsg := "" + if exitCode != 0 { + errMsg = "pnpm list -g command failed" + } + + return model.NodeScanResult{ + ProjectPath: globalDir, + PackageManager: "pnpm", + PMVersion: version, + WorkingDirectory: globalDir, + RawStdoutBase64: base64.StdEncoding.EncodeToString([]byte(stdout)), + RawStderrBase64: base64.StdEncoding.EncodeToString([]byte(stderr)), + Error: errMsg, + ExitCode: exitCode, + ScanDurationMs: duration, + }, true +} + +// projectEntry holds a discovered package.json with its modification time for sorting. +type projectEntry struct { + dir string + modTime int64 +} + +// ScanProjects finds package.json files, sorts by most recently modified, then scans. +// Respects the size limit (default 500MB, override via STEPSEC_MAX_NODE_SCAN_BYTES). +func (s *NodeScanner) ScanProjects(ctx context.Context, searchDirs []string) []model.NodeScanResult { + // Phase 1: Discover all package.json files + var projects []projectEntry + for _, dir := range searchDirs { + s.log.Progress(" Searching in: %s", dir) + _ = filepath.WalkDir(dir, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return nil + } + if entry.IsDir() { + name := entry.Name() + if name == "node_modules" || name == ".git" || name == ".cache" || + strings.HasPrefix(name, ".") { + return filepath.SkipDir + } + return nil + } + if entry.Name() != "package.json" { + return nil + } + projectDir := filepath.Dir(path) + if strings.Contains(projectDir, "/node_modules/") { + return nil + } + // Get modification time for sorting + modTime := int64(0) + if info, err := entry.Info(); err == nil { + modTime = info.ModTime().Unix() + } + projects = append(projects, projectEntry{dir: projectDir, modTime: modTime}) + return nil + }) + } + + // Phase 2: Sort by modification time descending (most recent first) + sort.Slice(projects, func(i, j int) bool { + return projects[i].modTime > projects[j].modTime + }) + + // Phase 3: Scan in order, respecting limits + maxBytes := getMaxProjectScanBytes() + var results []model.NodeScanResult + totalSize := int64(0) + + for i, p := range projects { + if i >= maxNodeProjects { + s.log.Progress(" Reached maximum of %d projects, stopping search", maxNodeProjects) + break + } + if totalSize > maxBytes { + s.log.Progress(" Reached data size limit (%d bytes collected, limit: %d bytes)", totalSize, maxBytes) + s.log.Progress(" Skipping remaining projects (prioritized by most recently modified)") + break + } + + s.log.Progress(" Found project: %s", p.dir) + pm := DetectProjectPM(s.exec, p.dir) + s.log.Progress(" Package manager: %s", pm) + + r := s.scanProject(ctx, p.dir) + resultSize := int64(len(r.RawStdoutBase64)) + int64(len(r.RawStderrBase64)) + + if totalSize+resultSize > maxBytes { + s.log.Progress(" Reached data size limit (%d bytes collected, limit: %d bytes)", totalSize, maxBytes) + s.log.Progress(" Skipping remaining projects (prioritized by most recently modified)") + break + } + + totalSize += resultSize + results = append(results, r) + } + + return results +} + +func (s *NodeScanner) scanProject(ctx context.Context, projectDir string) model.NodeScanResult { + pm := DetectProjectPM(s.exec, projectDir) + version := "" + + var cmd string + var args []string + + switch pm { + case "npm": + version = s.getVersion(ctx, "npm", "--version") + cmd = "npm" + args = []string{"ls", "--json", "--depth=3"} + case "yarn": + version = s.getVersion(ctx, "yarn", "--version") + cmd = "yarn" + args = []string{"list", "--json"} + case "yarn-berry": + version = s.getVersion(ctx, "yarn", "--version") + cmd = "yarn" + args = []string{"info", "--all", "--json"} + case "pnpm": + version = s.getVersion(ctx, "pnpm", "--version") + cmd = "pnpm" + args = []string{"ls", "--json", "--depth=3"} + case "bun": + version = s.getVersion(ctx, "bun", "--version") + cmd = "bun" + args = []string{"pm", "ls", "--all"} + default: + return model.NodeScanResult{ + ProjectPath: projectDir, + PackageManager: pm, + Error: "unsupported package manager", + ExitCode: 1, + } + } + + start := time.Now() + shellCmd := "cd " + shellQuote(projectDir) + " && " + cmd + for _, a := range args { + shellCmd += " " + a + } + stdout, stderr, exitCode, _ := s.exec.RunWithTimeout(ctx, 30*time.Second, "bash", "-c", shellCmd) + duration := time.Since(start).Milliseconds() + + errMsg := "" + if exitCode != 0 { + errMsg = cmd + " command failed with exit code" + } + + return model.NodeScanResult{ + ProjectPath: projectDir, + PackageManager: pm, + PMVersion: version, + WorkingDirectory: projectDir, + RawStdoutBase64: base64.StdEncoding.EncodeToString([]byte(stdout)), + RawStderrBase64: base64.StdEncoding.EncodeToString([]byte(stderr)), + Error: errMsg, + ExitCode: exitCode, + ScanDurationMs: duration, + } +} + +func (s *NodeScanner) getVersion(ctx context.Context, binary, flag string) string { + stdout, _, _, err := s.exec.RunWithTimeout(ctx, 10*time.Second, binary, flag) + if err != nil { + return "unknown" + } + return strings.TrimSpace(stdout) +} + +func (s *NodeScanner) getOutput(ctx context.Context, binary string, args ...string) string { + stdout, _, _, err := s.exec.RunWithTimeout(ctx, 10*time.Second, binary, args...) + if err != nil { + return "" + } + return strings.TrimSpace(stdout) +} + +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +} diff --git a/internal/device/device.go b/internal/device/device.go new file mode 100644 index 0000000..ba0d366 --- /dev/null +++ b/internal/device/device.go @@ -0,0 +1,88 @@ +package device + +import ( + "context" + "strings" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +// Gather collects device information (hostname, serial, OS version, user identity). +func Gather(ctx context.Context, exec executor.Executor) model.Device { + hostname, _ := exec.Hostname() + serial := getSerialNumber(ctx, exec) + osVersion := getOSVersion(ctx, exec) + userIdentity := getDeveloperIdentity(exec) + + return model.Device{ + Hostname: hostname, + SerialNumber: serial, + OSVersion: osVersion, + Platform: "darwin", + UserIdentity: userIdentity, + } +} + +func getSerialNumber(ctx context.Context, exec executor.Executor) string { + // Try ioreg first + stdout, _, _, err := exec.Run(ctx, "ioreg", "-l") + if err == nil { + for _, line := range strings.Split(stdout, "\n") { + if strings.Contains(line, "IOPlatformSerialNumber") { + parts := strings.Split(line, "=") + if len(parts) >= 2 { + serial := strings.TrimSpace(parts[1]) + serial = strings.Trim(serial, "\" ") + if serial != "" { + return serial + } + } + } + } + } + + // Fallback: system_profiler + stdout, _, _, err = exec.Run(ctx, "system_profiler", "SPHardwareDataType") + if err == nil { + for _, line := range strings.Split(stdout, "\n") { + if strings.Contains(line, "Serial") { + parts := strings.Split(line, ":") + if len(parts) >= 2 { + serial := strings.TrimSpace(parts[1]) + if serial != "" { + return serial + } + } + } + } + } + + return "unknown" +} + +func getOSVersion(ctx context.Context, exec executor.Executor) string { + stdout, _, _, err := exec.Run(ctx, "sw_vers", "-productVersion") + if err == nil { + v := strings.TrimSpace(stdout) + if v != "" { + return v + } + } + return "unknown" +} + +func getDeveloperIdentity(exec executor.Executor) string { + // Check environment variables in order of preference + for _, key := range []string{"USER_EMAIL", "DEVELOPER_EMAIL", "STEPSEC_DEVELOPER_EMAIL"} { + if v := exec.Getenv(key); v != "" { + return v + } + } + // Fallback to current username + u, err := exec.CurrentUser() + if err == nil { + return u.Username + } + return "unknown" +} diff --git a/internal/device/device_test.go b/internal/device/device_test.go new file mode 100644 index 0000000..9289ad9 --- /dev/null +++ b/internal/device/device_test.go @@ -0,0 +1,59 @@ +package device + +import ( + "context" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +func TestGather_BasicFields(t *testing.T) { + mock := executor.NewMock() + mock.SetHostname("test-mac.local") + mock.SetCommand("SERIAL123\n \"IOPlatformSerialNumber\" = \"SERIAL123\"\n", "", 0, "ioreg", "-l") + mock.SetCommand("15.1\n", "", 0, "sw_vers", "-productVersion") + mock.SetUsername("devuser") + + dev := Gather(context.Background(), mock) + + if dev.Hostname != "test-mac.local" { + t.Errorf("hostname: expected test-mac.local, got %s", dev.Hostname) + } + if dev.OSVersion != "15.1" { + t.Errorf("os_version: expected 15.1, got %s", dev.OSVersion) + } + if dev.Platform != "darwin" { + t.Errorf("platform: expected darwin, got %s", dev.Platform) + } + if dev.UserIdentity != "devuser" { + t.Errorf("user_identity: expected devuser, got %s", dev.UserIdentity) + } +} + +func TestGather_FallbackSerial(t *testing.T) { + mock := executor.NewMock() + mock.SetHostname("test") + // ioreg fails, system_profiler returns serial + mock.SetCommand("", "", 1, "ioreg", "-l") + mock.SetCommand("Hardware:\n Serial Number (system): FB123\n", "", 0, "system_profiler", "SPHardwareDataType") + mock.SetCommand("14.0\n", "", 0, "sw_vers", "-productVersion") + + dev := Gather(context.Background(), mock) + if dev.SerialNumber != "FB123" { + t.Errorf("serial: expected FB123, got %s", dev.SerialNumber) + } +} + +func TestGather_EmailIdentity(t *testing.T) { + mock := executor.NewMock() + mock.SetHostname("test") + mock.SetCommand("", "", 1, "ioreg", "-l") + mock.SetCommand("", "", 1, "system_profiler", "SPHardwareDataType") + mock.SetCommand("", "", 1, "sw_vers", "-productVersion") + mock.SetEnv("USER_EMAIL", "dev@example.com") + + dev := Gather(context.Background(), mock) + if dev.UserIdentity != "dev@example.com" { + t.Errorf("identity: expected dev@example.com, got %s", dev.UserIdentity) + } +} diff --git a/internal/executor/executor.go b/internal/executor/executor.go new file mode 100644 index 0000000..49d7248 --- /dev/null +++ b/internal/executor/executor.go @@ -0,0 +1,150 @@ +package executor + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "os/user" + "path/filepath" + "runtime" + "strings" + "time" +) + +// Executor defines the interface for all OS interactions. +// Every detector depends on this interface, enabling full unit-test coverage via mocks. +type Executor interface { + // Run executes a command and returns stdout, stderr, and exit code. + Run(ctx context.Context, name string, args ...string) (stdout, stderr string, exitCode int, err error) + // RunWithTimeout executes a command with a timeout. + RunWithTimeout(ctx context.Context, timeout time.Duration, name string, args ...string) (stdout, stderr string, exitCode int, err error) + // RunAsUser runs a shell command as a specific user (for root -> user delegation). + RunAsUser(ctx context.Context, username, command string) (string, error) + // LookPath searches for an executable in PATH. + LookPath(name string) (string, error) + // FileExists checks if a file exists and is not a directory. + FileExists(path string) bool + // DirExists checks if a directory exists. + DirExists(path string) bool + // ReadFile reads a file's contents. + ReadFile(path string) ([]byte, error) + // ReadDir lists directory entries. + ReadDir(path string) ([]os.DirEntry, error) + // Stat returns file info. + Stat(path string) (os.FileInfo, error) + // Hostname returns the system hostname. + Hostname() (string, error) + // Getenv reads an environment variable. + Getenv(key string) string + // IsRoot returns true if the process is running as root. + IsRoot() bool + // CurrentUser returns the current OS user. + CurrentUser() (*user.User, error) + // HomeDir returns the home directory for a given username. + HomeDir(username string) (string, error) + // Glob returns filenames matching a pattern. + Glob(pattern string) ([]string, error) + // GOOS returns the runtime operating system. + GOOS() string +} + +// Real implements Executor using actual OS calls. +type Real struct{} + +func NewReal() *Real { return &Real{} } + +func (r *Real) Run(ctx context.Context, name string, args ...string) (string, string, int, error) { + cmd := exec.CommandContext(ctx, name, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + exitCode := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + return stdout.String(), stderr.String(), -1, err + } + } + return stdout.String(), stderr.String(), exitCode, nil +} + +func (r *Real) RunWithTimeout(ctx context.Context, timeout time.Duration, name string, args ...string) (string, string, int, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + stdout, stderr, code, err := r.Run(ctx, name, args...) + if ctx.Err() == context.DeadlineExceeded { + return stdout, stderr, 124, fmt.Errorf("command timed out after %s", timeout) + } + return stdout, stderr, code, err +} + +func (r *Real) RunAsUser(ctx context.Context, username, command string) (string, error) { + if !r.IsRoot() { + stdout, _, _, err := r.Run(ctx, "bash", "-c", command) + return strings.TrimSpace(stdout), err + } + stdout, _, _, err := r.Run(ctx, "sudo", "-H", "-u", username, "bash", "-l", "-c", command) + return strings.TrimSpace(stdout), err +} + +func (r *Real) LookPath(name string) (string, error) { + return exec.LookPath(name) +} + +func (r *Real) FileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func (r *Real) DirExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +func (r *Real) ReadFile(path string) ([]byte, error) { + return os.ReadFile(path) +} + +func (r *Real) ReadDir(path string) ([]os.DirEntry, error) { + return os.ReadDir(path) +} + +func (r *Real) Stat(path string) (os.FileInfo, error) { + return os.Stat(path) +} + +func (r *Real) Hostname() (string, error) { + return os.Hostname() +} + +func (r *Real) Getenv(key string) string { + return os.Getenv(key) +} + +func (r *Real) IsRoot() bool { + return os.Getuid() == 0 +} + +func (r *Real) CurrentUser() (*user.User, error) { + return user.Current() +} + +func (r *Real) HomeDir(username string) (string, error) { + u, err := user.Lookup(username) + if err != nil { + return "", err + } + return u.HomeDir, nil +} + +func (r *Real) Glob(pattern string) ([]string, error) { + return filepath.Glob(pattern) +} + +func (r *Real) GOOS() string { + return runtime.GOOS +} diff --git a/internal/executor/mock.go b/internal/executor/mock.go new file mode 100644 index 0000000..b1ab5df --- /dev/null +++ b/internal/executor/mock.go @@ -0,0 +1,288 @@ +package executor + +import ( + "context" + "fmt" + "os" + "os/user" + "path/filepath" + "sync" + "time" +) + +// Mock implements Executor for unit testing. +type Mock struct { + mu sync.RWMutex + + // Command results: key is "name arg1 arg2 ..." + commands map[string]cmdResult + + // File system stubs + files map[string][]byte + dirs map[string]bool + dirEnts map[string][]os.DirEntry + fileInfos map[string]os.FileInfo + + // Path lookup stubs + paths map[string]string + + // Environment + env map[string]string + hostname string + isRoot bool + username string + homeDir string + goos string + + // Glob stubs + globs map[string][]string +} + +type cmdResult struct { + Stdout string + Stderr string + ExitCode int + Err error +} + +func NewMock() *Mock { + return &Mock{ + commands: make(map[string]cmdResult), + files: make(map[string][]byte), + dirs: make(map[string]bool), + dirEnts: make(map[string][]os.DirEntry), + fileInfos: make(map[string]os.FileInfo), + paths: make(map[string]string), + env: make(map[string]string), + globs: make(map[string][]string), + hostname: "test-host", + username: "testuser", + homeDir: "/Users/testuser", + goos: "darwin", + } +} + +// --- Stub setters --- + +func (m *Mock) SetCommand(stdout, stderr string, exitCode int, name string, args ...string) { + m.mu.Lock() + defer m.mu.Unlock() + key := cmdKey(name, args...) + m.commands[key] = cmdResult{Stdout: stdout, Stderr: stderr, ExitCode: exitCode} +} + +func (m *Mock) SetCommandError(err error, name string, args ...string) { + m.mu.Lock() + defer m.mu.Unlock() + key := cmdKey(name, args...) + m.commands[key] = cmdResult{Err: err} +} + +func (m *Mock) SetFile(path string, content []byte) { + m.mu.Lock() + defer m.mu.Unlock() + m.files[path] = content +} + +func (m *Mock) SetDir(path string) { + m.mu.Lock() + defer m.mu.Unlock() + m.dirs[path] = true +} + +func (m *Mock) SetDirEntries(path string, entries []os.DirEntry) { + m.mu.Lock() + defer m.mu.Unlock() + m.dirEnts[path] = entries + m.dirs[path] = true +} + +func (m *Mock) SetFileInfo(path string, info os.FileInfo) { + m.mu.Lock() + defer m.mu.Unlock() + m.fileInfos[path] = info +} + +func (m *Mock) SetPath(name, path string) { + m.mu.Lock() + defer m.mu.Unlock() + m.paths[name] = path +} + +func (m *Mock) SetEnv(key, value string) { + m.mu.Lock() + defer m.mu.Unlock() + m.env[key] = value +} + +func (m *Mock) SetHostname(h string) { + m.mu.Lock() + defer m.mu.Unlock() + m.hostname = h +} + +func (m *Mock) SetIsRoot(v bool) { + m.mu.Lock() + defer m.mu.Unlock() + m.isRoot = v +} + +func (m *Mock) SetUsername(u string) { + m.mu.Lock() + defer m.mu.Unlock() + m.username = u +} + +func (m *Mock) SetHomeDir(h string) { + m.mu.Lock() + defer m.mu.Unlock() + m.homeDir = h +} + +func (m *Mock) SetGlob(pattern string, matches []string) { + m.mu.Lock() + defer m.mu.Unlock() + m.globs[pattern] = matches +} + +// --- Executor interface --- + +func (m *Mock) Run(_ context.Context, name string, args ...string) (string, string, int, error) { + m.mu.RLock() + defer m.mu.RUnlock() + key := cmdKey(name, args...) + if r, ok := m.commands[key]; ok { + return r.Stdout, r.Stderr, r.ExitCode, r.Err + } + return "", "", -1, fmt.Errorf("mock: no command stub for %q", key) +} + +func (m *Mock) RunWithTimeout(ctx context.Context, _ time.Duration, name string, args ...string) (string, string, int, error) { + return m.Run(ctx, name, args...) +} + +func (m *Mock) RunAsUser(ctx context.Context, _ string, command string) (string, error) { + stdout, _, _, err := m.Run(ctx, "bash", "-c", command) + return stdout, err +} + +func (m *Mock) LookPath(name string) (string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if p, ok := m.paths[name]; ok { + return p, nil + } + return "", fmt.Errorf("mock: %q not found in PATH", name) +} + +func (m *Mock) FileExists(path string) bool { + m.mu.RLock() + defer m.mu.RUnlock() + _, ok := m.files[path] + return ok +} + +func (m *Mock) DirExists(path string) bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.dirs[path] +} + +func (m *Mock) ReadFile(path string) ([]byte, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if data, ok := m.files[path]; ok { + return data, nil + } + return nil, fmt.Errorf("mock: file not found: %s", path) +} + +func (m *Mock) ReadDir(path string) ([]os.DirEntry, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if ents, ok := m.dirEnts[path]; ok { + return ents, nil + } + return nil, fmt.Errorf("mock: directory not found: %s", path) +} + +func (m *Mock) Stat(path string) (os.FileInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if info, ok := m.fileInfos[path]; ok { + return info, nil + } + // Fall back: check files map + if _, ok := m.files[path]; ok { + return &mockFileInfo{name: filepath.Base(path), size: int64(len(m.files[path]))}, nil + } + return nil, fmt.Errorf("mock: stat: %s not found", path) +} + +func (m *Mock) Hostname() (string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + return m.hostname, nil +} + +func (m *Mock) Getenv(key string) string { + m.mu.RLock() + defer m.mu.RUnlock() + return m.env[key] +} + +func (m *Mock) IsRoot() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.isRoot +} + +func (m *Mock) CurrentUser() (*user.User, error) { + m.mu.RLock() + defer m.mu.RUnlock() + return &user.User{ + Username: m.username, + HomeDir: m.homeDir, + }, nil +} + +func (m *Mock) HomeDir(_ string) (string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + return m.homeDir, nil +} + +func (m *Mock) Glob(pattern string) ([]string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if matches, ok := m.globs[pattern]; ok { + return matches, nil + } + return nil, nil +} + +func (m *Mock) GOOS() string { + m.mu.RLock() + defer m.mu.RUnlock() + return m.goos +} + +// --- helpers --- + +func cmdKey(name string, args ...string) string { + parts := append([]string{name}, args...) + return fmt.Sprintf("%v", parts) +} + +type mockFileInfo struct { + name string + size int64 + dir bool +} + +func (fi *mockFileInfo) Name() string { return fi.name } +func (fi *mockFileInfo) Size() int64 { return fi.size } +func (fi *mockFileInfo) IsDir() bool { return fi.dir } +func (fi *mockFileInfo) ModTime() time.Time { return time.Time{} } +func (fi *mockFileInfo) Mode() os.FileMode { return 0o644 } +func (fi *mockFileInfo) Sys() any { return nil } diff --git a/internal/launchd/launchd.go b/internal/launchd/launchd.go new file mode 100644 index 0000000..e173f4f --- /dev/null +++ b/internal/launchd/launchd.go @@ -0,0 +1,189 @@ +package launchd + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + "text/template" + + "github.com/step-security/dev-machine-guard/internal/config" + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/progress" +) + +const ( + label = "com.stepsecurity.agent" + daemonPlistPath = "/Library/LaunchDaemons/com.stepsecurity.agent.plist" + systemLogDir = "/var/log/stepsecurity" +) + +func agentPlistPath() string { + homeDir, _ := os.UserHomeDir() + return homeDir + "/Library/LaunchAgents/com.stepsecurity.agent.plist" +} + +// Install configures launchd for periodic scanning. If already installed, upgrades. +func Install(exec executor.Executor, log *progress.Logger) error { + ctx := context.Background() + + // Check for existing installation and upgrade + if isConfigured(ctx, exec) { + log.Progress("Existing agent installation detected. Upgrading...") + if err := doUninstall(ctx, exec, log); err != nil { + log.Progress("Warning: failed to remove previous installation: %v", err) + } + log.Progress("Previous installation removed. Installing new version...") + } + + binaryPath, err := os.Executable() + if err != nil { + return fmt.Errorf("determining binary path: %w", err) + } + + hours, _ := strconv.Atoi(config.ScanFrequencyHours) + if hours <= 0 { + hours = 4 + } + intervalSeconds := hours * 3600 + + plistPath := daemonPlistPath + logDir := systemLogDir + + if !exec.IsRoot() { + plistPath = agentPlistPath() + homeDir, _ := os.UserHomeDir() + logDir = homeDir + "/.stepsecurity" + } + + // Ensure directories exist + if err := os.MkdirAll(logDir, 0o755); err != nil { + return fmt.Errorf("creating log directory: %w", err) + } + if !exec.IsRoot() { + homeDir, _ := os.UserHomeDir() + if err := os.MkdirAll(homeDir+"/Library/LaunchAgents", 0o755); err != nil { + return fmt.Errorf("creating LaunchAgents directory: %w", err) + } + } + + // Generate plist + plistData := plistTemplateData{ + Label: label, + BinaryPath: binaryPath, + IntervalSeconds: intervalSeconds, + LogDir: logDir, + } + + f, err := os.Create(plistPath) + if err != nil { + return fmt.Errorf("creating plist file: %w", err) + } + defer f.Close() + + tmpl, err := template.New("plist").Parse(plistTmpl) + if err != nil { + return fmt.Errorf("parsing plist template: %w", err) + } + if err := tmpl.Execute(f, plistData); err != nil { + return fmt.Errorf("writing plist: %w", err) + } + + if exec.IsRoot() { + _ = os.Chmod(plistPath, 0o644) + } + + // Load plist + _, _, exitCode, err := exec.Run(ctx, "launchctl", "load", plistPath) + if err != nil || exitCode != 0 { + return fmt.Errorf("failed to load launchd configuration") + } + + log.Progress("launchd configuration completed successfully") + log.Progress(" Plist: %s", plistPath) + log.Progress(" Logs: %s/agent.log", logDir) + log.Progress("Installation complete!") + log.Progress("The agent will now run automatically every %d hours", hours) + + return nil +} + +// Uninstall removes the launchd configuration. +func Uninstall(exec executor.Executor, log *progress.Logger) error { + ctx := context.Background() + + if !isConfigured(ctx, exec) { + log.Progress("Agent is not currently configured for periodic execution") + return nil + } + + return doUninstall(ctx, exec, log) +} + +func doUninstall(ctx context.Context, exec executor.Executor, log *progress.Logger) error { + plistPath := daemonPlistPath + if !exec.IsRoot() { + plistPath = agentPlistPath() + } + + // Unload + stdout, _, _, _ := exec.Run(ctx, "launchctl", "list") + if strings.Contains(stdout, label) { + _, _, _, _ = exec.Run(ctx, "launchctl", "unload", plistPath) + log.Progress("Unloaded launchd agent") + } + + // Remove plist + if exec.FileExists(plistPath) { + os.Remove(plistPath) + log.Progress("Removed plist file: %s", plistPath) + } + + log.Progress("launchd configuration removed successfully") + return nil +} + +func isConfigured(ctx context.Context, exec executor.Executor) bool { + plistPath := daemonPlistPath + if !exec.IsRoot() { + plistPath = agentPlistPath() + } + + if !exec.FileExists(plistPath) { + return false + } + + stdout, _, _, _ := exec.Run(ctx, "launchctl", "list") + return strings.Contains(stdout, label) +} + +type plistTemplateData struct { + Label string + BinaryPath string + IntervalSeconds int + LogDir string +} + +const plistTmpl = ` + + + + Label + {{.Label}} + ProgramArguments + + {{.BinaryPath}} + send-telemetry + + StartInterval + {{.IntervalSeconds}} + RunAtLoad + + StandardOutPath + {{.LogDir}}/agent.log + StandardErrorPath + {{.LogDir}}/agent.error.log + + +` diff --git a/internal/lock/lock.go b/internal/lock/lock.go new file mode 100644 index 0000000..9654605 --- /dev/null +++ b/internal/lock/lock.go @@ -0,0 +1,76 @@ +package lock + +import ( + "errors" + "fmt" + "os" + "strconv" + "strings" + "syscall" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +const lockFilePath = "/tmp/stepsecurity-dev-machine-guard.lock" + +// Lock represents an acquired instance lock. +type Lock struct { + path string +} + +// Acquire obtains an exclusive instance lock using atomic file creation. +// Returns error if another instance is running. +func Acquire(_ executor.Executor) (*Lock, error) { + // Check for existing lock file + if data, err := os.ReadFile(lockFilePath); err == nil { + pidStr := strings.TrimSpace(string(data)) + if pid, err := strconv.Atoi(pidStr); err == nil && pid > 0 { + if isProcessAlive(pid) { + return nil, fmt.Errorf("another instance is already running (PID %d)", pid) + } + } + // Stale lock — remove before attempting atomic create + _ = os.Remove(lockFilePath) + } + + // Atomic create: O_CREATE|O_EXCL fails if file already exists, + // preventing two processes from both creating the lock. + f, err := os.OpenFile(lockFilePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + if errors.Is(err, os.ErrExist) { + // Another process created the lock between our check and create + return nil, fmt.Errorf("another instance is already running (lock file created concurrently)") + } + return nil, fmt.Errorf("creating lock file: %w", err) + } + + _, err = fmt.Fprintf(f, "%d", os.Getpid()) + f.Close() + if err != nil { + _ = os.Remove(lockFilePath) + return nil, fmt.Errorf("writing PID to lock file: %w", err) + } + + return &Lock{path: lockFilePath}, nil +} + +// Release removes the lock file. +func (l *Lock) Release() { + if l != nil && l.path != "" { + _ = os.Remove(l.path) + } +} + +// isProcessAlive checks if a process with the given PID exists. +// Returns true if the process is alive (signal 0 succeeds or returns EPERM). +func isProcessAlive(pid int) bool { + err := syscall.Kill(pid, 0) + if err == nil { + return true // process exists and we can signal it + } + // EPERM means the process exists but we don't have permission to signal it + if errors.Is(err, syscall.EPERM) { + return true + } + return false // ESRCH or other error — process doesn't exist +} diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..066a1ab --- /dev/null +++ b/internal/model/model.go @@ -0,0 +1,98 @@ +package model + +// ScanResult is the community-mode JSON output structure. +type ScanResult struct { + AgentVersion string `json:"agent_version"` + AgentURL string `json:"agent_url"` + ScanTimestamp int64 `json:"scan_timestamp"` + ScanTimestampISO string `json:"scan_timestamp_iso"` + Device Device `json:"device"` + AIAgentsAndTools []AITool `json:"ai_agents_and_tools"` + IDEInstallations []IDE `json:"ide_installations"` + IDEExtensions []Extension `json:"ide_extensions"` + MCPConfigs []MCPConfig `json:"mcp_configs"` + NodePkgManagers []PkgManager `json:"node_package_managers"` + NodePackages []any `json:"node_packages"` + Summary Summary `json:"summary"` +} + +type Device struct { + Hostname string `json:"hostname"` + SerialNumber string `json:"serial_number"` + OSVersion string `json:"os_version"` + Platform string `json:"platform"` + UserIdentity string `json:"user_identity"` +} + +// AITool represents a detected AI agent, CLI tool, framework, or general agent. +// Fields are conditionally present based on type (cli_tool, general_agent, framework). +type AITool struct { + Name string `json:"name"` + Vendor string `json:"vendor"` + Type string `json:"type"` + Version string `json:"version"` + BinaryPath string `json:"binary_path,omitempty"` + InstallPath string `json:"install_path,omitempty"` + ConfigDir string `json:"config_dir,omitempty"` + IsRunning *bool `json:"is_running,omitempty"` +} + +type IDE struct { + IDEType string `json:"ide_type"` + Version string `json:"version"` + InstallPath string `json:"install_path"` + Vendor string `json:"vendor"` + IsInstalled bool `json:"is_installed"` +} + +type Extension struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Publisher string `json:"publisher"` + InstallDate int64 `json:"install_date"` + IDEType string `json:"ide_type"` +} + +// MCPConfig represents a detected MCP server configuration (community mode). +type MCPConfig struct { + ConfigSource string `json:"config_source"` + ConfigPath string `json:"config_path"` + Vendor string `json:"vendor"` +} + +// MCPConfigEnterprise includes base64-encoded content for enterprise mode. +type MCPConfigEnterprise struct { + ConfigSource string `json:"config_source"` + ConfigPath string `json:"config_path"` + Vendor string `json:"vendor"` + ConfigContentBase64 string `json:"config_content_base64,omitempty"` +} + +type PkgManager struct { + Name string `json:"name"` + Version string `json:"version"` + Path string `json:"path"` +} + +type Summary struct { + AIAgentsAndToolsCount int `json:"ai_agents_and_tools_count"` + IDEInstallationsCount int `json:"ide_installations_count"` + IDEExtensionsCount int `json:"ide_extensions_count"` + MCPConfigsCount int `json:"mcp_configs_count"` + NodeProjectsCount int `json:"node_projects_count"` +} + +// NodeScanResult holds raw scan output for enterprise telemetry. +// Used for both global packages and per-project scans. +type NodeScanResult struct { + ProjectPath string `json:"project_path"` + PackageManager string `json:"package_manager"` + PMVersion string `json:"package_manager_version"` + WorkingDirectory string `json:"working_directory"` + RawStdoutBase64 string `json:"raw_stdout_base64"` + RawStderrBase64 string `json:"raw_stderr_base64"` + Error string `json:"error"` + ExitCode int `json:"exit_code"` + ScanDurationMs int64 `json:"scan_duration_ms"` +} diff --git a/internal/output/html.go b/internal/output/html.go new file mode 100644 index 0000000..4c8afc0 --- /dev/null +++ b/internal/output/html.go @@ -0,0 +1,226 @@ +package output + +import ( + "fmt" + "html/template" + "os" + "time" + + "github.com/step-security/dev-machine-guard/internal/buildinfo" + "github.com/step-security/dev-machine-guard/internal/model" +) + +type htmlData struct { + ScanTime string + Version string + Device model.Device + AITools []model.AITool + IDEInstallations []model.IDE + IDEExtensions []model.Extension + MCPConfigs []model.MCPConfig + NodePkgManagers []model.PkgManager + Summary model.Summary +} + +func typeLabel(t string) string { + switch t { + case "cli_tool": + return "CLI Tool" + case "general_agent": + return "Agent" + case "framework": + return "Framework" + default: + return t + } +} + +// HTML generates a self-contained HTML report file. +func HTML(outputFile string, result *model.ScanResult) error { + f, err := os.Create(outputFile) + if err != nil { + return fmt.Errorf("creating HTML file: %w", err) + } + defer f.Close() + + scanTime := time.Unix(result.ScanTimestamp, 0).Format("2006-01-02 15:04:05") + + data := htmlData{ + ScanTime: scanTime, + Version: buildinfo.Version, + Device: result.Device, + AITools: result.AIAgentsAndTools, + IDEInstallations: result.IDEInstallations, + IDEExtensions: result.IDEExtensions, + MCPConfigs: result.MCPConfigs, + NodePkgManagers: result.NodePkgManagers, + Summary: result.Summary, + } + + funcMap := template.FuncMap{ + "ideDisplayName": ideDisplayName, + "typeLabel": typeLabel, + } + + tmpl, err := template.New("report").Funcs(funcMap).Parse(htmlTemplate) + if err != nil { + return fmt.Errorf("parsing HTML template: %w", err) + } + + return tmpl.Execute(f, data) +} + +const htmlTemplate = ` + + + + +StepSecurity Dev Machine Guard Report + + + +
+

StepSecurity Dev Machine Guard Report

+

Developer Environment Security Scanner

+
+
+ +

Scanned at {{.ScanTime}} · Agent v{{.Version}}

+ +
+
{{.Summary.AIAgentsAndToolsCount}}
AI Agents and Tools
+
{{.Summary.IDEInstallationsCount}}
IDEs & Desktop Apps
+
{{.Summary.IDEExtensionsCount}}
IDE Extensions
+
{{.Summary.MCPConfigsCount}}
MCP Servers
+
{{.Summary.NodeProjectsCount}}
Node.js Projects
+
+ +
+
Hostname{{.Device.Hostname}}
+
Serial{{.Device.SerialNumber}}
+
macOS{{.Device.OSVersion}}
+
User{{.Device.UserIdentity}}
+
+ +
+

AI Agents and Tools {{.Summary.AIAgentsAndToolsCount}}

+ + + {{if .AITools}}{{range .AITools}} + {{end}}{{else}}{{end}} +
NameVersionTypeVendor
{{.Name}}{{.Version}}{{typeLabel .Type}}{{.Vendor}}
None detected
+
+ +
+

IDE & AI Desktop Apps {{.Summary.IDEInstallationsCount}}

+ + + {{if .IDEInstallations}}{{range .IDEInstallations}} + {{end}}{{else}}{{end}} +
NameVersionVendorPath
{{ideDisplayName .IDEType}}{{.Version}}{{.Vendor}}{{.InstallPath}}
None detected
+
+ +
+

MCP Servers {{.Summary.MCPConfigsCount}}

+ + + {{if .MCPConfigs}}{{range .MCPConfigs}} + {{end}}{{else}}{{end}} +
SourceVendor
{{.ConfigSource}}{{.Vendor}}
None detected
+
+ +
+

IDE Extensions {{.Summary.IDEExtensionsCount}}

+ + + {{if .IDEExtensions}}{{range .IDEExtensions}} + {{end}}{{else}}{{end}} +
Extension IDVersionPublisherIDE
{{.ID}}{{.Version}}{{.Publisher}}{{.IDEType}}
None detected
+
+ +
+

Node.js Packages

+ + + {{if .NodePkgManagers}}{{range .NodePkgManagers}} + {{end}}{{else}}{{end}} +
Package ManagerVersionPath
{{.Name}}{{.Version}}{{.Path}}
No packages found (use --enable-npm-scan)
+
+ +
+ + +` diff --git a/internal/output/html_test.go b/internal/output/html_test.go new file mode 100644 index 0000000..9098a92 --- /dev/null +++ b/internal/output/html_test.go @@ -0,0 +1,86 @@ +package output + +import ( + "os" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/model" +) + +func TestHTML_GeneratesFile(t *testing.T) { + tmpFile := os.TempDir() + "/test-dmg-report.html" + defer os.Remove(tmpFile) + + result := &model.ScanResult{ + AgentVersion: "1.9.0", + ScanTimestamp: 1700000000, + ScanTimestampISO: "2023-11-14T22:13:20Z", + Device: model.Device{ + Hostname: "test-host", + SerialNumber: "ABC123", + OSVersion: "14.1", + Platform: "darwin", + UserIdentity: "testuser", + }, + AIAgentsAndTools: []model.AITool{}, + IDEInstallations: []model.IDE{}, + IDEExtensions: []model.Extension{}, + MCPConfigs: []model.MCPConfig{}, + NodePkgManagers: []model.PkgManager{}, + NodePackages: []any{}, + Summary: model.Summary{}, + } + + if err := HTML(tmpFile, result); err != nil { + t.Fatal(err) + } + + content, err := os.ReadFile(tmpFile) + if err != nil { + t.Fatal(err) + } + + html := string(content) + if !strings.Contains(html, "") { + t.Error("missing tag") + } + if !strings.Contains(html, "StepSecurity") { + t.Error("missing StepSecurity title") + } +} + +func TestHTML_ContainsData(t *testing.T) { + tmpFile := os.TempDir() + "/test-dmg-data.html" + defer os.Remove(tmpFile) + + result := &model.ScanResult{ + ScanTimestamp: 1700000000, + Device: model.Device{ + Hostname: "my-host", + }, + AIAgentsAndTools: []model.AITool{ + {Name: "claude-code", Vendor: "Anthropic", Type: "cli_tool", Version: "1.0"}, + }, + IDEInstallations: []model.IDE{}, + IDEExtensions: []model.Extension{}, + MCPConfigs: []model.MCPConfig{}, + NodePkgManagers: []model.PkgManager{}, + NodePackages: []any{}, + Summary: model.Summary{AIAgentsAndToolsCount: 1}, + } + + _ = HTML(tmpFile, result) + content, _ := os.ReadFile(tmpFile) + html := string(content) + + if !strings.Contains(html, "claude-code") { + t.Error("missing AI tool name") + } + if !strings.Contains(html, "my-host") { + t.Error("missing hostname") + } +} diff --git a/internal/output/json.go b/internal/output/json.go new file mode 100644 index 0000000..cbe03f2 --- /dev/null +++ b/internal/output/json.go @@ -0,0 +1,16 @@ +package output + +import ( + "encoding/json" + "io" + + "github.com/step-security/dev-machine-guard/internal/model" +) + +// JSON writes the scan result as formatted JSON to the given writer. +func JSON(w io.Writer, result *model.ScanResult) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + return enc.Encode(result) +} diff --git a/internal/output/json_test.go b/internal/output/json_test.go new file mode 100644 index 0000000..f7cd061 --- /dev/null +++ b/internal/output/json_test.go @@ -0,0 +1,180 @@ +package output + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/step-security/dev-machine-guard/internal/model" +) + +func TestJSON_ValidOutput(t *testing.T) { + result := &model.ScanResult{ + AgentVersion: "1.9.0", + AgentURL: "https://github.com/step-security/dev-machine-guard", + ScanTimestamp: 1700000000, + ScanTimestampISO: "2023-11-14T22:13:20Z", + Device: model.Device{ + Hostname: "test-host", + SerialNumber: "ABC123", + OSVersion: "14.1", + Platform: "darwin", + UserIdentity: "testuser", + }, + AIAgentsAndTools: []model.AITool{}, + IDEInstallations: []model.IDE{}, + IDEExtensions: []model.Extension{}, + MCPConfigs: []model.MCPConfig{}, + NodePkgManagers: []model.PkgManager{}, + NodePackages: []any{}, + Summary: model.Summary{}, + } + + var buf bytes.Buffer + if err := JSON(&buf, result); err != nil { + t.Fatal(err) + } + + // Validate it's valid JSON + var parsed map[string]any + if err := json.Unmarshal(buf.Bytes(), &parsed); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + // Check required top-level keys + requiredKeys := []string{ + "agent_version", "agent_url", "scan_timestamp", "scan_timestamp_iso", + "device", "ai_agents_and_tools", "ide_installations", + "ide_extensions", "mcp_configs", "summary", + } + for _, key := range requiredKeys { + if _, ok := parsed[key]; !ok { + t.Errorf("missing required key: %s", key) + } + } +} + +func TestJSON_DeviceFields(t *testing.T) { + result := &model.ScanResult{ + Device: model.Device{ + Hostname: "my-host", + SerialNumber: "SN123", + OSVersion: "15.0", + Platform: "darwin", + UserIdentity: "dev", + }, + AIAgentsAndTools: []model.AITool{}, + IDEInstallations: []model.IDE{}, + IDEExtensions: []model.Extension{}, + MCPConfigs: []model.MCPConfig{}, + NodePkgManagers: []model.PkgManager{}, + NodePackages: []any{}, + } + + var buf bytes.Buffer + if err := JSON(&buf, result); err != nil { + t.Fatal(err) + } + + var parsed map[string]any + if err := json.Unmarshal(buf.Bytes(), &parsed); err != nil { + t.Fatal(err) + } + + device, ok := parsed["device"].(map[string]any) + if !ok { + t.Fatal("device is not an object") + } + + for _, key := range []string{"hostname", "os_version", "serial_number", "platform", "user_identity"} { + if _, ok := device[key]; !ok { + t.Errorf("missing device field: %s", key) + } + } +} + +func TestJSON_SummaryFields(t *testing.T) { + result := &model.ScanResult{ + AIAgentsAndTools: []model.AITool{}, + IDEInstallations: []model.IDE{}, + IDEExtensions: []model.Extension{}, + MCPConfigs: []model.MCPConfig{}, + NodePkgManagers: []model.PkgManager{}, + NodePackages: []any{}, + Summary: model.Summary{ + AIAgentsAndToolsCount: 5, + IDEInstallationsCount: 3, + IDEExtensionsCount: 10, + MCPConfigsCount: 2, + NodeProjectsCount: 0, + }, + } + + var buf bytes.Buffer + if err := JSON(&buf, result); err != nil { + t.Fatal(err) + } + + var parsed map[string]any + if err := json.Unmarshal(buf.Bytes(), &parsed); err != nil { + t.Fatal(err) + } + + summary, ok := parsed["summary"].(map[string]any) + if !ok { + t.Fatal("summary is not an object") + } + + for _, key := range []string{ + "ai_agents_and_tools_count", "ide_installations_count", + "ide_extensions_count", "mcp_configs_count", "node_projects_count", + } { + v, ok := summary[key] + if !ok { + t.Errorf("missing summary field: %s", key) + continue + } + if _, ok := v.(float64); !ok { + t.Errorf("summary.%s is not numeric", key) + } + } +} + +func TestJSON_AIToolSchema(t *testing.T) { + running := true + result := &model.ScanResult{ + AIAgentsAndTools: []model.AITool{ + {Name: "test-tool", Vendor: "TestVendor", Type: "cli_tool", Version: "1.0"}, + {Name: "test-fw", Vendor: "Unknown", Type: "framework", Version: "2.0", IsRunning: &running}, + }, + IDEInstallations: []model.IDE{}, + IDEExtensions: []model.Extension{}, + MCPConfigs: []model.MCPConfig{}, + NodePkgManagers: []model.PkgManager{}, + NodePackages: []any{}, + Summary: model.Summary{AIAgentsAndToolsCount: 2}, + } + + var buf bytes.Buffer + _ = JSON(&buf, result) + + var parsed map[string]any + _ = json.Unmarshal(buf.Bytes(), &parsed) + + items, ok := parsed["ai_agents_and_tools"].([]any) + if !ok { + t.Fatal("ai_agents_and_tools is not an array") + } + for i, item := range items { + obj, ok := item.(map[string]any) + if !ok { + t.Fatalf("item %d is not an object", i) + } + if _, ok := obj["name"]; !ok { + t.Errorf("item %d missing name", i) + } + if _, ok := obj["type"]; !ok { + t.Errorf("item %d missing type", i) + } + } +} diff --git a/internal/output/pretty.go b/internal/output/pretty.go new file mode 100644 index 0000000..73f6c58 --- /dev/null +++ b/internal/output/pretty.go @@ -0,0 +1,211 @@ +package output + +import ( + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/step-security/dev-machine-guard/internal/buildinfo" + "github.com/step-security/dev-machine-guard/internal/model" +) + +// Pretty writes human-readable formatted output. +func Pretty(w io.Writer, result *model.ScanResult, colorMode string) error { + c := setupColors(colorMode) + + scanTime := time.Unix(result.ScanTimestamp, 0).Format("2006-01-02 15:04:05") + + title := fmt.Sprintf("StepSecurity Dev Machine Guard v%s", buildinfo.Version) + url := buildinfo.AgentURL + boxWidth := 58 + titlePad := boxWidth - 2 - len(title) + urlPad := boxWidth - 2 - len(url) + + // Banner + fmt.Fprintln(w) + fmt.Fprintf(w, " %s┌%s┐%s\n", c.purple, strings.Repeat("─", boxWidth), c.reset) + fmt.Fprintf(w, " %s│%s %s%s%s%*s%s│%s\n", c.purple, c.reset, c.bold, title, c.reset, titlePad, "", c.purple, c.reset) + fmt.Fprintf(w, " %s│%s %s%s%s%*s%s│%s\n", c.purple, c.reset, c.dim, url, c.reset, urlPad, "", c.purple, c.reset) + fmt.Fprintf(w, " %s└%s┘%s\n", c.purple, strings.Repeat("─", boxWidth), c.reset) + fmt.Fprintf(w, " %sScanned at %s%s\n", c.dim, scanTime, c.reset) + fmt.Fprintln(w) + + // DEVICE + fmt.Fprintf(w, " %s%sDEVICE%s\n", c.purple, c.bold, c.reset) + fmt.Fprintf(w, " %-16s %s\n", "Hostname", result.Device.Hostname) + fmt.Fprintf(w, " %-16s %s\n", "Serial", result.Device.SerialNumber) + fmt.Fprintf(w, " %-16s %s\n", "macOS", result.Device.OSVersion) + fmt.Fprintf(w, " %-16s %s\n", "User", result.Device.UserIdentity) + fmt.Fprintln(w) + + // SUMMARY + fmt.Fprintf(w, " %s%sSUMMARY%s\n", c.purple, c.bold, c.reset) + fmt.Fprintf(w, " %-24s %s%d%s\n", "AI Agents and Tools", c.green, result.Summary.AIAgentsAndToolsCount, c.reset) + fmt.Fprintf(w, " %-24s %s%d%s\n", "IDEs & Desktop Apps", c.green, result.Summary.IDEInstallationsCount, c.reset) + fmt.Fprintf(w, " %-24s %s%d%s\n", "IDE Extensions", c.green, result.Summary.IDEExtensionsCount, c.reset) + fmt.Fprintf(w, " %-24s %s%d%s\n", "MCP Servers", c.green, result.Summary.MCPConfigsCount, c.reset) + if len(result.NodePkgManagers) > 0 { + fmt.Fprintf(w, " %-24s %s%d%s\n", "Node.js Projects", c.green, result.Summary.NodeProjectsCount, c.reset) + } + fmt.Fprintln(w) + + // AI AGENTS AND TOOLS + printSectionHeader(w, c, "AI AGENTS AND TOOLS", result.Summary.AIAgentsAndToolsCount) + if len(result.AIAgentsAndTools) > 0 { + for _, t := range result.AIAgentsAndTools { + typeLabel := t.Type + switch t.Type { + case "cli_tool": + typeLabel = "cli" + case "general_agent": + typeLabel = "agent" + case "framework": + typeLabel = "framework" + } + fmt.Fprintf(w, " %-24s %sv%-20s %-12s %s%s\n", + truncate(t.Name, 24), c.dim, truncate(t.Version, 20), "["+typeLabel+"]", t.Vendor, c.reset) + } + } else { + fmt.Fprintf(w, " %sNone detected%s\n", c.dim, c.reset) + } + fmt.Fprintln(w) + + // IDE & AI DESKTOP APPS + printSectionHeader(w, c, "IDE & AI DESKTOP APPS", result.Summary.IDEInstallationsCount) + if len(result.IDEInstallations) > 0 { + for _, ide := range result.IDEInstallations { + displayName := ideDisplayName(ide.IDEType) + fmt.Fprintf(w, " %-24s %sv%-20s %s%s\n", + truncate(displayName, 24), c.dim, truncate(ide.Version, 20), ide.Vendor, c.reset) + } + } else { + fmt.Fprintf(w, " %sNone detected%s\n", c.dim, c.reset) + } + fmt.Fprintln(w) + + // MCP SERVERS + printSectionHeader(w, c, "MCP SERVERS", result.Summary.MCPConfigsCount) + if len(result.MCPConfigs) > 0 { + for _, cfg := range result.MCPConfigs { + fmt.Fprintf(w, " %-24s %s%s%s\n", cfg.ConfigSource, c.dim, cfg.Vendor, c.reset) + } + } else { + fmt.Fprintf(w, " %sNone detected%s\n", c.dim, c.reset) + } + fmt.Fprintln(w) + + // IDE EXTENSIONS + printSectionHeader(w, c, "IDE EXTENSIONS", result.Summary.IDEExtensionsCount) + if len(result.IDEExtensions) > 0 { + // Group by IDE type + groups := make(map[string][]model.Extension) + for _, ext := range result.IDEExtensions { + groups[ext.IDEType] = append(groups[ext.IDEType], ext) + } + for ideType, exts := range groups { + displayType := ideType + switch ideType { + case "vscode": + displayType = "VSCode" + case "openvsx": + displayType = "Cursor" + } + fmt.Fprintf(w, " %s%s%s%s%*s%s%d found%s\n", + c.purple, c.bold, displayType, c.reset, 33-len(displayType), "", c.green, len(exts), c.reset) + for _, ext := range exts { + fmt.Fprintf(w, " %-42s %sv%-14s %s%s\n", + truncate(ext.ID, 42), c.dim, truncate(ext.Version, 14), ext.Publisher, c.reset) + } + } + } else { + fmt.Fprintf(w, " %sNone detected%s\n", c.dim, c.reset) + } + fmt.Fprintln(w) + + // NODE.JS PACKAGE MANAGERS (only if npm scan was enabled) + if len(result.NodePkgManagers) > 0 { + printSectionHeader(w, c, "NODE.JS PACKAGE MANAGERS", len(result.NodePkgManagers)) + for _, pm := range result.NodePkgManagers { + fmt.Fprintf(w, " %-24s %sv%s%s\n", pm.Name, c.dim, pm.Version, c.reset) + } + fmt.Fprintln(w) + + printSectionHeader(w, c, "NODE.JS PROJECTS", result.Summary.NodeProjectsCount) + fmt.Fprintln(w) + } + + return nil +} + +func printSectionHeader(w io.Writer, c *colors, title string, count int) { + padding := 35 - len(title) + if padding < 1 { + padding = 1 + } + fmt.Fprintf(w, " %s%s%s%s%*s%s%d found%s\n", c.purple, c.bold, title, c.reset, padding, "", c.green, count, c.reset) +} + +type colors struct { + purple string + green string + bold string + dim string + reset string +} + +func setupColors(mode string) *colors { + useColors := false + switch mode { + case "always": + useColors = true + case "never": + useColors = false + default: // auto + fi, err := os.Stdout.Stat() + if err == nil && fi.Mode()&os.ModeCharDevice != 0 { + useColors = true + } + } + + if !useColors { + return &colors{} + } + + return &colors{ + purple: "\033[0;35m", + green: "\033[0;32m", + bold: "\033[1m", + dim: "\033[2m", + reset: "\033[0m", + } +} + +func truncate(s string, max int) string { + if len(s) > max { + return s[:max-3] + "..." + } + return s +} + +func ideDisplayName(ideType string) string { + switch ideType { + case "vscode": + return "Visual Studio Code" + case "cursor": + return "Cursor" + case "windsurf": + return "Windsurf" + case "antigravity": + return "Antigravity" + case "zed": + return "Zed" + case "claude_desktop": + return "Claude" + case "microsoft_copilot_desktop": + return "Microsoft Copilot" + default: + return ideType + } +} diff --git a/internal/output/pretty_test.go b/internal/output/pretty_test.go new file mode 100644 index 0000000..2fa656c --- /dev/null +++ b/internal/output/pretty_test.go @@ -0,0 +1,119 @@ +package output + +import ( + "bytes" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/model" +) + +func TestPretty_ContainsHeaders(t *testing.T) { + result := &model.ScanResult{ + AgentVersion: "1.9.0", + ScanTimestamp: 1700000000, + ScanTimestampISO: "2023-11-14T22:13:20Z", + Device: model.Device{ + Hostname: "test-host", + SerialNumber: "ABC123", + OSVersion: "14.1", + Platform: "darwin", + UserIdentity: "testuser", + }, + AIAgentsAndTools: []model.AITool{}, + IDEInstallations: []model.IDE{}, + IDEExtensions: []model.Extension{}, + MCPConfigs: []model.MCPConfig{}, + Summary: model.Summary{}, + } + + var buf bytes.Buffer + _ = Pretty(&buf, result, "never") + + output := buf.String() + for _, header := range []string{"DEVICE", "SUMMARY", "AI AGENTS", "IDE & AI DESKTOP APPS", "MCP SERVERS", "IDE EXTENSIONS"} { + if !strings.Contains(output, header) { + t.Errorf("output missing header: %s", header) + } + } +} + +func TestPretty_ContainsBanner(t *testing.T) { + result := &model.ScanResult{ + AgentVersion: "1.9.0", + ScanTimestamp: 1700000000, + Device: model.Device{Hostname: "test"}, + AIAgentsAndTools: []model.AITool{}, + IDEInstallations: []model.IDE{}, + IDEExtensions: []model.Extension{}, + MCPConfigs: []model.MCPConfig{}, + } + + var buf bytes.Buffer + _ = Pretty(&buf, result, "never") + output := buf.String() + + if !strings.Contains(output, "StepSecurity Dev Machine Guard") { + t.Error("output missing banner title") + } +} + +func TestPretty_ShowsDeviceInfo(t *testing.T) { + result := &model.ScanResult{ + ScanTimestamp: 1700000000, + Device: model.Device{ + Hostname: "my-host", + SerialNumber: "SN123", + OSVersion: "14.1", + UserIdentity: "dev-user", + }, + AIAgentsAndTools: []model.AITool{}, + IDEInstallations: []model.IDE{}, + IDEExtensions: []model.Extension{}, + MCPConfigs: []model.MCPConfig{}, + } + + var buf bytes.Buffer + _ = Pretty(&buf, result, "never") + output := buf.String() + + for _, expected := range []string{"my-host", "SN123", "14.1", "dev-user"} { + if !strings.Contains(output, expected) { + t.Errorf("output missing device info: %s", expected) + } + } +} + +func TestTruncate(t *testing.T) { + tests := []struct { + input string + max int + want string + }{ + {"short", 10, "short"}, + {"a very long string", 10, "a very ..."}, + {"exactly10!", 10, "exactly10!"}, + } + for _, tt := range tests { + got := truncate(tt.input, tt.max) + if got != tt.want { + t.Errorf("truncate(%q, %d) = %q, want %q", tt.input, tt.max, got, tt.want) + } + } +} + +func TestIdeDisplayName(t *testing.T) { + tests := map[string]string{ + "vscode": "Visual Studio Code", + "cursor": "Cursor", + "claude_desktop": "Claude", + "microsoft_copilot_desktop": "Microsoft Copilot", + "unknown": "unknown", + } + for input, expected := range tests { + got := ideDisplayName(input) + if got != expected { + t.Errorf("ideDisplayName(%q) = %q, want %q", input, got, expected) + } + } +} diff --git a/internal/progress/progress.go b/internal/progress/progress.go new file mode 100644 index 0000000..9a04793 --- /dev/null +++ b/internal/progress/progress.go @@ -0,0 +1,133 @@ +package progress + +import ( + "fmt" + "os" + "sync" + "time" +) + +// Logger handles progress output to stderr. +// Logging format matches the shell script: +// [scanning] message — progress (suppressed in quiet mode) +// [error] message — errors (never suppressed) +// ⠋ label... (Xms) — spinner animation +// ✓ label (Xms) — step done +// ○ label (skipped) — step skipped +type Logger struct { + quiet bool + spinner *spinner +} + +// NewLogger creates a Logger. If quiet is true, progress messages are suppressed. +func NewLogger(quiet bool) *Logger { + return &Logger{quiet: quiet} +} + +// NewNoop returns a Logger that suppresses all output. +func NewNoop() *Logger { + return &Logger{quiet: true} +} + +// Progress prints a progress message to stderr (suppressed in quiet mode). +// Format: [scanning] message +func (l *Logger) Progress(format string, args ...any) { + if l.quiet { + return + } + fmt.Fprintf(os.Stderr, "\033[2m[scanning]\033[0m %s\n", fmt.Sprintf(format, args...)) +} + +// Error always prints to stderr regardless of quiet mode. +// Format: [error] message +func (l *Logger) Error(format string, args ...any) { + fmt.Fprintf(os.Stderr, "\033[0;31m[error]\033[0m %s\n", fmt.Sprintf(format, args...)) +} + +// StepStart begins a labeled progress step with a spinner. +func (l *Logger) StepStart(label string) { + if l.quiet { + return + } + l.spinner = newSpinner(label) + l.spinner.run() +} + +// StepDone completes the current step, showing elapsed time. +func (l *Logger) StepDone(elapsed time.Duration) { + if l.quiet || l.spinner == nil { + return + } + l.spinner.stopDone(elapsed) + l.spinner = nil +} + +// StepSkip marks the current step as skipped. +func (l *Logger) StepSkip(reason string) { + if l.quiet || l.spinner == nil { + return + } + l.spinner.stopSkip(reason) + l.spinner = nil +} + +// spinner renders an animated progress indicator on stderr. +type spinner struct { + label string + startedAt time.Time + stopCh chan stopMsg + wg sync.WaitGroup +} + +type stopMsg struct { + kind string // "done" or "skip" + reason string + elapsed time.Duration +} + +var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +func newSpinner(label string) *spinner { + return &spinner{ + label: label, + startedAt: time.Now(), + stopCh: make(chan stopMsg, 1), + } +} + +func (s *spinner) run() { + s.wg.Add(1) + go func() { + defer s.wg.Done() + i := 0 + ticker := time.NewTicker(120 * time.Millisecond) + defer ticker.Stop() + for { + select { + case msg := <-s.stopCh: + switch msg.kind { + case "done": + ms := msg.elapsed.Milliseconds() + fmt.Fprintf(os.Stderr, "\r ✓ %s (%dms)\033[K\n", s.label, ms) + case "skip": + fmt.Fprintf(os.Stderr, "\r ○ %s (skipped)\033[K\n", s.label) + } + return + case <-ticker.C: + ms := time.Since(s.startedAt).Milliseconds() + fmt.Fprintf(os.Stderr, "\r %s %s... (%dms)\033[K", spinnerFrames[i%len(spinnerFrames)], s.label, ms) + i++ + } + } + }() +} + +func (s *spinner) stopDone(elapsed time.Duration) { + s.stopCh <- stopMsg{kind: "done", elapsed: elapsed} + s.wg.Wait() +} + +func (s *spinner) stopSkip(reason string) { + s.stopCh <- stopMsg{kind: "skip", reason: reason} + s.wg.Wait() +} diff --git a/internal/scan/scanner.go b/internal/scan/scanner.go new file mode 100644 index 0000000..a7183c8 --- /dev/null +++ b/internal/scan/scanner.go @@ -0,0 +1,166 @@ +package scan + +import ( + "context" + "os" + "time" + + "github.com/step-security/dev-machine-guard/internal/buildinfo" + "github.com/step-security/dev-machine-guard/internal/cli" + "github.com/step-security/dev-machine-guard/internal/detector" + "github.com/step-security/dev-machine-guard/internal/device" + "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/output" + "github.com/step-security/dev-machine-guard/internal/progress" +) + +// Run executes a community-mode scan and outputs results. +func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { + ctx := context.Background() + + // Resolve search directories + searchDirs := resolveSearchDirs(exec, cfg.SearchDirs) + + // Gather device info + log.StepStart("Gathering device information") + start := time.Now() + dev := device.Gather(ctx, exec) + log.StepDone(time.Since(start)) + + // Detect IDE installations + log.StepStart("Detecting IDE installations") + start = time.Now() + ideDetector := detector.NewIDEDetector(exec) + ides := ideDetector.Detect(ctx) + log.StepDone(time.Since(start)) + + // Detect AI agents and tools + log.StepStart("Detecting AI agents and tools") + start = time.Now() + cliDetector := detector.NewAICLIDetector(exec) + cliTools := cliDetector.Detect(ctx) + agentDetector := detector.NewAgentDetector(exec) + agents := agentDetector.Detect(ctx, searchDirs) + fwDetector := detector.NewFrameworkDetector(exec) + frameworks := fwDetector.Detect(ctx) + aiTools := mergeAITools(cliTools, agents, frameworks) + log.StepDone(time.Since(start)) + + // Collect MCP configurations + log.StepStart("Collecting MCP configurations") + start = time.Now() + mcpDetector := detector.NewMCPDetector(exec) + mcpConfigs := mcpDetector.Detect(ctx, dev.UserIdentity, false) + log.StepDone(time.Since(start)) + + // Collect IDE extensions + log.StepStart("Collecting IDE extensions") + start = time.Now() + extDetector := detector.NewExtensionDetector(exec) + extensions := extDetector.Detect(ctx, searchDirs) + log.StepDone(time.Since(start)) + + // Node.js scanning (community mode defaults to off, explicit flag overrides) + npmEnabled := false + if cfg.EnableNPMScan != nil { + npmEnabled = *cfg.EnableNPMScan + } + // auto: disabled in community mode + + var pkgManagers []model.PkgManager + nodeProjectsCount := 0 + + if npmEnabled { + log.StepStart("Detecting package managers") + start = time.Now() + npmDetector := detector.NewNodePMDetector(exec) + pkgManagers = npmDetector.DetectManagers(ctx) + log.StepDone(time.Since(start)) + + log.StepStart("Scanning Node.js projects") + start = time.Now() + projectDetector := detector.NewNodeProjectDetector(exec) + nodeProjectsCount = projectDetector.CountProjects(ctx, searchDirs) + log.StepDone(time.Since(start)) + } else { + log.StepStart("Node.js package scanning") + log.StepSkip("disabled (use --enable-npm-scan to enable)") + } + + // Ensure no nil slices (JSON must emit [] not null) + if aiTools == nil { + aiTools = []model.AITool{} + } + if ides == nil { + ides = []model.IDE{} + } + if extensions == nil { + extensions = []model.Extension{} + } + if pkgManagers == nil { + pkgManagers = []model.PkgManager{} + } + + // Build result + now := time.Now() + result := &model.ScanResult{ + AgentVersion: buildinfo.Version, + AgentURL: buildinfo.AgentURL, + ScanTimestamp: now.Unix(), + ScanTimestampISO: now.UTC().Format(time.RFC3339), + Device: dev, + AIAgentsAndTools: aiTools, + IDEInstallations: ides, + IDEExtensions: extensions, + MCPConfigs: mcpConfigsToCommunity(mcpConfigs), + NodePkgManagers: pkgManagers, + NodePackages: []any{}, + Summary: model.Summary{ + AIAgentsAndToolsCount: len(aiTools), + IDEInstallationsCount: len(ides), + IDEExtensionsCount: len(extensions), + MCPConfigsCount: len(mcpConfigs), + NodeProjectsCount: nodeProjectsCount, + }, + } + + // Output + switch cfg.OutputFormat { + case "json": + return output.JSON(os.Stdout, result) + case "html": + return output.HTML(cfg.HTMLOutputFile, result) + default: + return output.Pretty(os.Stdout, result, cfg.ColorMode) + } +} + +func resolveSearchDirs(exec executor.Executor, dirs []string) []string { + resolved := make([]string, 0, len(dirs)) + for _, d := range dirs { + if d == "$HOME" { + u, err := exec.CurrentUser() + if err == nil { + d = u.HomeDir + } + } + resolved = append(resolved, d) + } + return resolved +} + +func mergeAITools(cli, agents, frameworks []model.AITool) []model.AITool { + result := make([]model.AITool, 0, len(cli)+len(agents)+len(frameworks)) + result = append(result, cli...) + result = append(result, agents...) + result = append(result, frameworks...) + return result +} + +func mcpConfigsToCommunity(configs []model.MCPConfig) []model.MCPConfig { + if configs == nil { + return []model.MCPConfig{} + } + return configs +} diff --git a/internal/telemetry/logcapture.go b/internal/telemetry/logcapture.go new file mode 100644 index 0000000..3a83722 --- /dev/null +++ b/internal/telemetry/logcapture.go @@ -0,0 +1,100 @@ +package telemetry + +import ( + "bytes" + "encoding/base64" + "fmt" + "io" + "os" + "sync" +) + +// LogCapture captures all stderr output during telemetry execution. +// The captured output is base64-encoded and included in the execution_logs payload. +type LogCapture struct { + buf bytes.Buffer + mu sync.Mutex + origErr *os.File + pipeRead *os.File + pipeWrite *os.File + done chan struct{} +} + +// StartCapture redirects stderr to a tee that writes to both the original +// stderr and an in-memory buffer for later base64 encoding. +func StartCapture() *LogCapture { + lc := &LogCapture{ + origErr: os.Stderr, + done: make(chan struct{}), + } + + r, w, err := os.Pipe() + if err != nil { + return lc // fallback: no capture + } + lc.pipeRead = r + lc.pipeWrite = w + + // Redirect stderr to the pipe + os.Stderr = w + + // Tee: read from pipe, write to both original stderr and buffer + go func() { + defer close(lc.done) + buf := make([]byte, 4096) + for { + n, err := r.Read(buf) + if n > 0 { + lc.mu.Lock() + lc.buf.Write(buf[:n]) + lc.mu.Unlock() + _, _ = lc.origErr.Write(buf[:n]) + } + if err != nil { + break + } + } + }() + + return lc +} + +// Finalize stops capture and returns the base64-encoded output. +// Safe to call multiple times — subsequent calls return the cached result. +func (lc *LogCapture) Finalize() string { + lc.mu.Lock() + defer lc.mu.Unlock() + + if lc.pipeWrite == nil { + // Already finalized or never started + return base64.StdEncoding.EncodeToString(lc.buf.Bytes()) + } + + // Close write end so the reader goroutine exits + lc.pipeWrite.Close() + lc.pipeWrite = nil + lc.mu.Unlock() + <-lc.done + lc.mu.Lock() + + // Restore stderr + os.Stderr = lc.origErr + lc.pipeRead.Close() + + return base64.StdEncoding.EncodeToString(lc.buf.Bytes()) +} + +// Write allows direct writing to the capture buffer (for banner etc.) +// while also writing to stderr. +func (lc *LogCapture) Write(p []byte) (n int, err error) { + if lc.pipeWrite != nil { + return lc.pipeWrite.Write(p) + } + return lc.origErr.Write(p) +} + +// Fprintf is a convenience method. +func (lc *LogCapture) Fprintf(format string, args ...any) { + msg := fmt.Sprintf(format, args...) + _, _ = io.WriteString(lc, msg) +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go new file mode 100644 index 0000000..4edc629 --- /dev/null +++ b/internal/telemetry/telemetry.go @@ -0,0 +1,407 @@ +package telemetry + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/step-security/dev-machine-guard/internal/buildinfo" + "github.com/step-security/dev-machine-guard/internal/cli" + "github.com/step-security/dev-machine-guard/internal/config" + "github.com/step-security/dev-machine-guard/internal/detector" + "github.com/step-security/dev-machine-guard/internal/device" + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/lock" + "github.com/step-security/dev-machine-guard/internal/model" + "github.com/step-security/dev-machine-guard/internal/progress" +) + +// Payload is the enterprise telemetry JSON structure. +type Payload struct { + CustomerID string `json:"customer_id"` + DeviceID string `json:"device_id"` + SerialNumber string `json:"serial_number"` + UserIdentity string `json:"user_identity"` + Hostname string `json:"hostname"` + Platform string `json:"platform"` + OSVersion string `json:"os_version"` + AgentVersion string `json:"agent_version"` + CollectedAt int64 `json:"collected_at"` + NoUserLoggedIn bool `json:"no_user_logged_in"` + + IDEExtensions []model.Extension `json:"ide_extensions"` + IDEInstallations []model.IDE `json:"ide_installations"` + NodePkgManagers []model.PkgManager `json:"node_package_managers"` + NodeGlobalPackages []model.NodeScanResult `json:"node_global_packages"` + NodeProjects []model.NodeScanResult `json:"node_projects"` + AIAgents []model.AITool `json:"ai_agents"` + MCPConfigs []model.MCPConfigEnterprise `json:"mcp_configs"` + + ExecutionLogs *ExecutionLogs `json:"execution_logs,omitempty"` + PerformanceMetrics *PerformanceMetrics `json:"performance_metrics,omitempty"` +} + +type ExecutionLogs struct { + OutputBase64 string `json:"output_base64"` + StartTime int64 `json:"start_time"` + EndTime int64 `json:"end_time"` + ExitCode int `json:"exit_code"` + AgentVersion string `json:"agent_version"` +} + +type PerformanceMetrics struct { + ExtensionsCount int `json:"extensions_count"` + NodePackagesScanMs int64 `json:"node_packages_scan_ms"` + NodeGlobalPkgsCount int `json:"node_global_packages_count"` + NodeProjectsCount int `json:"node_projects_count"` +} + +// Run executes enterprise telemetry: scan, build payload, upload to S3. +// Output format matches the shell script's sample_log: +// +// ========================================== +// StepSecurity Device Agent v1.9.0 +// ========================================== +// [scanning] Lock acquired (PID: 32560) +// [scanning] Device ID (Serial): ... +// ... +func Run(exec executor.Executor, log *progress.Logger, cfg *cli.Config) error { + ctx := context.Background() + startTime := time.Now() + + // Start capturing all stderr output for execution_logs. + // Defer Finalize immediately to ensure stderr is always restored, + // even on early returns (e.g., lock failure). + capture := StartCapture() + defer capture.Finalize() + + // Banner (matches shell script format) + fmt.Fprintf(os.Stderr, "==========================================\n") + fmt.Fprintf(os.Stderr, "StepSecurity Device Agent v%s\n", buildinfo.Version) + fmt.Fprintf(os.Stderr, "==========================================\n\n") + + // Acquire lock + lk, err := lock.Acquire(exec) + if err != nil { + return fmt.Errorf("acquiring lock: %w", err) + } + defer func() { + lk.Release() + log.Progress("Lock released (PID: %d)", os.Getpid()) + }() + log.Progress("Lock acquired (PID: %d)", os.Getpid()) + + // Device info + log.Progress("Gathering device information...") + dev := device.Gather(ctx, exec) + log.Progress("Device ID (Serial): %s", dev.SerialNumber) + log.Progress("OS Version: %s", dev.OSVersion) + log.Progress("Developer: %s", dev.UserIdentity) + + // Resolve search dirs + searchDirs := resolveSearchDirs(exec, cfg.SearchDirs) + fmt.Fprintln(os.Stderr) + + // Detect IDEs + log.Progress("Detecting IDE and AI desktop app installations...") + ideDetector := detector.NewIDEDetector(exec) + ides := ideDetector.Detect(ctx) + for _, ide := range ides { + log.Progress(" Found: %s (%s) v%s at %s", ideDisplayName(ide.IDEType), ide.Vendor, ide.Version, ide.InstallPath) + } + if len(ides) == 0 { + log.Progress(" No IDEs or AI desktop apps found") + } + fmt.Fprintln(os.Stderr) + + // Collect extensions + log.Progress("Scanning extensions...") + extDetector := detector.NewExtensionDetector(exec) + extensions := extDetector.Detect(ctx, searchDirs) + log.Progress("Found total of %d IDE extensions", len(extensions)) + fmt.Fprintln(os.Stderr) + + // Detect AI tools + log.Progress("Detecting AI agents and tools...") + fmt.Fprintln(os.Stderr) + + log.Progress("Detecting AI CLI tools...") + cliTools := detector.NewAICLIDetector(exec).Detect(ctx) + for _, t := range cliTools { + log.Progress(" Found: %s (%s) v%s at %s", t.Name, t.Vendor, t.Version, t.BinaryPath) + } + if len(cliTools) == 0 { + log.Progress(" No AI CLI tools found") + } + fmt.Fprintln(os.Stderr) + + log.Progress("Detecting general-purpose AI agents...") + agents := detector.NewAgentDetector(exec).Detect(ctx, searchDirs) + for _, a := range agents { + log.Progress(" Found: %s (%s) at %s", a.Name, a.Vendor, a.InstallPath) + } + if len(agents) == 0 { + log.Progress(" No general-purpose AI agents found") + } + fmt.Fprintln(os.Stderr) + + log.Progress("Detecting AI frameworks and runtimes...") + frameworks := detector.NewFrameworkDetector(exec).Detect(ctx) + for _, f := range frameworks { + running := "false" + if f.IsRunning != nil && *f.IsRunning { + running = "true" + } + log.Progress(" Found: %s v%s at %s (running: %s)", f.Name, f.Version, f.BinaryPath, running) + } + if len(frameworks) == 0 { + log.Progress(" No AI frameworks found") + } + fmt.Fprintln(os.Stderr) + + allAI := append(append(cliTools, agents...), frameworks...) + + // MCP configs + log.Progress("Collecting MCP configuration files...") + mcpDetector := detector.NewMCPDetector(exec) + mcpConfigs := mcpDetector.DetectEnterprise(ctx) + for _, c := range mcpConfigs { + log.Progress(" Found: %s config (%s)", c.ConfigSource, c.Vendor) + } + if len(mcpConfigs) == 0 { + log.Progress(" No MCP config files found") + } + fmt.Fprintln(os.Stderr) + + // Node.js scanning + npmEnabled := true + if cfg.EnableNPMScan != nil { + npmEnabled = *cfg.EnableNPMScan + } + + var pkgManagers []model.PkgManager + var globalPkgs []model.NodeScanResult + var nodeProjects []model.NodeScanResult + var nodeScanMs int64 + + if npmEnabled { + log.Progress("Node.js package scanning is ENABLED") + + log.Progress("Detecting Node.js package managers...") + npmDetector := detector.NewNodePMDetector(exec) + pkgManagers = npmDetector.DetectManagers(ctx) + for _, pm := range pkgManagers { + log.Progress(" Found: %s v%s at %s", pm.Name, pm.Version, pm.Path) + } + fmt.Fprintln(os.Stderr) + + log.Progress("Scanning globally installed packages...") + nodeScanner := detector.NewNodeScanner(exec, log) + globalPkgs = nodeScanner.ScanGlobalPackages(ctx) + log.Progress(" Found %d global package location(s)", len(globalPkgs)) + fmt.Fprintln(os.Stderr) + + log.Progress("Searching for Node.js projects...") + scanStart := time.Now() + nodeProjects = nodeScanner.ScanProjects(ctx, searchDirs) + nodeScanMs = time.Since(scanStart).Milliseconds() + log.Progress(" Found %d Node.js projects", len(nodeProjects)) + log.Progress(" Scan duration: %dms", nodeScanMs) + fmt.Fprintln(os.Stderr) + } else { + log.Progress("Node.js package scanning is DISABLED") + fmt.Fprintln(os.Stderr) + } + + if globalPkgs == nil { + globalPkgs = []model.NodeScanResult{} + } + if nodeProjects == nil { + nodeProjects = []model.NodeScanResult{} + } + + // Finalize execution logs before building payload + execLogsBase64 := capture.Finalize() + endTime := time.Now() + + // Build payload + payload := &Payload{ + CustomerID: config.CustomerID, + DeviceID: dev.SerialNumber, + SerialNumber: dev.SerialNumber, + UserIdentity: dev.UserIdentity, + Hostname: dev.Hostname, + Platform: "darwin", + OSVersion: dev.OSVersion, + AgentVersion: buildinfo.Version, + CollectedAt: endTime.Unix(), + NoUserLoggedIn: dev.UserIdentity == "" || dev.UserIdentity == "unknown", + + IDEExtensions: extensions, + IDEInstallations: ides, + NodePkgManagers: pkgManagers, + NodeGlobalPackages: globalPkgs, + NodeProjects: nodeProjects, + AIAgents: allAI, + MCPConfigs: mcpConfigs, + + ExecutionLogs: &ExecutionLogs{ + OutputBase64: execLogsBase64, + StartTime: startTime.Unix(), + EndTime: endTime.Unix(), + ExitCode: 0, + AgentVersion: buildinfo.Version, + }, + + PerformanceMetrics: &PerformanceMetrics{ + ExtensionsCount: len(extensions), + NodePackagesScanMs: nodeScanMs, + NodeGlobalPkgsCount: len(globalPkgs), + NodeProjectsCount: len(nodeProjects), + }, + } + + // Upload to S3 + log.Progress("Requesting upload URL from backend...") + if err := uploadToS3(ctx, log, payload); err != nil { + return fmt.Errorf("uploading telemetry: %w", err) + } + + fmt.Fprintln(os.Stderr) + log.Progress("Telemetry collection completed successfully") + return nil +} + +func uploadToS3(ctx context.Context, log *progress.Logger, payload *Payload) error { + payloadJSON, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshaling payload: %w", err) + } + + // Request upload URL + reqBody, _ := json.Marshal(map[string]string{ + "device_id": payload.DeviceID, + }) + + uploadURLEndpoint := fmt.Sprintf("%s/v1/%s/developer-mdm-agent/telemetry/upload-url", + config.APIEndpoint, config.CustomerID) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURLEndpoint, bytes.NewReader(reqBody)) + if err != nil { + return fmt.Errorf("creating upload URL request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+config.APIKey) + req.Header.Set("X-Agent-Version", buildinfo.Version) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("requesting upload URL: %w", err) + } + defer resp.Body.Close() + + var urlResp struct { + UploadURL string `json:"upload_url"` + S3Key string `json:"s3_key"` + } + if err := json.NewDecoder(resp.Body).Decode(&urlResp); err != nil { + return fmt.Errorf("decoding upload URL response: %w", err) + } + + if urlResp.UploadURL == "" { + return fmt.Errorf("empty upload URL in response") + } + + // Upload payload to S3 + log.Progress("Uploading telemetry to S3...") + putReq, err := http.NewRequestWithContext(ctx, http.MethodPut, urlResp.UploadURL, bytes.NewReader(payloadJSON)) + if err != nil { + return fmt.Errorf("creating S3 PUT request: %w", err) + } + putReq.Header.Set("Content-Type", "application/json") + + putResp, err := client.Do(putReq) + if err != nil { + return fmt.Errorf("uploading to S3: %w", err) + } + defer putResp.Body.Close() + _, _ = io.Copy(io.Discard, putResp.Body) + + if putResp.StatusCode != http.StatusOK { + return fmt.Errorf("S3 upload failed with status %d", putResp.StatusCode) + } + log.Progress("Uploaded to S3") + + // Notify backend + log.Progress("Notifying backend of upload...") + notifyBody, _ := json.Marshal(map[string]string{ + "s3_key": urlResp.S3Key, + "device_id": payload.DeviceID, + }) + + notifyEndpoint := fmt.Sprintf("%s/v1/%s/developer-mdm-agent/telemetry/process-uploaded", + config.APIEndpoint, config.CustomerID) + + notifyReq, err := http.NewRequestWithContext(ctx, http.MethodPost, notifyEndpoint, bytes.NewReader(notifyBody)) + if err != nil { + return fmt.Errorf("creating notify request: %w", err) + } + notifyReq.Header.Set("Content-Type", "application/json") + notifyReq.Header.Set("Authorization", "Bearer "+config.APIKey) + notifyReq.Header.Set("X-Agent-Version", buildinfo.Version) + + notifyResp, err := client.Do(notifyReq) + if err != nil { + return fmt.Errorf("notifying backend: %w", err) + } + defer notifyResp.Body.Close() + _, _ = io.Copy(io.Discard, notifyResp.Body) + + if notifyResp.StatusCode != http.StatusOK && notifyResp.StatusCode != http.StatusCreated { + return fmt.Errorf("backend notification failed with status %d", notifyResp.StatusCode) + } + log.Progress("Backend processing initiated (HTTP %d)", notifyResp.StatusCode) + + return nil +} + +func resolveSearchDirs(exec executor.Executor, dirs []string) []string { + resolved := make([]string, 0, len(dirs)) + for _, d := range dirs { + if d == "$HOME" { + u, err := exec.CurrentUser() + if err == nil { + d = u.HomeDir + } + } + resolved = append(resolved, d) + } + return resolved +} + +func ideDisplayName(ideType string) string { + switch ideType { + case "vscode": + return "Visual Studio Code" + case "cursor": + return "Cursor" + case "windsurf": + return "Windsurf" + case "antigravity": + return "Antigravity" + case "zed": + return "Zed" + case "claude_desktop": + return "Claude" + case "microsoft_copilot_desktop": + return "Microsoft Copilot" + default: + return ideType + } +} diff --git a/tests/test_smoke_go.sh b/tests/test_smoke_go.sh new file mode 100755 index 0000000..fecb07a --- /dev/null +++ b/tests/test_smoke_go.sh @@ -0,0 +1,358 @@ +#!/bin/bash +# +# Smoke tests for the Go binary (stepsecurity-dev-machine-guard) +# Adapted from tests/test_smoke.sh +# +# Usage: bash tests/test_smoke_go.sh +# + +set -uo pipefail + +BINARY="./stepsecurity-dev-machine-guard" + +#============================================================================== +# Test framework +#============================================================================== + +PASS_COUNT=0 +FAIL_COUNT=0 +GREEN='\033[0;32m' +RED='\033[0;31m' +BOLD='\033[1m' +RESET='\033[0m' + +pass() { + PASS_COUNT=$((PASS_COUNT + 1)) + printf " ${GREEN}PASS${RESET} %s\n" "$1" +} + +fail() { + FAIL_COUNT=$((FAIL_COUNT + 1)) + printf " ${RED}FAIL${RESET} %s\n" "$1" + if [ -n "${2:-}" ]; then + printf " %s\n" "$2" + fi +} + +assert_eq() { + local label="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then + pass "$label" + else + fail "$label" "expected=$expected actual=$actual" + fi +} + +assert_contains() { + local label="$1" haystack="$2" needle="$3" + if echo "$haystack" | grep -q "$needle"; then + pass "$label" + else + fail "$label" "output does not contain: $needle" + fi +} + +section() { + printf "\n${BOLD}── %s${RESET}\n" "$1" +} + +#============================================================================== +# 0. Binary check +#============================================================================== + +section "Binary check" + +if [ -f "$BINARY" ] && [ -x "$BINARY" ]; then + pass "Binary exists and is executable" +else + fail "Binary exists and is executable" + printf "\n Build the binary first: make build\n\n" + exit 1 +fi + +#============================================================================== +# 1. CLI basics +#============================================================================== + +section "CLI basics" + +HELP_RC=0 +HELP_OUTPUT=$("$BINARY" --help 2>&1) || HELP_RC=$? +assert_eq "--help exits 0" "0" "$HELP_RC" +assert_contains "--help shows usage" "$HELP_OUTPUT" "Usage:" + +VERSION_RC=0 +VERSION_OUTPUT=$("$BINARY" --version 2>&1) || VERSION_RC=$? +assert_eq "--version exits 0" "0" "$VERSION_RC" +assert_contains "--version prints version string" "$VERSION_OUTPUT" "StepSecurity Dev Machine Guard v" + +#============================================================================== +# 2. Pretty output (default) +#============================================================================== + +section "Pretty output (default)" + +PRETTY_OUTPUT=$("$BINARY" --pretty --color=never 2>&1 || true) +assert_contains "Runs successfully (produces output)" "$PRETTY_OUTPUT" "StepSecurity" +assert_contains "Output contains DEVICE header" "$PRETTY_OUTPUT" "DEVICE" +assert_contains "Output contains AI AGENTS header" "$PRETTY_OUTPUT" "AI AGENTS" +assert_contains "Output contains SUMMARY header" "$PRETTY_OUTPUT" "SUMMARY" + +#============================================================================== +# 3. JSON output +#============================================================================== + +section "JSON output" + +JSON_OUTPUT=$("$BINARY" --json 2>/dev/null || true) + +# Validate well-formed JSON +if echo "$JSON_OUTPUT" | python3 -m json.tool >/dev/null 2>&1; then + pass "Output is valid JSON" +else + fail "Output is valid JSON" +fi + +# Top-level keys +for key in agent_version device ai_agents_and_tools ide_installations ide_extensions mcp_configs summary; do + if echo "$JSON_OUTPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); assert '$key' in d" 2>/dev/null; then + pass "JSON has top-level key: $key" + else + fail "JSON has top-level key: $key" + fi +done + +# Scan metadata fields +for key in scan_timestamp scan_timestamp_iso agent_version; do + if echo "$JSON_OUTPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); assert '$key' in d" 2>/dev/null; then + pass "JSON has scan metadata field: $key" + else + fail "JSON has scan metadata field: $key" + fi +done + +# device object fields +for key in hostname os_version serial_number platform user_identity; do + if echo "$JSON_OUTPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); assert '$key' in d['device']" 2>/dev/null; then + pass "device has field: $key" + else + fail "device has field: $key" + fi +done + +# summary has numeric count fields +SUMMARY_CHECK=$(echo "$JSON_OUTPUT" | python3 -c " +import sys, json +d = json.load(sys.stdin) +s = d['summary'] +counts = ['ai_agents_and_tools_count', 'ide_installations_count', 'ide_extensions_count', 'mcp_configs_count', 'node_projects_count'] +for c in counts: + assert c in s, f'missing {c}' + assert isinstance(s[c], int), f'{c} is not int' +print('ok') +" 2>&1) +assert_eq "summary has numeric count fields" "ok" "$SUMMARY_CHECK" + +#============================================================================== +# 4. HTML output +#============================================================================== + +section "HTML output" + +HTML_TMP="/tmp/test-dmg-go-report-$$.html" +"$BINARY" --html "$HTML_TMP" >/dev/null 2>&1 || true + +if [ -f "$HTML_TMP" ] && [ -s "$HTML_TMP" ]; then + pass "HTML file exists and is non-empty" +else + fail "HTML file exists and is non-empty" +fi + +HTML_CONTENT="" +if [ -f "$HTML_TMP" ]; then + HTML_CONTENT=$(cat "$HTML_TMP") +fi +assert_contains "HTML contains tag" "$HTML_CONTENT" "" + +rm -f "$HTML_TMP" +pass "Cleaned up temp HTML file" + +#============================================================================== +# 5. Flag combinations +#============================================================================== + +section "Flag combinations" + +VERBOSE_OUT=$("$BINARY" --pretty --verbose --color=never 2>&1 || true) +if [ -n "$VERBOSE_OUT" ]; then + pass "--verbose runs and produces output" +else + fail "--verbose runs and produces output" +fi + +JSON_VERBOSE_OUT=$("$BINARY" --json --verbose 2>/dev/null || true) +if echo "$JSON_VERBOSE_OUT" | python3 -m json.tool >/dev/null 2>&1; then + pass "--json --verbose produces valid JSON" +else + fail "--json --verbose produces valid JSON" +fi + +COLOR_JSON_OUT=$("$BINARY" --color=never --json 2>/dev/null || true) +if echo "$COLOR_JSON_OUT" | python3 -m json.tool >/dev/null 2>&1; then + pass "--color=never --json produces valid JSON" +else + fail "--color=never --json produces valid JSON" +fi + +# Invalid flag must exit non-zero +BOGUS_RC=0 +"$BINARY" --bogus-flag >/dev/null 2>&1 || BOGUS_RC=$? +if [ "$BOGUS_RC" -ne 0 ]; then + pass "Invalid flag exits non-zero" +else + fail "Invalid flag exits non-zero" "exit code was 0" +fi + +BOGUS_ERR=$("$BINARY" --bogus-flag 2>&1 || true) +assert_contains "Invalid flag shows error" "$BOGUS_ERR" "Unknown option" + +#============================================================================== +# 6. JSON schema validation +#============================================================================== + +section "JSON schema validation" + +# ai_tools items have name and type +AI_SCHEMA=$(echo "$JSON_OUTPUT" | python3 -c " +import sys, json +d = json.load(sys.stdin) +items = d['ai_agents_and_tools'] +if len(items) == 0: + print('ok_empty') +else: + for i, item in enumerate(items): + assert 'name' in item, f'ai_tools[{i}] missing name' + assert 'type' in item, f'ai_tools[{i}] missing type' + print('ok') +" 2>&1) +if [ "$AI_SCHEMA" = "ok" ] || [ "$AI_SCHEMA" = "ok_empty" ]; then + pass "ai_agents_and_tools items have name and type" +else + fail "ai_agents_and_tools items have name and type" "$AI_SCHEMA" +fi + +# ide_installations items have ide_type and version +IDE_SCHEMA=$(echo "$JSON_OUTPUT" | python3 -c " +import sys, json +d = json.load(sys.stdin) +items = d['ide_installations'] +if len(items) == 0: + print('ok_empty') +else: + for i, item in enumerate(items): + assert 'ide_type' in item, f'ide_installations[{i}] missing ide_type' + assert 'version' in item, f'ide_installations[{i}] missing version' + print('ok') +" 2>&1) +if [ "$IDE_SCHEMA" = "ok" ] || [ "$IDE_SCHEMA" = "ok_empty" ]; then + pass "ide_installations items have ide_type and version" +else + fail "ide_installations items have ide_type and version" "$IDE_SCHEMA" +fi + +# ide_extensions items have name, publisher, and ide_type +EXT_SCHEMA=$(echo "$JSON_OUTPUT" | python3 -c " +import sys, json +d = json.load(sys.stdin) +items = d['ide_extensions'] +if len(items) == 0: + print('ok_empty') +else: + for i, item in enumerate(items): + assert 'name' in item, f'ide_extensions[{i}] missing name' + assert 'publisher' in item, f'ide_extensions[{i}] missing publisher' + assert 'ide_type' in item, f'ide_extensions[{i}] missing ide_type' + print('ok') +" 2>&1) +if [ "$EXT_SCHEMA" = "ok" ] || [ "$EXT_SCHEMA" = "ok_empty" ]; then + pass "ide_extensions items have name, publisher, and ide_type" +else + fail "ide_extensions items have name, publisher, and ide_type" "$EXT_SCHEMA" +fi + +# mcp_configs items have config_source, config_path, and vendor +MCP_SCHEMA=$(echo "$JSON_OUTPUT" | python3 -c " +import sys, json +d = json.load(sys.stdin) +items = d['mcp_configs'] +if len(items) == 0: + print('ok_empty') +else: + for i, item in enumerate(items): + assert 'config_source' in item, f'mcp_configs[{i}] missing config_source' + assert 'config_path' in item, f'mcp_configs[{i}] missing config_path' + assert 'vendor' in item, f'mcp_configs[{i}] missing vendor' + print('ok') +" 2>&1) +if [ "$MCP_SCHEMA" = "ok" ] || [ "$MCP_SCHEMA" = "ok_empty" ]; then + pass "mcp_configs items have config_source, config_path, and vendor" +else + fail "mcp_configs items have config_source, config_path, and vendor" "$MCP_SCHEMA" +fi + +#============================================================================== +# 7. Search dirs +#============================================================================== + +section "Search dirs" + +SEARCH_RC=0 +SEARCH_OUT=$("$BINARY" --search-dirs /tmp --json 2>/dev/null || true) +if echo "$SEARCH_OUT" | python3 -m json.tool >/dev/null 2>&1; then + pass "--search-dirs /tmp --json produces valid JSON" +else + fail "--search-dirs /tmp --json produces valid JSON" +fi + +# Missing dir arg +SEARCH_ERR_RC=0 +"$BINARY" --search-dirs 2>/dev/null || SEARCH_ERR_RC=$? +if [ "$SEARCH_ERR_RC" -ne 0 ]; then + pass "--search-dirs without arg exits non-zero" +else + fail "--search-dirs without arg exits non-zero" +fi + +#============================================================================== +# 8. Configure command +#============================================================================== + +section "Configure command" + +# configure should be a recognized command (not "Unknown option") +assert_contains "--help mentions configure" "$HELP_OUTPUT" "configure" + +# configure with empty stdin should not crash (sends EOF immediately) +CONFIGURE_RC=0 +echo "" | "$BINARY" configure >/dev/null 2>&1 || CONFIGURE_RC=$? +assert_eq "configure exits 0 with empty input" "0" "$CONFIGURE_RC" + +# Config file should be created +CONFIG_PATH="$HOME/.stepsecurity/config.json" +if [ -f "$CONFIG_PATH" ]; then + pass "configure creates config file" +else + fail "configure creates config file" +fi + +#============================================================================== +# Summary +#============================================================================== + +printf "\n${BOLD}══════════════════════════════════════${RESET}\n" +printf " Total: %d ${GREEN}Passed: %d${RESET} ${RED}Failed: %d${RESET}\n" \ + $((PASS_COUNT + FAIL_COUNT)) "$PASS_COUNT" "$FAIL_COUNT" +printf "${BOLD}══════════════════════════════════════${RESET}\n\n" + +[ "$FAIL_COUNT" -eq 0 ]