diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f51c324..338d276 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,22 @@ jobs: - name: Run Tests run: swift test + build-linux: + name: Build and Test (Linux) + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - uses: swift-actions/setup-swift@v2 + with: + swift-version: "5.9" + + - name: Build CLI + run: swift build --product PastewatchCLI + + - name: Run Tests + run: swift test + lint: name: Lint runs-on: macos-14 @@ -32,4 +48,40 @@ jobs: run: brew install swiftlint - name: Run SwiftLint - run: swiftlint lint --strict || true + run: swiftlint lint --strict + + auto-tag: + name: Auto-tag version bumps + runs-on: ubuntu-22.04 + needs: [build, build-linux, lint] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Check for version bump commit + id: check + run: | + MSG=$(git log -1 --format='%s') + if echo "$MSG" | grep -qE '^chore: bump version to [0-9]+\.[0-9]+\.[0-9]+$'; then + VERSION=$(echo "$MSG" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "should_tag=true" >> "$GITHUB_OUTPUT" + else + echo "should_tag=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create and push tag + if: steps.check.outputs.should_tag == 'true' + env: + GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + run: | + TAG="v${{ steps.check.outputs.version }}" + git tag "$TAG" + if [ -n "$GH_TOKEN" ]; then + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" + fi + git push origin "$TAG" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1f4dcb6..4526524 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,33 +4,107 @@ on: push: tags: - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Release tag (e.g., v0.2.0)' + required: true permissions: contents: write jobs: + resolve: + name: Resolve Tag + runs-on: ubuntu-22.04 + outputs: + tag: ${{ steps.tag.outputs.tag }} + ref: ${{ steps.tag.outputs.ref }} + steps: + - name: Resolve tag and ref + id: tag + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "tag=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT" + echo "ref=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT" + else + echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT" + echo "ref=${{ github.ref }}" >> "$GITHUB_OUTPUT" + fi + + test: + name: Test + runs-on: macos-14 + needs: resolve + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.resolve.outputs.ref }} + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_15.2.app + + - name: Run Tests + run: swift test + + build-linux: + name: Build Linux Release + runs-on: ubuntu-22.04 + needs: [resolve, test] + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.resolve.outputs.ref }} + + - uses: swift-actions/setup-swift@v2 + with: + swift-version: "5.9" + + - name: Build CLI + run: | + swift build -c release --product PastewatchCLI + mkdir -p release + cp .build/release/PastewatchCLI release/pastewatch-cli-linux-amd64 + + - name: Generate SHA256 + run: | + cd release + sha256sum pastewatch-cli-linux-amd64 > pastewatch-cli-linux-amd64.sha256 + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: linux-release + path: release/ + build: name: Build Release runs-on: macos-14 + needs: [resolve, test, build-linux] steps: - uses: actions/checkout@v4 + with: + ref: ${{ needs.resolve.outputs.ref }} - name: Select Xcode run: sudo xcode-select -s /Applications/Xcode_15.2.app - - name: Build Release Binary + - name: Build Release Binaries run: | swift build -c release mkdir -p release - cp .build/release/pastewatch release/ + cp .build/release/Pastewatch release/pastewatch + cp .build/release/PastewatchCLI release/pastewatch-cli - name: Create App Bundle run: | + VERSION="${{ needs.resolve.outputs.tag }}" + VERSION="${VERSION#v}" mkdir -p "release/Pastewatch.app/Contents/MacOS" mkdir -p "release/Pastewatch.app/Contents/Resources" - cp .build/release/pastewatch "release/Pastewatch.app/Contents/MacOS/Pastewatch" + cp .build/release/Pastewatch "release/Pastewatch.app/Contents/MacOS/Pastewatch" cp Sources/Pastewatch/Resources/AppIcon.icns "release/Pastewatch.app/Contents/Resources/AppIcon.icns" - cat > "release/Pastewatch.app/Contents/Info.plist" << 'EOF' + cat > "release/Pastewatch.app/Contents/Info.plist" << EOF @@ -50,7 +124,7 @@ jobs: CFBundlePackageType APPL CFBundleShortVersionString - ${GITHUB_REF_NAME#v} + ${VERSION} CFBundleVersion 1 LSMinimumSystemVersion @@ -67,30 +141,116 @@ jobs: - name: Create DMG run: | - hdiutil create -volname "Pastewatch" -srcfolder release/Pastewatch.app -ov -format UDZO release/Pastewatch-${{ github.ref_name }}.dmg + TAG="${{ needs.resolve.outputs.tag }}" + hdiutil create -volname "Pastewatch" -srcfolder release/Pastewatch.app -ov -format UDZO "release/Pastewatch-${TAG}.dmg" - name: Create ZIP run: | + TAG="${{ needs.resolve.outputs.tag }}" cd release - zip -r Pastewatch-${{ github.ref_name }}.zip Pastewatch.app + zip -r "Pastewatch-${TAG}.zip" Pastewatch.app - name: Generate SHA256 checksums run: | + TAG="${{ needs.resolve.outputs.tag }}" cd release - shasum -a 256 Pastewatch-${{ github.ref_name }}.dmg > Pastewatch-${{ github.ref_name }}.dmg.sha256 - shasum -a 256 Pastewatch-${{ github.ref_name }}.zip > Pastewatch-${{ github.ref_name }}.zip.sha256 + shasum -a 256 "Pastewatch-${TAG}.dmg" > "Pastewatch-${TAG}.dmg.sha256" + shasum -a 256 "Pastewatch-${TAG}.zip" > "Pastewatch-${TAG}.zip.sha256" shasum -a 256 pastewatch > pastewatch.sha256 + shasum -a 256 pastewatch-cli > pastewatch-cli.sha256 + + - name: Download Linux amd64 artifacts + uses: actions/download-artifact@v4 + with: + name: linux-release + path: release/ + + - name: Extract release notes from CHANGELOG + id: notes + run: | + TAG="${{ needs.resolve.outputs.tag }}" + VERSION="${TAG#v}" + # Extract the [VERSION] section from CHANGELOG.md + # Matches "## [0.19.0]" and captures until the next "## [" header + NOTES=$(awk -v ver="## [${VERSION}]" ' + BEGIN { found=0 } + index($0, ver) == 1 { found=1; next } + found && /^## \[/ { exit } + found { print } + ' CHANGELOG.md | sed '/^$/N;/^\n$/d') + + if [ -z "$NOTES" ]; then + NOTES="Release ${TAG}" + fi + + # Write to file to avoid quoting issues + echo "$NOTES" > /tmp/release-notes.md - name: Create Release uses: softprops/action-gh-release@v1 with: + tag_name: ${{ needs.resolve.outputs.tag }} + body_path: /tmp/release-notes.md files: | - release/Pastewatch-${{ github.ref_name }}.dmg - release/Pastewatch-${{ github.ref_name }}.dmg.sha256 - release/Pastewatch-${{ github.ref_name }}.zip - release/Pastewatch-${{ github.ref_name }}.zip.sha256 + release/Pastewatch-${{ needs.resolve.outputs.tag }}.dmg + release/Pastewatch-${{ needs.resolve.outputs.tag }}.dmg.sha256 + release/Pastewatch-${{ needs.resolve.outputs.tag }}.zip + release/Pastewatch-${{ needs.resolve.outputs.tag }}.zip.sha256 release/pastewatch release/pastewatch.sha256 - generate_release_notes: true + release/pastewatch-cli + release/pastewatch-cli.sha256 + release/pastewatch-cli-linux-amd64 + release/pastewatch-cli-linux-amd64.sha256 + generate_release_notes: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Update Homebrew formula + env: + GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + run: | + TAG="${{ needs.resolve.outputs.tag }}" + VERSION_NUM="${TAG#v}" + SHA256=$(shasum -a 256 release/pastewatch-cli | awk '{print $1}') + + cat > /tmp/pastewatch.rb << FORMULA + # typed: false + # frozen_string_literal: true + + class Pastewatch < Formula + desc "Sensitive data scanner — deterministic detection and obfuscation for text content" + homepage "https://github.com/ppiankov/pastewatch" + version "${VERSION_NUM}" + license "MIT" + + depends_on :macos + depends_on arch: :arm64 + + url "https://github.com/ppiankov/pastewatch/releases/download/${TAG}/pastewatch-cli" + sha256 "${SHA256}" + + def install + bin.install "pastewatch-cli" + end + + test do + assert_match "pastewatch-cli", shell_output("#{bin}/pastewatch-cli version") + end + end + FORMULA + + # Remove leading whitespace from heredoc + sed -i '' 's/^ //' /tmp/pastewatch.rb + + if [ -n "$GH_TOKEN" ]; then + EXISTING_SHA=$(gh api repos/ppiankov/homebrew-tap/contents/Formula/pastewatch.rb --jq .sha 2>/dev/null || echo '') + gh api repos/ppiankov/homebrew-tap/contents/Formula/pastewatch.rb \ + --method PUT \ + --field message="Update pastewatch to ${TAG}" \ + --field content="$(base64 -i /tmp/pastewatch.rb)" \ + --field sha="${EXISTING_SHA}" \ + || echo "Homebrew tap update failed — check HOMEBREW_TAP_TOKEN secret" + else + echo "HOMEBREW_TAP_TOKEN not set — skipping Homebrew update" + fi diff --git a/.github/workflows/vscode-extension.yml b/.github/workflows/vscode-extension.yml new file mode 100644 index 0000000..e64c1a5 --- /dev/null +++ b/.github/workflows/vscode-extension.yml @@ -0,0 +1,74 @@ +name: VS Code Extension + +on: + push: + branches: [main] + paths: + - "vscode-pastewatch/**" + - ".github/workflows/vscode-extension.yml" + pull_request: + branches: [main] + paths: + - "vscode-pastewatch/**" + release: + types: [published] + workflow_dispatch: + +jobs: + build: + name: Build Extension + runs-on: ubuntu-latest + defaults: + run: + working-directory: vscode-pastewatch + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install dependencies + run: npm install + + - name: Typecheck + run: npm run lint + + - name: Build + run: npm run build + + - name: Package VSIX + run: npx @vscode/vsce package + + - name: Upload VSIX + uses: actions/upload-artifact@v4 + with: + name: pastewatch-vsix + path: vscode-pastewatch/*.vsix + + publish: + name: Publish to Marketplace + needs: build + if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'release' || github.event_name == 'workflow_dispatch') + runs-on: ubuntu-latest + defaults: + run: + working-directory: vscode-pastewatch + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install dependencies + run: npm install + + - name: Build + run: npm run build + + - name: Publish + if: env.VSCE_PAT != '' + run: npx @vscode/vsce publish + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} diff --git a/.gitignore b/.gitignore index 847822e..4be0dbb 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ release/ # Config (user-specific) .config/ + +# Claude Code session files diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 0000000..2f1efcc --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,6 @@ +- id: pastewatch + name: pastewatch - detect sensitive data + entry: pastewatch-cli scan --check --file + language: system + types: [text] + stages: [pre-commit] diff --git a/CHANGELOG.md b/CHANGELOG.md index fb5e19a..db1d87d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,475 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.24.1] - 2026-03-26 + +### Fixed + +- Workledger key regex now matches 32+ base64url chars (was exactly 44, real keys are 43) +- Standalone `wl_sk_` keys without `KEY=` context now detected + +## [0.24.0] - 2026-03-26 + +### Added + +- Proxy alert injection: when secrets are redacted, a `[PASTEWATCH]` alert is prepended to the API response so the agent gets immediate feedback +- `--alert` / `--no-alert` flag on `proxy` command (default: on) +- Type names included in alert (deduplicated, sorted) +- Pass-through on non-JSON, error responses, or missing content array + +## [0.23.3] - 2026-03-26 + +### Fixed + +- New detection types (Workledger Key, Oracul Key, JDBC URL, etc.) now auto-enable in existing configs +- Previously, configs saved before new types were added silently missed them + +## [0.23.2] - 2026-03-25 + +### Added + +- Path-based protection for `~/.openclaw/` directory in guard commands +- Configurable `protectedPaths` in `config.json` (default: `["~/.openclaw"]`) +- Tests for workledger key detection and path protection (8 new tests) + +## [0.23.1] - 2026-03-23 + +### Added + +- Detection rules for Workledger API keys (`wl_sk_` prefix) +- Detection rules for Oracul API keys (`vc__` prefix) + +## [0.23.0] - 2026-03-16 + +### Added + +- `proxy` subcommand: API proxy that scans and redacts secrets from all outbound requests (WO-81) +- `--forward-proxy` flag for corporate proxy chaining +- Catches secrets from subagents that bypass hooks and MCP — the last line of defense + +## [0.22.0] - 2026-03-16 + +## [0.21.0] - 2026-03-15 + +### Fixed + +- Credential regex: exclude boolean values (`auth=true`), Go env lookups (`os.Getenv`), and short values (WO-79) +- AWS Secret Key regex: require keyword context, no longer matches standalone 40-char strings (WO-79) + +### Added + +- `watch` subcommand: continuous file monitoring with real-time secret detection (WO-59) +- `dashboard` subcommand: aggregate view across multiple audit log sessions (WO-65) +- Gitignore-aware scanning: gitignored files shown with `[gitignored]` prefix but excluded from `--check` exit code (WO-80) +- `--include-gitignored` flag to count gitignored findings toward exit code + +## [0.20.0] - 2026-03-13 + +### Added + +- `setup claude-code` auto-injects pastewatch snippet into CLAUDE.md (WO-74) +- `setup` runs `doctor` health check and canary smoke test after configuration (WO-75, WO-78) +- Manual install documentation for environments without Homebrew (WO-76) +- Admin-enforced config layer at `/etc/pastewatch/config.json` — highest priority in cascade (WO-77) + +## [0.19.8] - 2026-03-13 + +### Added + +- JDBC URL built-in detection type — Oracle, DB2, MySQL, PostgreSQL, SQL Server, AS/400 (WO-71) +- `init --profile banking` for enterprise onboarding — medium severity, JDBC, RFC 1918 IPs, service account rules (WO-72) + +## [0.19.7] - 2026-03-13 + +### Changed + +- MCP placeholder format from `__PW{TYPE_N}__` to `__PW_TYPE_N__` for LLM proxy compatibility (WO-70) + +## [0.19.6] - 2026-03-11 + +### Fixed + +- `init` generates complete config with all fields including `placeholderPrefix` + +## [0.19.5] - 2026-03-11 + +### Added + +- Configurable `placeholderPrefix` for LLM-proxy compatible redaction placeholders + +## [0.19.4] - 2026-03-11 + +### Added + +- XML value parser for ClickHouse and other XML config files +- XML credential detection (``, ``, etc.) +- XML username detection (``, ``) +- XML hostname detection (``, ``, ``) +- Configurable `xmlSensitiveTags` for custom XML tag scanning + +## [0.19.3] - 2026-03-09 + +### Added + +- Perplexity AI API key detection (`pplx-` prefix, critical severity) + +## [0.19.2] - 2026-03-07 + +### Added + +- `posture --org ` scans all repos in a GitHub org/user for secret posture +- `--repos org/repo` flag for scanning specific repositories +- `--compare` compares with previous posture scan JSON for trend tracking +- `--findings-only` hides clean repositories from output +- Output formats: text, json, markdown + +## [0.19.1] - 2026-03-05 + +### Fixed + +- `version` subcommand now reads from CommandConfiguration instead of hardcoded string (was stuck at 0.8.1) + +## [0.19.0] - 2026-03-05 + +### Added + +- `fix --encrypt` writes secrets to ChaCha20-Poly1305 encrypted vault (`.pastewatch-vault`) instead of plaintext `.env` +- `--init-key` generates a 256-bit encryption key (`.pastewatch-key`, local-only, mode 0600) +- `vault decrypt` exports vault to `.env` for deployment +- `vault export` prints `export VAR=VALUE` for shell eval +- `vault rotate-key` re-encrypts all entries with a new key +- `vault list` shows vault entries without decrypting values +- `canary generate` creates format-valid but non-functional canary tokens for 7 critical secret types (AWS, GitHub, OpenAI, Anthropic, DB, Stripe, API Key) +- `--prefix` flag embeds identifier in canary values for source tracking +- `canary verify` confirms all canaries are detected by DetectionRules +- `canary check --log` searches external log files for leaked canary values +- `report` subcommand generates session report from MCP audit log: `pastewatch-cli report --audit-log /tmp/pw.log` +- Report aggregates files read/written, secrets redacted, placeholders resolved, output checks, scan findings +- Report outputs text, JSON, markdown formats with `--format` and `--output` flags +- `--since` flag filters report to entries after a given ISO timestamp +- Verdict indicates whether secrets leaked (unresolved placeholders or dirty output checks) +- `setup` subcommand for one-command agent integration: `pastewatch-cli setup claude-code`, `setup cline`, `setup cursor` +- Claude Code setup: writes guard hook script, merges MCP + hook config into settings.json, aligns severity +- Cline setup: merges MCP config, writes hook script, prints hook registration instructions +- Cursor setup: merges MCP config, prints advisory instructions +- `--severity` flag aligns hook blocking and MCP redaction thresholds by construction +- `--project` flag for project-level Claude Code config (`.claude/settings.json`) +- Idempotent: safe to re-run — updates existing config without duplication +- `scan --git-log` scans git commit history for secrets, reporting only the first commit that introduced each finding +- `--range`, `--since`, `--branch` flags for scoping history scans (e.g., `--range HEAD~50..HEAD`, `--since 2025-01-01`) +- Deduplication by fingerprint — same secret across multiple commits is reported once at its introduction point +- All output formats supported: text (commit-grouped), json, sarif, markdown +- `guard` now detects database CLIs: `psql`, `mysql`, `mongosh`, `mongo`, `redis-cli`, `sqlite3` — extracts file flags (`-f`, `--defaults-file`) and positional database files +- `guard` now scans inline values in database commands: connection strings (`postgres://`, `mongodb://`, `redis://`), attached passwords (`-psecret`, `--password=secret`), auth tokens (`-a token`) +- `guard` now strips redirect operators (`>`, `>>`, `2>`, `&>`) from commands and scans input redirect (`<`) source files +- `guard` now extracts and scans subshell commands: `$(cat .env)` and backtick expressions + +### Fixed + +- CI auto-tag now waits for all jobs (build, test, lint) to pass before tagging +- Release workflow now checks out the tag commit, not main HEAD, for workflow_dispatch triggers +- Release notes now extracted from CHANGELOG.md instead of auto-generated + +## [0.17.3] - 2026-03-02 + +### Added + +- `guard` now detects scripting interpreters: `python3`, `python`, `ruby`, `node`, `perl`, `php`, `lua` (skips `-c`/`-e` inline code) +- `guard` now detects file transfer tools: `scp`, `rsync`, `ssh`, `ssh-keygen` (skips remote paths with `:`) +- `guard` now parses pipe chains (`|`) and command chaining (`&&`, `||`, `;`) — each segment scanned independently +- Quoted strings are preserved across pipe/chain splitting (e.g., `grep 'foo|bar'` is not split) + +## [0.17.2] - 2026-03-02 + +### Added + +- `guard` now detects infrastructure tools: `ansible-playbook`, `ansible`, `ansible-vault`, `terraform`, `docker-compose`, `docker`, `kubectl`, `helm` +- Extracts file paths from tool-specific flags (`-i`, `-f`, `--env-file`, `-var-file`, etc.) and positional arguments + +## [0.17.1] - 2026-03-02 + +### Added + +- `doctor` now shows per-process `--min-severity` and `--audit-log` for each running MCP server +- `doctor` now shows `mcpMinSeverity` from resolved config +- Ready-to-use agent integration examples in `docs/examples/` (Claude Code, Cline, Cursor) + +## [0.17.0] - 2026-03-02 + +### Added + +- `mcpMinSeverity` config field — set default MCP redaction threshold in `.pastewatch.json` +- `--min-severity` flag on `mcp` subcommand — per-agent severity thresholds (e.g., `pastewatch-cli mcp --min-severity medium`) +- Severity precedence: per-request `min_severity` > `--min-severity` CLI flag > config `mcpMinSeverity` > default (`high`) + +## [0.16.0] - 2026-03-01 + +### Added + +- `doctor` subcommand — installation health check showing CLI version, active config, hook status, MCP server processes, and Homebrew formula version +- `--json` flag for `doctor` for programmatic output + +### Fixed + +- CI auto-tag now triggers release workflow (uses PAT instead of GITHUB_TOKEN for tag push) +- SwiftLint orphaned doc comment violation in DetectionRules.swift + +## [0.15.0] - 2026-03-01 + +### Added + +- `sensitiveHosts` now catches 2-segment hostnames (e.g., `.local` matches `nas.local`) +- `sensitiveIPPrefixes` config field — IP prefixes that override the built-in exclude list (e.g., `172.16.`, `10.`) + +### Fixed + +- MCP server now reads `.pastewatch.json` and `~/.config/pastewatch/config.json` instead of using hardcoded defaults + +## [0.14.1] - 2026-03-01 + +### Fixed + +- MCP redaction now produces consistent placeholders across files — same secret value always maps to same placeholder regardless of which file it appears in + +## [0.14.0] - 2026-02-27 + +### Added + +- VS Code extension (`vscode-pastewatch/`): real-time secret detection in the editor + - Inline diagnostics with severity-mapped squiggles (red/yellow/blue) + - Hover tooltips showing detection type and severity + - Quick-fix actions: add inline `pastewatch:allow` or append to `.pastewatch-allow` + - Status bar with finding count, auto-refresh on save (debounced) + - CI workflow for build, VSIX packaging, and marketplace publishing + +## [0.13.0] - 2026-02-27 + +### Added + +- `inventory` subcommand: generates structured secret posture reports for a directory + - Output formats: text (default), json, markdown, csv + - Severity breakdown, hot spots (top 10 files), findings by type, per-entry line numbers + - `--compare` flag loads a previous JSON inventory and shows added/removed findings + - `--output` writes report to file instead of stdout + - Supports `--allowlist`, `--rules`, `--ignore` (same as `scan`) + +## [0.12.0] - 2026-02-27 + +### Added + +- Entropy-based secret detection: Shannon entropy scoring as opt-in second pass after pattern rules + - Threshold 4.0 bits/char, minimum 20 characters, requires 2+ character classes + - Filters git SHAs, pure alphabetic/numeric strings; severity `.low` + - Enable via `enabledTypes: ["High Entropy", ...]` in `.pastewatch.json` +- `guard-read` subcommand: blocks Claude Code Read tool on files containing secrets (exit 2) +- `guard-write` subcommand: blocks Claude Code Write tool on files containing secrets (exit 2) + - Both use format-aware scanning (`.env`, `.json`, `.yml`) unlike the shell-based `guard` command + - Support `--fail-on-severity`, `PW_GUARD=0` bypass, inline `pastewatch:allow` comments + +## [0.11.0] - 2026-02-27 + +### Added + +- `--git-diff` flag for `scan`: scans only added lines in git diff with format-aware parsing and accurate line numbers + - Staged changes by default, `--unstaged` for working tree changes + - Proper JSON/YAML/env parsing (scans full file, filters to added lines) + - Works with `--check`, `--bail`, `--format`, `--fail-on-severity` + - Replaces raw `git diff | scan` piping with correct per-file scanning + +## [0.10.0] - 2026-02-27 + +### Added + +- `fix` subcommand: externalize secrets to environment variables with language-aware code patching + - `--dry-run` to preview fix plan without applying + - `--min-severity` to filter by severity threshold (default: high) + - `--env-file` to specify output .env path + - Language-aware replacements: Python (`os.environ`), JS/TS (`process.env`), Go (`os.Getenv`), Ruby (`ENV`), Swift (`ProcessInfo`), Shell (`${VAR}`) + - Auto-generates `.env` file with extracted secrets + - Warns if `.env` not in `.gitignore` + +## [0.9.4] - 2026-02-27 + +### Added + +- `--bail` flag for `scan --dir`: stops at first finding for fast pre-dispatch gate checks (optimized for runforge integration) + +## [0.9.3] - 2026-02-27 + +### Added + +- Host suffix matching: leading-dot entries in `safeHosts`/`sensitiveHosts` match any subdomain (e.g., `.company.com` matches `db.company.com`) +- `allowedPatterns` config field: regex-based allowlist for suppressing findings by pattern (e.g., `sk_test_.*` suppresses Stripe test keys) + +## [0.9.2] - 2026-02-27 + +### Added + +- `safeHosts` config field: user-defined hostnames excluded from detection (extends built-in safe list) +- `sensitiveHosts` config field: hostnames that always trigger detection, overriding built-in and user safe hosts +- Config validation: warns when a host appears in both lists + +## [0.9.1] - 2026-02-26 + +### Added + +- `PW_GUARD=0` environment variable: native bypass for `guard` and `scan --check` — every hook gets the escape hatch for free +- Homebrew formula auto-update in release workflow +- Documentation: guard subcommand in README, enforcement hooks in agent-setup, Layer 2b in agent-safety + +## [0.9.0] - 2026-02-26 + +### Added + +- `guard` subcommand: scans files referenced in Bash commands for secrets, blocks commands that would leak sensitive data to cloud APIs +- `CommandParser` for extracting file paths from shell commands (cat, head, tail, sed, awk, grep, source) + +### Changed + +- Extracted per-type validators in `DetectionRules` to fix cyclomatic complexity lint violation + +## [0.8.1] - 2026-02-26 + +### Fixed + +- Reduced false positives: exclude well-known DNS IPs, noreply/bot emails, common system paths, nil UUIDs + +## [0.8.0] - 2026-02-26 + +### Added + +- `min_severity` parameter for `pastewatch_read_file` MCP tool (default: `high`) — only redacts findings at or above the threshold +- Built-in safe hosts allowlist for badge services, CI/CD platforms, package registries, and CDNs + +## [0.7.2] - 2026-02-26 + +### Fixed + +- MCP audit log now flushes after each write (tool calls were lost when server process was killed) + +## [0.7.1] - 2026-02-26 + +### Fixed + +- MCP server no longer responds to JSON-RPC notifications (fixes Cline compatibility) + +### Added + +- Per-agent MCP setup guide (`docs/agent-setup.md`) covering Claude Code, Claude Desktop, Cline, Cursor, OpenCode, Codex CLI, Qwen Code + +## [0.7.0] - 2026-02-25 + +### Added + +- 12 new detection types: OpenAI Key, Anthropic Key, Hugging Face Token, Groq Key, npm Token, PyPI Token, RubyGems Token, GitLab Token, Telegram Bot Token, SendGrid Key, Shopify Token, DigitalOcean Token (all critical severity) +- ClickHouse connection string detection (`clickhouse://`) +- MCP redacted read/write tools (`pastewatch_read_file`, `pastewatch_write_file`, `pastewatch_check_output`) for AI agent secret protection +- MCP audit logging via `--audit-log` flag — proof of what was redacted during agent sessions +- Agent safety guide (`docs/agent-safety.md`) with setup for Claude Code, Cline, and Cursor + +## [0.6.0] - 2026-02-23 + +### Added + +- `--fail-on-severity` flag: only exit 6 when findings meet or exceed a severity threshold +- `--output` flag: write report to file instead of stdout +- `--format markdown` output for PR comments via `gh pr comment --body-file` +- `--ignore` flag and `.pastewatchignore` file for glob-based path exclusion +- 4 new credential detection types: Slack Webhook, Discord Webhook, Azure Connection String, GCP Service Account (all critical severity) +- Custom severity on custom rules: `{"name": "...", "pattern": "...", "severity": "low"}` +- `explain` subcommand: show detection type details, severity, and examples +- `config check` subcommand: validate config, custom rules, and severity strings + +## [0.5.0] - 2026-02-23 + +### Added + +- Linux binary support (`pastewatch-cli-linux-amd64`) for CI runners + - 10x cheaper GitHub Actions via `ubuntu` runners instead of `macos` + - `swift-crypto` for cross-platform SHA256 hashing +- Severity levels on all detection types (critical, high, medium, low) + - SARIF output uses severity-appropriate levels (error, warning, note) + - JSON output includes `severity` field on each finding +- Pre-commit framework integration (`.pre-commit-hooks.yaml`) + - `language: system` hook for pre-commit.com users +- `--stdin-filename` flag for format-aware stdin parsing + - Enables structured parsing (.env, .json, .yml) when piping via stdin +- Inline allowlist comments (`pastewatch:allow` on any line) + - Works with `#`, `//`, and `/* */` comment styles +- GitHub Action test workflow for `pastewatch-action` + +### Fixed + +- CI: pin Linux jobs to `ubuntu-22.04` for Swift 5.9 compatibility + +## [0.4.0] - 2026-02-23 + +### Added + +- MCP server (`pastewatch-cli mcp`) — JSON-RPC 2.0 over stdio for AI agent integration + - Three tools: `pastewatch_scan`, `pastewatch_scan_file`, `pastewatch_scan_dir` + - Compatible with Claude Desktop, Cursor, and other MCP clients +- Baseline diff mode (`--baseline path` and `baseline create` subcommand) + - SHA256 fingerprints for suppressing known findings + - Only new findings are reported when a baseline is provided +- Pre-commit hook installer (`hook install` and `hook uninstall`) + - Marker-based sections (`# BEGIN PASTEWATCH` / `# END PASTEWATCH`) + - `--append` flag for existing hooks + - Worktree-safe via `git rev-parse --git-path hooks` +- Config init (`pastewatch-cli init`) generates `.pastewatch.json` and `.pastewatch-allow` +- Project-level config resolution: CWD `.pastewatch.json` → `~/.config/pastewatch/config.json` → defaults + +## [0.3.0] - 2026-02-23 + +### Added + +- SARIF 2.1.0 output format (`--format sarif`) for GitHub code scanning integration +- Directory scanning (`--dir path`) with recursive file discovery + - Extension whitelist for config, source, and key files + - Skips .git, node_modules, vendor, build directories + - Binary file detection +- Format-aware scanning for structured files + - .env: KEY=VALUE with quote stripping + - .json: recursive string value extraction + - .yml/.yaml: line-by-line key: value parsing + - .properties/.cfg/.ini: key=value with comment handling +- Allowlist for false positive suppression (`--allowlist path`) + - File-based (one value per line, # comments) + - Config-based (allowedValues array) + - Merged from all sources into O(1) lookup +- Custom detection rules (`--rules path`) + - JSON array of {name, pattern} objects + - Regex validated at load time + - Runs after built-in rules with same overlap logic + - SARIF integration: `pastewatch/CUSTOM_` rule IDs +- Line number tracking in DetectedMatch for precise location reporting + +## [0.2.0] - 2026-02-22 + +### Added + +- CLI scan mode via `pastewatch-cli` binary + - `scan` subcommand reads from stdin or file + - `--check` mode for CI (exit code 6 = findings) + - `--format json` for structured output + - `version` subcommand +- New detection types ported from chainwatch nullbot: + - File Path — Linux system paths (/home, /etc, /var, ...) + - Hostname — internal FQDNs with safe list filtering + - Credential — key=value credential pairs (password=, secret=, etc.) +- Safe host list for reducing hostname false positives +- SKILL.md for agent integration +- Agent Integration section in README +- CLI Mode section in README +- Project Status section in README + +### Changed + +- Package.swift restructured: shared logic extracted to PastewatchCore library +- Tests target PastewatchCore directly +- CI lint job now fails on violations (removed `|| true`) +- Release workflow supports manual dispatch and includes CLI binary + ## [0.1.0] - 2026-02-05 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 04247b3..6eca9b1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,7 @@ Thank you for your interest in contributing. Before contributing, understand what Pastewatch is and is not: **Pastewatch is:** -- A local-only utility +- A local-only secret detection and obfuscation tool (GUI, CLI, MCP server) - Deterministic and predictable - Conservative (false negatives > false positives) - Silent when successful @@ -32,21 +32,44 @@ If your contribution moves Pastewatch toward the second list, it will be decline ``` pastewatch/ -├── Sources/Pastewatch/ -│ ├── PastewatchApp.swift # Main app entry -│ ├── Types.swift # Data models -│ ├── DetectionRules.swift # Pattern detection -│ ├── Obfuscator.swift # Value replacement -│ ├── ClipboardMonitor.swift # Clipboard watching -│ ├── MenuBarView.swift # UI -│ └── NotificationManager.swift +├── Sources/ +│ ├── Pastewatch/ # macOS GUI (menubar app) +│ │ ├── PastewatchApp.swift +│ │ ├── ClipboardMonitor.swift +│ │ ├── MenuBarView.swift +│ │ └── NotificationManager.swift +│ ├── PastewatchCLI/ # CLI tool (pastewatch-cli) +│ │ ├── PastewatchCLI.swift +│ │ ├── ScanCommand.swift +│ │ ├── MCPCommand.swift # MCP server for AI agents +│ │ ├── HookCommand.swift +│ │ ├── BaselineCommand.swift +│ │ ├── InitCommand.swift +│ │ ├── ConfigCommand.swift +│ │ ├── ExplainCommand.swift +│ │ └── VersionCommand.swift +│ └── PastewatchCore/ # Shared detection and obfuscation logic +│ ├── DetectionRules.swift +│ ├── Obfuscator.swift +│ ├── Types.swift +│ ├── RedactionStore.swift # MCP placeholder mapping +│ ├── MCPProtocol.swift +│ ├── MCPAuditLogger.swift +│ ├── DirectoryScanner.swift +│ ├── FormatParser.swift # .env, JSON, YAML, properties +│ ├── SarifOutput.swift +│ ├── MarkdownOutput.swift +│ ├── Allowlist.swift +│ ├── Baseline.swift +│ ├── CustomRule.swift +│ └── IgnoreFile.swift ├── Tests/PastewatchTests/ -│ ├── DetectionRulesTests.swift -│ └── ObfuscatorTests.swift +├── docs/ ├── Package.swift +├── Makefile ├── README.md -├── CONTRIBUTING.md ├── CHANGELOG.md +├── CONTRIBUTING.md ├── SECURITY.md └── LICENSE ``` @@ -55,7 +78,7 @@ pastewatch/ ### Prerequisites -- macOS 13.0+ +- macOS 14.0+ (GUI) or Linux (CLI only) - Xcode 15.0+ or Swift 5.9+ ### Building diff --git a/Makefile b/Makefile index 9fa4bc0..861d693 100644 --- a/Makefile +++ b/Makefile @@ -59,5 +59,13 @@ dmg: app ## Build DMG installer hdiutil create -volname "Pastewatch" -srcfolder release/Pastewatch.app -ov -format UDZO release/Pastewatch.dmg @echo "DMG created at release/Pastewatch.dmg" +.PHONY: build-cli +build-cli: ## Build CLI debug binary + swift build --target PastewatchCLI + +.PHONY: release-cli +release-cli: ## Build CLI release binary + swift build -c release --target PastewatchCLI + .PHONY: all -all: lint test release ## Run lint, tests, and build release +all: lint test release release-cli ## Run lint, tests, and build all releases diff --git a/Package.swift b/Package.swift index 3ebc17b..5deeb13 100644 --- a/Package.swift +++ b/Package.swift @@ -2,26 +2,50 @@ import PackageDescription +var targets: [Target] = [ + .target( + name: "PastewatchCore", + dependencies: [ + .product(name: "Crypto", package: "swift-crypto", condition: .when(platforms: [.linux])) + ], + path: "Sources/PastewatchCore" + ), + .executableTarget( + name: "PastewatchCLI", + dependencies: [ + "PastewatchCore", + .product(name: "ArgumentParser", package: "swift-argument-parser") + ], + path: "Sources/PastewatchCLI" + ), + .testTarget( + name: "PastewatchTests", + dependencies: ["PastewatchCore"], + path: "Tests/PastewatchTests" + ) +] + +#if os(macOS) +targets.append( + .executableTarget( + name: "Pastewatch", + dependencies: ["PastewatchCore"], + path: "Sources/Pastewatch", + resources: [ + .copy("Resources/AppIcon.icns") + ] + ) +) +#endif + let package = Package( name: "Pastewatch", platforms: [ .macOS(.v14) ], - products: [ - .executable(name: "pastewatch", targets: ["Pastewatch"]) + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser.git", "1.3.0"..<"1.5.0"), + .package(url: "https://github.com/apple/swift-crypto.git", "3.0.0"..<"4.0.0") ], - targets: [ - .executableTarget( - name: "Pastewatch", - path: "Sources/Pastewatch", - resources: [ - .copy("Resources/AppIcon.icns") - ] - ), - .testTarget( - name: "PastewatchTests", - dependencies: ["Pastewatch"], - path: "Tests/PastewatchTests" - ) - ] + targets: targets ) diff --git a/README.md b/README.md index ba7c8ef..b2f08e7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Pastewatch +[![ANCC](https://img.shields.io/badge/ANCC-compliant-brightgreen)](https://ancc.dev) -Local macOS utility that obfuscates sensitive data before it is pasted into AI chat interfaces. +Detects and obfuscates sensitive data before it reaches AI systems — clipboard monitoring, CLI scanner, MCP server, API proxy, shell guard hooks, and VS Code extension. It operates **before paste**, not after submission. @@ -10,7 +11,7 @@ If sensitive data never enters the prompt, the incident does not exist. ## Core Principle -**Principiis obsta** — resist the beginnings. +**Principiis obsta** - resist the beginnings. Pastewatch intervenes at the earliest irreversible boundary: the moment data leaves the user's control. @@ -20,6 +21,20 @@ Pastewatch refuses that transition. --- +## Why Pastewatch + +No other tool does what Pastewatch does. Here's why: + +- **Before-paste boundary** - secrets never leave your machine. Nightfall, Prisma, Check Point all intercept downstream. Pastewatch prevents upstream +- **MCP server for AI agents** - no other tool provides redacted read/write at the tool level. The agent works with placeholders, your secrets stay local +- **Bash guard with deep parsing** - pipes, subshells, redirects, database CLIs, infra tools. Every shell command the agent runs is scanned before execution +- **Canary honeypots** - "prove it works" not "trust it works." Plant format-valid fake secrets and verify they're caught +- **Local-only, deterministic, no ML** - no cloud dependency, no probabilistic scoring, no telemetry. Runs offline, gives the same answer every time +- **One-command agent setup** - `pastewatch-cli setup claude-code` and you're protected. MCP server, hooks, severity alignment - all configured in one step +- **Watch mode + dashboard** - continuous file monitoring during sessions, aggregate reporting across sessions. Know what's happening, prove it's working + +--- + ## What Pastewatch Does - Monitors clipboard content locally @@ -78,7 +93,37 @@ after paste. 3. Launch Pastewatch from Applications 4. Grant notification permissions when prompted -### From Source +### CLI via Homebrew + +```bash +brew install ppiankov/tap/pastewatch +pastewatch-cli doctor # verify installation +``` + +### CLI Manual Install (No Homebrew) + +For environments where Homebrew is not available (CI runners, restricted workstations, Linux): + +```bash +# Download the latest release binary +curl -L -o pastewatch-cli https://github.com/ppiankov/pastewatch/releases/latest/download/pastewatch-cli +chmod +x pastewatch-cli +sudo mv pastewatch-cli /usr/local/bin/ + +# Verify +pastewatch-cli doctor +``` + +Or build from source (requires Swift 5.9+): + +```bash +git clone https://github.com/ppiankov/pastewatch.git +cd pastewatch +swift build -c release +sudo cp .build/release/PastewatchCLI /usr/local/bin/pastewatch-cli +``` + +### From Source (GUI) ```bash git clone https://github.com/ppiankov/pastewatch.git @@ -102,9 +147,36 @@ Pastewatch detects only **deterministic, high-confidence patterns**: | API Keys | `sk_test_...`, `ghp_...` | | UUIDs | `550e8400-e29b-41d4-a716-446655440000` | | JWT Tokens | `eyJhbGciOiJIUzI1NiIs...` | -| DB Connections | `postgres://user:pass@host/db` | +| DB Connections | `postgres://...`, `clickhouse://...` | | SSH Keys | `-----BEGIN RSA PRIVATE KEY-----` | | Credit Cards | `4111111111111111` (Luhn validated) | +| File Paths | `/etc/nginx/nginx.conf`, `/home/deploy/.ssh/id_rsa` | +| Hostnames | `db-primary.internal.corp.net` | +| Credentials | `password=...`, `secret: ...`, `api_key=...` | +| Slack Webhooks | `https://hooks.slack.com/services/...` | +| Discord Webhooks | `https://discord.com/api/webhooks/...` | +| Azure Connections | `DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...` | +| GCP Service Accounts | `{"type": "service_account", ...}` | +| OpenAI Keys | `sk-proj-...`, `sk-svcacct-...` | +| Anthropic Keys | `sk-ant-api03-...`, `sk-ant-admin01-...` | +| Hugging Face Tokens | `hf_...` | +| Groq Keys | `gsk_...` | +| npm Tokens | `npm_...` | +| PyPI Tokens | `pypi-...` | +| RubyGems Tokens | `rubygems_...` | +| GitLab Tokens | `glpat-...` | +| Telegram Bot Tokens | `123456789:AA...` | +| SendGrid Keys | `SG....` | +| Shopify Tokens | `shpat_...`, `shpca_...` | +| DigitalOcean Tokens | `dop_v1_...`, `doo_v1_...` | +| Perplexity Keys | `pplx-...` | +| JDBC URLs | `jdbc:oracle:thin:@...`, `jdbc:db2://...`, `jdbc:postgresql://...` | +| XML Credentials | ``, ``, etc. in XML configs | +| XML Usernames | ``, `` in XML configs | +| XML Hostnames | ``, ``, `` in XML configs | +| High Entropy Strings | Opt-in Shannon entropy detection (4.0 bits/char threshold) | + +Each type has a severity level (critical, high, medium, low) used in SARIF, JSON, and markdown output. No ML. No probabilistic scoring. No confidence levels. @@ -144,20 +216,511 @@ Silence is success. --- +## CLI Mode + +Pastewatch includes a CLI tool for scanning text without the GUI: + +```bash +# Scan from stdin +echo "password=hunter2" | pastewatch-cli scan + +# Scan a file +pastewatch-cli scan --file config.yml + +# Scan a directory recursively +pastewatch-cli scan --dir ./project --check + +# SARIF output for GitHub code scanning +pastewatch-cli scan --dir . --format sarif > results.sarif + +# Suppress known-safe values +pastewatch-cli scan --file app.yml --allowlist .pastewatch-allow + +# Custom detection rules +pastewatch-cli scan --file data.txt --rules custom-rules.json + +# Baseline: suppress known findings +pastewatch-cli baseline create --dir . --output .pastewatch-baseline.json +pastewatch-cli scan --dir . --baseline .pastewatch-baseline.json --check + +# Check mode (exit code only, for CI) +git diff --cached | pastewatch-cli scan --check + +# JSON output +pastewatch-cli scan --format json --check < input.txt + +# Markdown output (for PR comments) +pastewatch-cli scan --dir . --format markdown --output report.md + +# Only fail on critical severity findings +pastewatch-cli scan --dir . --check --fail-on-severity critical + +# Write report to file +pastewatch-cli scan --dir . --format sarif --output results.sarif + +# Ignore paths +pastewatch-cli scan --dir . --ignore "*.log" --ignore "fixtures/" + +# Explain detection types +pastewatch-cli explain +pastewatch-cli explain email + +# Validate config +pastewatch-cli config check +``` + +### API Proxy — Last Line of Defense + +Every tool call an AI agent makes — including internal subprocesses you don't control — ends up as an HTTP request to the API. The proxy scans and redacts secrets from **all** outbound requests before they leave your machine. Nothing gets through. + +``` + Your machine Cloud API + ┌──────────────────────────────────────┐ + │ Agent (any process, any tool) │ + │ │ │ + │ ▼ │ + │ pastewatch proxy (localhost:8443) │ + │ scan request body → redact secrets │ + │ │ │ + │ ▼ │ + │ corporate proxy (if present) │ + │ │ │ + └───────────┼──────────────────────────┘ + ▼ + api.anthropic.com (secrets never arrive) +``` + +```bash +# Start the proxy +pastewatch-cli proxy + +# Start your agent through the proxy +ANTHROPIC_BASE_URL=http://127.0.0.1:8443 claude +``` + +**Corporate environments** often require a company proxy for API access. Pastewatch chains through it: + +```bash +# Company proxy on :3456 — pastewatch sits in front +pastewatch-cli proxy --port 3456 --forward-proxy http://127.0.0.1:3457 + +# Agent connects to :3456 as usual — pastewatch is transparent +``` + +Configure port and upstream in your shell profile for zero-friction sessions: + +```bash +# .zshrc / .bashrc +alias claude='ANTHROPIC_BASE_URL=http://127.0.0.1:8443 claude' +``` + +The proxy logs every redaction: +``` +[2026-03-16T11:36:56Z] PROXY REDACTED 3 secret(s) in /v1/messages +``` + +Use `--audit-log` to write to a file for dashboard aggregation. + +### MCP Server - Redacted Read/Write + +AI coding agents send file contents to cloud APIs. If those files contain secrets, the secrets leave your machine. Pastewatch MCP solves this: **the agent works with placeholders, your secrets stay local.** + +``` + Your machine (local only) Cloud API + ┌────────────────────────┐ + │ pastewatch MCP server │ + │ │ __PW_AWS_KEY_1__ + │ read: scan + redact ──┼──────────────────────► Agent sees placeholders + │ write: resolve local ◄┼────────────────────── Agent returns placeholders + │ │ + │ secrets stay in RAM │ Secrets never leave. + └────────────────────────┘ +``` + +**Setup** (Claude Code, Cline, Cursor - any MCP-compatible agent): + +```json +{ + "mcpServers": { + "pastewatch": { + "command": "pastewatch-cli", + "args": ["mcp"] + } + } +} +``` + +**Tools:** + +| Tool | Purpose | +|------|---------| +| `pastewatch_read_file` | Read file with secrets replaced by `__PW_TYPE_N__` placeholders | +| `pastewatch_write_file` | Write file, resolving placeholders back to real values locally | +| `pastewatch_check_output` | Verify text contains no raw secrets before returning | +| `pastewatch_scan` | Scan text for sensitive data | +| `pastewatch_scan_file` | Scan a file for sensitive data | +| `pastewatch_scan_dir` | Scan a directory recursively | + +The server holds mappings in memory for the session. Same file re-read returns the same placeholders. Mappings die when the server stops. + +**Audit logging** - verify what the MCP server did during a session: + +```json +{ + "mcpServers": { + "pastewatch": { + "command": "pastewatch-cli", + "args": ["mcp", "--audit-log", "/tmp/pastewatch-audit.log"] + } + } +} +``` + +Logs timestamps, tool calls, file paths, and redaction counts. Never logs secret values. + +**What this protects:** API keys, DB credentials, SSH keys, tokens, emails, IPs - secrets never leave your machine. **What this doesn't protect:** prompt content, code structure, business logic - these still reach the API. Pastewatch protects your keys; for protecting your ideas, use a local model. + +See [docs/agent-safety.md](docs/agent-safety.md) for the full agent safety guide with setup for Claude Code, Cline, and Cursor. + +### Agent Auto-Setup + +One-command agent integration - configures MCP server, hooks, and severity alignment: + +```bash +pastewatch-cli setup claude-code # global config +pastewatch-cli setup claude-code --project # project-level config +pastewatch-cli setup cline +pastewatch-cli setup cursor +pastewatch-cli setup claude-code --severity medium # align hook + MCP thresholds +``` + +Idempotent - safe to re-run. Updates existing config without duplication. + +### Session Report + +Generate compliance artifacts from MCP audit logs: + +```bash +pastewatch-cli report --audit-log /tmp/pastewatch-audit.log +pastewatch-cli report --audit-log /tmp/pw.log --format json +pastewatch-cli report --audit-log /tmp/pw.log --format markdown --output session-report.md +pastewatch-cli report --audit-log /tmp/pw.log --since "2026-03-02T10:00:00Z" +``` + +Aggregates files read/written, secrets redacted, placeholders resolved, output checks, and scan findings. Verdict indicates whether any secrets leaked. + +### Canary Secrets + +Plant format-valid but non-functional secrets as leak detection tripwires: + +```bash +pastewatch-cli canary generate # generate 7 canary tokens +pastewatch-cli canary generate --prefix myproject # embed identifier for tracking +pastewatch-cli canary verify # confirm all canaries are detected +pastewatch-cli canary check --log /tmp/trail.json # search logs for leaked canaries +``` + +Covers AWS Key, GitHub Token, OpenAI Key, Anthropic Key, DB Connection, Stripe Key, and generic API Key. If a canary value appears in provider logs, your prevention failed. + +### Bash Command Guard + +Block shell commands that would read or write files containing secrets: + +```bash +pastewatch-cli guard "cat .env" +# BLOCKED: .env contains 3 secret(s) (2 critical, 1 high) + +pastewatch-cli guard "echo hello" +# exit 0 (safe - no file access) + +pastewatch-cli guard --json "cat config.yml" +# JSON output for programmatic integration +``` + +Handles pipe chains (`|`), command chaining (`&&`, `||`, `;`), redirect operators, subshell extraction (`$(...)`, backticks), scripting interpreters, file transfer tools, infrastructure tools (terraform, docker, kubectl), and database CLIs (psql, mysql, redis-cli) with inline value scanning. + +Integrates with agent hooks (Claude Code, Cline) to intercept Bash tool calls before execution. See [docs/agent-setup.md](docs/agent-setup.md) for hook configuration. + +### Secret Externalization (Fix) + +Externalize secrets to environment variables with language-aware code patching: + +```bash +pastewatch-cli fix --dir . # apply fixes +pastewatch-cli fix --dir . --dry-run # preview fix plan +pastewatch-cli fix --dir . --min-severity high --env-file .env +``` + +Supports Python (`os.environ`), JS/TS (`process.env`), Go (`os.Getenv`), Ruby (`ENV`), Swift (`ProcessInfo`), and Shell (`${VAR}`). + +### Secret Inventory + +Generate structured posture reports with severity breakdown and hot spots: + +```bash +pastewatch-cli inventory --dir . +pastewatch-cli inventory --dir . --format json --output inventory.json +pastewatch-cli inventory --dir . --compare previous.json # show added/removed +``` + +Output formats: text, json, markdown, csv. + +### Git History Scanning + +Scan commit history for secrets, reporting the first commit that introduced each finding: + +```bash +pastewatch-cli scan --git-log +pastewatch-cli scan --git-log --range HEAD~50..HEAD +pastewatch-cli scan --git-log --since 2025-01-01 +pastewatch-cli scan --git-log --branch feature/auth --format sarif +``` + +Deduplicates by fingerprint - same secret across multiple commits is reported once. + +### Git Diff Scanning + +Scan only added lines in git diff with format-aware parsing: + +```bash +pastewatch-cli scan --git-diff # staged changes (default) +pastewatch-cli scan --git-diff --unstaged # working tree changes +pastewatch-cli scan --git-diff --check # CI gate mode +``` + +### Doctor + +Installation health check: + +```bash +pastewatch-cli doctor # text output +pastewatch-cli doctor --json # programmatic output +``` + +Shows CLI version, config status, hook status, MCP server processes (with per-process `--min-severity` and `--audit-log`), and Homebrew version. + +### Watch Mode + +Continuous file monitoring — scans changed files in real-time: + +```bash +pastewatch-cli watch --dir . # watch current directory +pastewatch-cli watch --dir . --severity high # only report high+ findings +pastewatch-cli watch --dir . --json # newline-delimited JSON output +``` + +Polls every 2 seconds, prints warnings to stderr. Respects `.pastewatchignore` and `.gitignore`. Ctrl-C to stop. + +### Dashboard + +Aggregate view across multiple MCP audit log sessions: + +```bash +pastewatch-cli dashboard # text summary from /tmp +pastewatch-cli dashboard --dir /tmp --format json # machine-readable +pastewatch-cli dashboard --since 2026-03-01T00:00:00Z --format markdown +``` + +Shows total sessions, secrets redacted, top secret types, hot files, and overall verdict. + +### VS Code Extension + +Real-time secret detection in the editor with inline diagnostics, hover tooltips, and quick-fix actions. Install from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=ppiankov.pastewatch). + +### Environment Variables + +| Variable | Effect | +|----------|--------| +| `PW_GUARD=0` | Disable `guard` and `scan --check` - all commands allowed, no scanning. Set before starting the agent session. | + +### Pre-commit Hook + +```bash +# Install hook +pastewatch-cli hook install + +# Append to existing hook +pastewatch-cli hook install --append + +# Remove hook +pastewatch-cli hook uninstall +``` + +### Baseline Diff + +Create a baseline of known findings, then only report new ones: + +```bash +pastewatch-cli baseline create --dir . --output .pastewatch-baseline.json +pastewatch-cli scan --dir . --baseline .pastewatch-baseline.json --check +``` + +### Config Init + +Generate project configuration files: + +```bash +pastewatch-cli init # creates .pastewatch.json and .pastewatch-allow +pastewatch-cli init --profile banking # banking profile: JDBC, medium severity, internal host detection +pastewatch-cli init --force # overwrite existing files +``` + +**Banking profile** sets `mcpMinSeverity: medium` (catches IPs and internal hostnames), enables JDBC URL detection, adds example `customRules` for service accounts and internal URIs, and pre-fills `sensitiveIPPrefixes` with all RFC 1918 ranges. Replace `YOURBANK` in `sensitiveHosts` with your domain. + +Config resolution cascade: CWD `.pastewatch.json` > `~/.config/pastewatch/config.json` > defaults. + +### Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Clean | +| 1 | Internal error | +| 2 | Invalid args | +| 6 | Findings detected | + +### Stdin Filename Hint + +When piping content via stdin, use `--stdin-filename` to enable format-aware parsing: + +```bash +cat .env | pastewatch-cli scan --stdin-filename .env --check +git show HEAD:config.yml | pastewatch-cli scan --stdin-filename config.yml +``` + +### Inline Allowlist + +Suppress findings on a specific line by adding a `pastewatch:allow` comment: + +```env +SAFE_API_KEY=test_key_123 # pastewatch:allow +``` + +Works with any comment style (`#`, `//`, `/* */`). + +### Pre-commit Framework (pre-commit.com) + +```yaml +# .pre-commit-config.yaml +repos: + - repo: https://github.com/ppiankov/pastewatch + rev: v0.24.1 + hooks: + - id: pastewatch +``` + +Requires `pastewatch-cli` installed via Homebrew. + +### Pre-commit Hook (manual) + +```bash +#!/bin/sh +git diff --cached --diff-filter=d | pastewatch-cli scan --check +``` + +### Format-Aware Scanning + +When scanning `.env`, `.json`, `.yml`/`.yaml`, `.properties`/`.cfg`/`.ini`, or `.xml` files, pastewatch parses the file structure and scans values only. This reduces false positives from keys, comments, and structural elements. + +For XML files, pastewatch extracts values from sensitive tags (``, ``, ``, etc.) covering ClickHouse, Hadoop, and other XML-based configs. Custom tags can be added via the `xmlSensitiveTags` config field. + +### Allowlist + +Create a file with one value per line to suppress known-safe findings: + +``` +test@example.com +192.168.1.1 +# Comments start with # +``` + +### Custom Rules + +Define additional patterns in a JSON file: + +```json +[ + {"name": "Internal ID", "pattern": "MYCO-[0-9]{6}"}, + {"name": "Internal URL", "pattern": "https://internal\\.corp\\.net/\\S+"} +] +``` + +--- + +## Agent Integration + +Install via Homebrew: + +```bash +brew install ppiankov/tap/pastewatch +``` + +Or download the binary: + +```bash +curl -LO https://github.com/ppiankov/pastewatch/releases/latest/download/pastewatch-cli +chmod +x pastewatch-cli +sudo mv pastewatch-cli /usr/local/bin/ +``` + +**For AI coding agents**: Use MCP redacted read/write to prevent secret leakage - see [docs/agent-safety.md](docs/agent-safety.md) for setup. + +**For CI/CD**: Use the CLI scan command or [GitHub Action](https://github.com/ppiankov/pastewatch-action). + +Agents: read [`docs/SKILL.md`](docs/SKILL.md) for commands, flags, config files, detection types, and exit codes. + +--- + ## Configuration -Optional configuration file: `~/.config/pastewatch/config.json` +### Config files + +| File | Location | Purpose | Created By | +|------|----------|---------|------------| +| `.pastewatch.json` | Project root | Project-level config (rules, allowlists, hosts) | `pastewatch-cli init` | +| `~/.config/pastewatch/config.json` | Home | User-level defaults | Manual / GUI app | +| `.pastewatch-allow` | Project root | Value allowlist (one per line, `#` comments) | `pastewatch-cli init` | +| `.pastewatchignore` | Project root | Path exclusion patterns (glob, like `.gitignore`) | Manual | +| `.pastewatch-baseline.json` | Project root | Known findings baseline | `pastewatch-cli baseline create` | + +Resolution cascade: CWD `.pastewatch.json` > `~/.config/pastewatch/config.json` > built-in defaults. + +### `.pastewatch.json` schema ```json { "enabled": true, - "enabledTypes": ["Email", "Phone", "IP", "AWS Key", "API Key", "UUID", "DB Connection", "SSH Key", "JWT", "Card"], + "enabledTypes": ["Email", "AWS Key", "API Key", "Credential", "High Entropy"], "showNotifications": true, - "soundEnabled": false + "soundEnabled": false, + "allowedValues": ["test@example.com"], + "allowedPatterns": ["sk_test_.*", "EXAMPLE_.*"], + "customRules": [ + {"name": "Internal ID", "pattern": "MYCO-[0-9]{6}", "severity": "medium"} + ], + "safeHosts": [".internal.company.com"], + "sensitiveHosts": [".local", "secrets.vault.internal.net"], + "sensitiveIPPrefixes": ["172.16.", "10."], + "mcpMinSeverity": "high", + "placeholderPrefix": "REDACTED_PLACEHOLDER_" } ``` -All settings can also be changed via the menubar dropdown. +| Field | Type | Description | +|-------|------|-------------| +| `enabled` | bool | Enable/disable scanning globally | +| `enabledTypes` | string[] | Detection types to activate (default: all except High Entropy) | +| `showNotifications` | bool | System notifications on GUI obfuscation | +| `soundEnabled` | bool | Sound on GUI obfuscation | +| `allowedValues` | string[] | Exact values to suppress (merged with `.pastewatch-allow`) | +| `allowedPatterns` | string[] | Regex patterns for value suppression (wrapped in `^(...)$`) | +| `customRules` | object[] | Additional regex patterns with name, pattern, optional severity | +| `safeHosts` | string[] | Hostnames excluded from detection (leading dot = suffix match) | +| `sensitiveHosts` | string[] | Hostnames always detected (overrides safe hosts, catches 2-segment hosts like `.local`) | +| `sensitiveIPPrefixes` | string[] | IP prefixes always detected (overrides built-in exclude list, e.g., `172.16.`) | +| `mcpMinSeverity` | string | Default severity threshold for MCP redacted reads (default: `high`) | +| `placeholderPrefix` | string? | Custom prefix for MCP placeholders (e.g., `REDACTED_` produces `REDACTED_001`). Default: `null` (uses `__PW_TYPE_N__` format) | + +GUI settings can also be changed via the menubar dropdown. --- @@ -188,38 +751,29 @@ If a feature increases complexity without reducing risk, it is rejected. ## Platform Support -macOS 14+ on Apple Silicon (M1 and newer). - -Intel-based Macs are not supported. +| Platform | Component | Status | +|----------|-----------|--------| +| macOS 14+ (Apple Silicon) | GUI + CLI | Supported | +| Linux x86_64 | CLI only | Supported | ---- - -## Project Family - -Pastewatch applies **Principiis obsta** at the clipboard boundary. It is part of a family of tools applying the same principle at different surfaces: - -| Project | Boundary | Intervention Point | -|---------|----------|-------------------| -| [Chainwatch](https://github.com/ppiankov/chainwatch) | AI agent execution | Before tool calls | -| **Pastewatch** | Data transmission | Before paste | -| [VaultSpectre](https://github.com/ppiankov/vaultspectre) | Secrets lifecycle | Before exposure | -| [Relay](https://github.com/ppiankov/relay) | Human connection | Before isolation compounds | - -Same principle. Different surfaces. Consistent philosophy. +Intel-based Macs are not supported and there are no plans to add prebuilt binaries. Intel Mac users can compile from source (`swift build -c release`). The GUI (clipboard monitoring) is macOS-only. --- ## Documentation -- [docs/design-baseline.md](docs/design-baseline.md) — Core philosophy and design priorities -- [docs/hard-constraints.md](docs/hard-constraints.md) — Non-negotiable rules -- [docs/status.md](docs/status.md) — Current scope and non-goals +- [docs/agent-integration.md](docs/agent-integration.md) - Consolidated agent reference (enforcement matrix, MCP setup, hooks, config) +- [docs/agent-setup.md](docs/agent-setup.md) - Per-agent MCP setup (Claude Code, Claude Desktop, Cline, Cursor, OpenCode, Codex CLI, Qwen Code) +- [docs/agent-safety.md](docs/agent-safety.md) - Agent safety guide (layered defenses for AI coding agents) +- [docs/examples/](docs/examples/) - Ready-to-use agent configs (Claude Code, Cline, Cursor) +- [docs/hard-constraints.md](docs/hard-constraints.md) - Design philosophy and non-negotiable rules +- [docs/status.md](docs/status.md) - Current scope and non-goals --- ## License -MIT License. +[MIT License](LICENSE). Use it. Fork it. Modify it. @@ -227,8 +781,54 @@ Do not pretend it guarantees compliance or safety. --- -## Status - -**MVP** — Experimental prototype. - -The core detection and obfuscation work. Edge cases exist. Feedback welcome. +## Project Status + +**Status: Stable** · **v0.24.1** · Active development + +| Milestone | Status | +|-----------|--------| +| Core detection (30 types) | Complete | +| Clipboard obfuscation | Complete | +| CLI scan mode | Complete | +| macOS menubar app | Complete | +| CI pipeline (test/lint) | Complete | +| SKILL.md agent integration | Complete | +| Homebrew distribution | Complete | +| SARIF 2.1.0 output | Complete | +| Directory scanning | Complete | +| Format-aware parsing | Complete | +| Allowlist / custom rules | Complete | +| MCP server | Complete | +| Baseline diff mode | Complete | +| Pre-commit hook installer | Complete | +| Config init / resolution | Complete | +| Linux CLI binary | Complete | +| Severity levels | Complete | +| Inline allowlist comments | Complete | +| Pre-commit framework | Complete | +| Stdin filename hint | Complete | +| Severity threshold (--fail-on-severity) | Complete | +| File output (--output) | Complete | +| Markdown output format | Complete | +| Cloud credentials (Slack, Discord, Azure, GCP) | Complete | +| Custom rule severity | Complete | +| .pastewatchignore | Complete | +| Explain subcommand | Complete | +| Config check subcommand | Complete | +| MCP redacted read/write | Complete | +| MCP per-agent severity thresholds | Complete | +| MCP audit logging | Complete | +| Bash command guard (pipes, subshells, redirects) | Complete | +| Guard: database CLIs, infra tools, scripting interpreters | Complete | +| Read/Write tool guards | Complete | +| Fix subcommand (secret externalization) | Complete | +| Inventory subcommand (posture reports) | Complete | +| Doctor subcommand (health check) | Complete | +| Setup subcommand (agent auto-setup) | Complete | +| Report subcommand (session reports) | Complete | +| Canary subcommand (leak detection) | Complete | +| Git diff scanning | Complete | +| Git history scanning | Complete | +| Entropy-based detection | Complete | +| VS Code extension | Complete | +| Host/IP config (safeHosts, sensitiveHosts, sensitiveIPPrefixes) | Complete | diff --git a/SECURITY.md b/SECURITY.md index d3cce24..c198cb0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,9 +14,11 @@ The clipboard content never leaves your machine. | Threat | Protection | |--------|------------| -| Accidental paste of secrets into AI chat | Obfuscation before paste | +| Accidental paste of secrets into AI chat | Obfuscation before paste (GUI) | | Credential leakage to LLM training data | Data never reaches the service | | API key exposure in prompts | Pattern-based detection and replacement | +| Secret leakage via AI coding agents | MCP server redacts secrets, agent sees only placeholders | +| Secrets committed to repositories | Pre-commit hook and CLI scanning | ### What Pastewatch Does NOT Protect Against @@ -30,28 +32,28 @@ The clipboard content never leaves your machine. ### Limitations -Pastewatch is an **MVP prototype**. Known limitations: +Known limitations: 1. **Detection is conservative** — Unknown secret formats will not be detected 2. **No encryption** — Obfuscated content uses plaintext placeholders -3. **No audit log** — Obfuscation events are not logged -4. **Memory only** — Mappings are not persisted (by design) -5. **macOS only** — No Windows/Linux support +3. **Memory only** — Mappings are not persisted (by design) +4. **GUI is macOS only** — CLI and MCP server run on macOS and Linux ## Security Boundaries ### What Pastewatch Can Access -- System clipboard (read and write) +- System clipboard (read and write) — GUI only +- Files and directories — CLI scan and MCP read/write tools - Configuration file at `~/.config/pastewatch/config.json` -- System notification service +- System notification service — GUI only +- MCP audit log file (if `--audit-log` specified) ### What Pastewatch Cannot Access - Network - Other applications' data - Keychain -- File system (beyond config) - Screen content ## Responsible Disclosure diff --git a/Sources/Pastewatch/ClipboardMonitor.swift b/Sources/Pastewatch/ClipboardMonitor.swift index a4793da..77a335d 100644 --- a/Sources/Pastewatch/ClipboardMonitor.swift +++ b/Sources/Pastewatch/ClipboardMonitor.swift @@ -1,5 +1,6 @@ import AppKit import Foundation +import PastewatchCore /// Monitors the macOS clipboard for changes. /// diff --git a/Sources/Pastewatch/DetectionRules.swift b/Sources/Pastewatch/DetectionRules.swift deleted file mode 100644 index b49031c..0000000 --- a/Sources/Pastewatch/DetectionRules.swift +++ /dev/null @@ -1,295 +0,0 @@ -import Foundation - -/// Deterministic detection rules for sensitive data. -/// No ML. No confidence scores. No guessing. -/// -/// Each rule is a regex pattern that matches high-confidence patterns only. -/// False negatives are preferred over false positives. -struct DetectionRules { - - /// All detection rules, ordered by specificity (most specific first). - static let rules: [(SensitiveDataType, NSRegularExpression)] = { - var result: [(SensitiveDataType, NSRegularExpression)] = [] - - // SSH Private Key - very high confidence - // Matches the header of SSH private keys - if let regex = try? NSRegularExpression( - pattern: #"-----BEGIN\s+(RSA|DSA|EC|OPENSSH)\s+PRIVATE\s+KEY-----"#, - options: [] - ) { - result.append((.sshPrivateKey, regex)) - } - - // AWS Access Key ID - high confidence - // Format: AKIA followed by 16 alphanumeric characters - if let regex = try? NSRegularExpression( - pattern: #"\b(AKIA|ABIA|ACCA|ASIA)[0-9A-Z]{16}\b"#, - options: [] - ) { - result.append((.awsKey, regex)) - } - - // AWS Secret Access Key - high confidence - // 40 character base64-ish string (often near AKIA keys) - if let regex = try? NSRegularExpression( - pattern: #"\b[A-Za-z0-9/+=]{40}\b"#, - options: [] - ) { - result.append((.awsKey, regex)) - } - - // JWT Token - high confidence - // Three base64url segments separated by dots - if let regex = try? NSRegularExpression( - pattern: #"\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b"#, - options: [] - ) { - result.append((.jwtToken, regex)) - } - - // Database Connection String - high confidence - // PostgreSQL, MySQL, MongoDB connection strings - if let regex = try? NSRegularExpression( - pattern: #"(postgres|postgresql|mysql|mongodb|redis)://[^\s]+"#, - options: [.caseInsensitive] - ) { - result.append((.dbConnectionString, regex)) - } - - // Generic API Key patterns - high confidence - // Common prefixes: sk-, pk-, api_, key_, token_ - if let regex = try? NSRegularExpression( - pattern: #"\b(sk|pk|api|key|token|secret|bearer)[_-][A-Za-z0-9]{20,}\b"#, - options: [.caseInsensitive] - ) { - result.append((.genericApiKey, regex)) - } - - // GitHub Token - high confidence - if let regex = try? NSRegularExpression( - pattern: #"\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b"#, - options: [] - ) { - result.append((.genericApiKey, regex)) - } - - // Stripe API Key - high confidence - if let regex = try? NSRegularExpression( - pattern: #"\b(sk|pk|rk)_(test|live)_[A-Za-z0-9]{24,}\b"#, - options: [] - ) { - result.append((.genericApiKey, regex)) - } - - // UUID - high confidence - // Standard UUID v4 format - if let regex = try? NSRegularExpression( - pattern: #"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b"#, - options: [.caseInsensitive] - ) { - result.append((.uuid, regex)) - } - - // Credit Card - high confidence - // Visa, Mastercard, Amex, Discover patterns with optional separators - if let regex = try? NSRegularExpression( - pattern: #"\b(?:4[0-9]{3}|5[1-5][0-9]{2}|3[47][0-9]{2}|6(?:011|5[0-9]{2}))[- ]?[0-9]{4}[- ]?[0-9]{4}[- ]?[0-9]{4}\b"#, - options: [] - ) { - result.append((.creditCard, regex)) - } - - // IP Address - high confidence - // IPv4 with valid octet ranges (not 0.0.0.0 or localhost) - if let regex = try? NSRegularExpression( - pattern: #"\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b"#, - options: [] - ) { - result.append((.ipAddress, regex)) - } - - // Email Address - high confidence - // Standard email format, excludes example.com - if let regex = try? NSRegularExpression( - pattern: #"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"#, - options: [] - ) { - result.append((.email, regex)) - } - - // Phone Number - conservative, high confidence - // International format: +XX XXXX XXXX XXXX (flexible spacing/separators) - // Matches: Malaysian (+60), Indian (+91), Russian (+7), UK (+44), German (+49), etc. - if let regex = try? NSRegularExpression( - pattern: #"\+[1-9][0-9]{0,2}[-.\s]?[0-9]{1,4}[-.\s]?[0-9]{2,4}[-.\s]?[0-9]{2,4}[-.\s]?[0-9]{0,4}"#, - options: [] - ) { - result.append((.phone, regex)) - } - - // US format with area code in parentheses: (XXX) XXX-XXXX - if let regex = try? NSRegularExpression( - pattern: #"\([0-9]{3}\)\s?[0-9]{3}[-.\s]?[0-9]{4}"#, - options: [] - ) { - result.append((.phone, regex)) - } - - // Compact international without spaces (common in logs/configs) - // E.164 format: +XXXXXXXXXXX (10-15 digits after +) - if let regex = try? NSRegularExpression( - pattern: #"\+[1-9][0-9]{9,14}\b"#, - options: [] - ) { - result.append((.phone, regex)) - } - - // Local formats without country code prefix - // Malaysian local: 01X-XXXXXXX or 01XXXXXXXX (10-11 digits starting with 01) - if let regex = try? NSRegularExpression( - pattern: #"\b01[0-9][-.\s]?[0-9]{3,4}[-.\s]?[0-9]{4}\b"#, - options: [] - ) { - result.append((.phone, regex)) - } - - // Malaysian compact: 01XXXXXXXX (10-11 digits, no separators) - if let regex = try? NSRegularExpression( - pattern: #"\b01[0-9]{8,9}\b"#, - options: [] - ) { - result.append((.phone, regex)) - } - - // Russian local: 8XXXXXXXXXX (11 digits starting with 8) - if let regex = try? NSRegularExpression( - pattern: #"\b8[-.\s]?[0-9]{3}[-.\s]?[0-9]{3}[-.\s]?[0-9]{2}[-.\s]?[0-9]{2}\b"#, - options: [] - ) { - result.append((.phone, regex)) - } - - // Thai international dial: 00X-XXXXXXXXX (starts with 00) - if let regex = try? NSRegularExpression( - pattern: #"\b00[0-9]{1,3}[-.\s]?[0-9]{2,4}[-.\s]?[0-9]{3,4}[-.\s]?[0-9]{3,4}\b"#, - options: [] - ) { - result.append((.phone, regex)) - } - - return result - }() - - /// Patterns to exclude from detection (reduce false positives). - /// Note: We intentionally do NOT exclude test/example domains for emails - /// because in production, all emails should be detected. - static let exclusionPatterns: [NSRegularExpression] = { - var patterns: [NSRegularExpression] = [] - - // Exclude localhost IP only (not general private ranges) - if let regex = try? NSRegularExpression( - pattern: #"^(127\.0\.0\.1|0\.0\.0\.0)$"#, - options: [] - ) { - patterns.append(regex) - } - - return patterns - }() - - /// Scan content for sensitive data. - /// Returns all matches found. - static func scan(_ content: String, config: PastewatchConfig) -> [DetectedMatch] { - var matches: [DetectedMatch] = [] - var matchedRanges: [Range] = [] - - for (type, regex) in rules { - // Skip disabled types - guard config.isTypeEnabled(type) else { continue } - - let nsRange = NSRange(content.startIndex..., in: content) - let regexMatches = regex.matches(in: content, options: [], range: nsRange) - - for match in regexMatches { - guard let range = Range(match.range, in: content) else { continue } - - // Skip if this range overlaps with an already matched range - let overlaps = matchedRanges.contains { existingRange in - range.overlaps(existingRange) - } - if overlaps { continue } - - let value = String(content[range]) - - // Check exclusion patterns - if shouldExclude(value) { continue } - - // Additional validation per type - if !isValidMatch(value, type: type) { continue } - - matches.append(DetectedMatch(type: type, value: value, range: range)) - matchedRanges.append(range) - } - } - - return matches - } - - /// Check if a value should be excluded from detection. - private static func shouldExclude(_ value: String) -> Bool { - for pattern in exclusionPatterns { - let nsRange = NSRange(value.startIndex..., in: value) - if pattern.firstMatch(in: value, options: [], range: nsRange) != nil { - return true - } - } - return false - } - - /// Additional validation for specific types. - private static func isValidMatch(_ value: String, type: SensitiveDataType) -> Bool { - switch type { - case .ipAddress: - // Exclude common non-sensitive IPs - let excluded = ["0.0.0.0", "127.0.0.1", "255.255.255.255"] - if excluded.contains(value) { return false } - - // Exclude IPs that look like version numbers (context check) - // This is a heuristic — we're conservative - return true - - case .phone: - // Require minimum length to avoid matching random numbers - let digitsOnly = value.filter { $0.isNumber } - return digitsOnly.count >= 10 - - case .creditCard: - // Luhn algorithm validation - return isValidLuhn(value) - - case .email: - // Basic validation — regex already handles most - return value.contains("@") && value.contains(".") - - default: - return true - } - } - - /// Luhn algorithm for credit card validation. - private static func isValidLuhn(_ value: String) -> Bool { - let digits = value.compactMap { $0.wholeNumberValue } - guard digits.count >= 13 else { return false } - - var sum = 0 - for (index, digit) in digits.reversed().enumerated() { - if index % 2 == 1 { - let doubled = digit * 2 - sum += doubled > 9 ? doubled - 9 : doubled - } else { - sum += digit - } - } - return sum % 10 == 0 - } -} diff --git a/Sources/Pastewatch/MenuBarView.swift b/Sources/Pastewatch/MenuBarView.swift index eb47e4d..55b8b16 100644 --- a/Sources/Pastewatch/MenuBarView.swift +++ b/Sources/Pastewatch/MenuBarView.swift @@ -1,3 +1,4 @@ +import PastewatchCore import SwiftUI /// Main menubar view for Pastewatch. @@ -91,24 +92,24 @@ struct MenuBarView: View { private var actionsSection: some View { VStack(spacing: 0) { - Button(action: { monitor.toggle() }) { + Button(action: { monitor.toggle() }, label: { HStack { Image(systemName: toggleIcon) Text(toggleText) Spacer() } - } + }) .buttonStyle(.plain) .padding(.horizontal, 12) .padding(.vertical, 6) - Button(action: { showingSettings.toggle() }) { + Button(action: { showingSettings.toggle() }, label: { HStack { Image(systemName: "gear") Text("Settings...") Spacer() } - } + }) .buttonStyle(.plain) .padding(.horizontal, 12) .padding(.vertical, 6) @@ -119,13 +120,13 @@ struct MenuBarView: View { } private var footerSection: some View { - Button(action: { NSApplication.shared.terminate(nil) }) { + Button(action: { NSApplication.shared.terminate(nil) }, label: { HStack { Image(systemName: "power") Text("Quit Pastewatch") Spacer() } - } + }) .buttonStyle(.plain) .padding(.horizontal, 12) .padding(.vertical, 6) diff --git a/Sources/Pastewatch/NotificationManager.swift b/Sources/Pastewatch/NotificationManager.swift index 9d37724..759377c 100644 --- a/Sources/Pastewatch/NotificationManager.swift +++ b/Sources/Pastewatch/NotificationManager.swift @@ -1,4 +1,5 @@ import Foundation +import PastewatchCore import UserNotifications /// Manages system notifications for Pastewatch. @@ -18,7 +19,7 @@ final class NotificationManager: NSObject { /// Request notification permissions. func requestPermissions() { let center = UNUserNotificationCenter.current() - center.requestAuthorization(options: [.alert, .sound]) { granted, error in + center.requestAuthorization(options: [.alert, .sound]) { _, error in if let error = error { print("Notification permission error: \(error.localizedDescription)") } diff --git a/Sources/Pastewatch/PastewatchApp.swift b/Sources/Pastewatch/PastewatchApp.swift index dd9c479..3710b93 100644 --- a/Sources/Pastewatch/PastewatchApp.swift +++ b/Sources/Pastewatch/PastewatchApp.swift @@ -1,6 +1,8 @@ +import PastewatchCore import SwiftUI -/// Pastewatch — Local macOS utility that obfuscates sensitive data before paste. +/// Pastewatch — Detects and obfuscates sensitive data before it reaches AI systems +/// via clipboard monitoring, CLI scanner, MCP server, API proxy, shell guard hooks, and VS Code extension. /// /// Core principle: Principiis obsta — resist the beginnings. /// If sensitive data never enters the prompt, the incident does not exist. diff --git a/Sources/Pastewatch/Types.swift b/Sources/Pastewatch/Types.swift deleted file mode 100644 index 3ff1ed8..0000000 --- a/Sources/Pastewatch/Types.swift +++ /dev/null @@ -1,101 +0,0 @@ -import Foundation - -/// Detected sensitive data types. -/// Each type has deterministic detection rules — no ML, no guessing. -enum SensitiveDataType: String, CaseIterable { - case email = "Email" - case phone = "Phone" - case ipAddress = "IP" - case awsKey = "AWS Key" - case genericApiKey = "API Key" - case uuid = "UUID" - case dbConnectionString = "DB Connection" - case sshPrivateKey = "SSH Key" - case jwtToken = "JWT" - case creditCard = "Card" -} - -/// A single detected match in the clipboard content. -struct DetectedMatch: Identifiable, Equatable { - let id = UUID() - let type: SensitiveDataType - let value: String - let range: Range - - static func == (lhs: DetectedMatch, rhs: DetectedMatch) -> Bool { - lhs.id == rhs.id - } -} - -/// Result of scanning clipboard content. -struct ScanResult { - let originalContent: String - let matches: [DetectedMatch] - let obfuscatedContent: String - let timestamp: Date - - var hasMatches: Bool { !matches.isEmpty } - - /// Summary for notification display. - var summary: String { - guard hasMatches else { return "" } - - let grouped = Dictionary(grouping: matches, by: { $0.type }) - let parts = grouped.map { type, items in - "\(type.rawValue) (\(items.count))" - } - return parts.joined(separator: ", ") - } -} - -/// Application state. -enum AppState: Equatable { - case idle - case monitoring - case paused -} - -/// Configuration for Pastewatch. -/// Loaded from ~/.config/pastewatch/config.json if present. -struct PastewatchConfig: Codable { - var enabled: Bool - var enabledTypes: [String] - var showNotifications: Bool - var soundEnabled: Bool - - static let defaultConfig = PastewatchConfig( - enabled: true, - enabledTypes: SensitiveDataType.allCases.map { $0.rawValue }, - showNotifications: true, - soundEnabled: false - ) - - static let configPath: URL = { - let home = FileManager.default.homeDirectoryForCurrentUser - return home.appendingPathComponent(".config/pastewatch/config.json") - }() - - static func load() -> PastewatchConfig { - guard FileManager.default.fileExists(atPath: configPath.path) else { - return defaultConfig - } - - do { - let data = try Data(contentsOf: configPath) - return try JSONDecoder().decode(PastewatchConfig.self, from: data) - } catch { - return defaultConfig - } - } - - func save() throws { - let directory = PastewatchConfig.configPath.deletingLastPathComponent() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let data = try JSONEncoder().encode(self) - try data.write(to: PastewatchConfig.configPath) - } - - func isTypeEnabled(_ type: SensitiveDataType) -> Bool { - enabledTypes.contains(type.rawValue) - } -} diff --git a/Sources/PastewatchCLI/BaselineCommand.swift b/Sources/PastewatchCLI/BaselineCommand.swift new file mode 100644 index 0000000..aa7084a --- /dev/null +++ b/Sources/PastewatchCLI/BaselineCommand.swift @@ -0,0 +1,50 @@ +import ArgumentParser +import Foundation +import PastewatchCore + +struct BaselineGroup: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "baseline", + abstract: "Manage baseline of known findings", + subcommands: [Create.self] + ) +} + +extension BaselineGroup { + struct Create: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Create baseline from current scan results" + ) + + @Option(name: .long, help: "Directory to scan") + var dir: String + + @Option(name: [.short, .long], help: "Output file path") + var output: String = ".pastewatch-baseline.json" + + func run() throws { + let config = PastewatchConfig.resolve() + + guard FileManager.default.fileExists(atPath: dir) else { + FileHandle.standardError.write(Data("error: directory not found: \(dir)\n".utf8)) + throw ExitCode(rawValue: 2) + } + + let fileResults = try DirectoryScanner.scan(directory: dir, config: config) + + var entries: [BaselineEntry] = [] + for fr in fileResults { + for match in fr.matches { + entries.append(BaselineEntry.from(match: match, filePath: fr.filePath)) + } + } + + let baseline = BaselineFile(entries: entries) + try baseline.save(to: output) + + let totalFindings = fileResults.reduce(0) { $0 + $1.matches.count } + print("baseline created: \(entries.count) entries from \(totalFindings) findings in \(fileResults.count) files") + print("saved to \(output)") + } + } +} diff --git a/Sources/PastewatchCLI/CanaryCommand.swift b/Sources/PastewatchCLI/CanaryCommand.swift new file mode 100644 index 0000000..d5591ee --- /dev/null +++ b/Sources/PastewatchCLI/CanaryCommand.swift @@ -0,0 +1,123 @@ +import ArgumentParser +import Foundation +import PastewatchCore + +struct CanaryGroup: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "canary", + abstract: "Canary secrets for AI agent leak detection", + subcommands: [Generate.self, Verify.self, Check.self] + ) +} + +extension CanaryGroup { + struct Generate: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Generate canary tokens for leak detection" + ) + + @Option(name: .long, help: "Prefix embedded in canary values for source tracking (default: canary)") + var prefix: String = "canary" + + @Option(name: .long, help: "Output file path (default: .pastewatch-canaries.json)") + var output: String = ".pastewatch-canaries.json" + + func run() throws { + let manifest = CanaryGenerator.generate(prefix: prefix) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(manifest) + try data.write(to: URL(fileURLWithPath: output)) + + print("Generated \(manifest.canaries.count) canary tokens → \(output)") + for token in manifest.canaries { + print(" \(token.type): \(token.value)") + } + } + } + + struct Verify: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Verify canaries are detected by pastewatch" + ) + + @Option(name: .long, help: "Path to canary manifest (default: .pastewatch-canaries.json)") + var file: String = ".pastewatch-canaries.json" + + func validate() throws { + guard FileManager.default.fileExists(atPath: file) else { + throw ValidationError("canary manifest not found: \(file)") + } + } + + func run() throws { + let data = try Data(contentsOf: URL(fileURLWithPath: file)) + let manifest = try JSONDecoder().decode(CanaryManifest.self, from: data) + let results = CanaryGenerator.verify(manifest: manifest) + + var allPassed = true + for result in results { + let status = result.detected ? "PASS" : "FAIL" + let detail = result.detectedAs.map { " (as \($0))" } ?? "" + print(" [\(status)] \(result.type)\(detail)") + if !result.detected { allPassed = false } + } + + if allPassed { + print("\nAll \(results.count) canaries detected.") + } else { + let failed = results.filter { !$0.detected }.count + FileHandle.standardError.write( + Data("\(failed) canary type(s) not detected\n".utf8) + ) + throw ExitCode(rawValue: 1) + } + } + } + + struct Check: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Check if canary values leaked in external logs" + ) + + @Option(name: .long, help: "Path to log file to search (CloudTrail JSON, any text)") + var log: String + + @Option(name: .long, help: "Path to canary manifest (default: .pastewatch-canaries.json)") + var file: String = ".pastewatch-canaries.json" + + func validate() throws { + guard FileManager.default.fileExists(atPath: file) else { + throw ValidationError("canary manifest not found: \(file)") + } + guard FileManager.default.fileExists(atPath: log) else { + throw ValidationError("log file not found: \(log)") + } + } + + func run() throws { + let manifestData = try Data(contentsOf: URL(fileURLWithPath: file)) + let manifest = try JSONDecoder().decode(CanaryManifest.self, from: manifestData) + let logContent = try String(contentsOfFile: log, encoding: .utf8) + let results = CanaryGenerator.checkLog(manifest: manifest, logContent: logContent) + + var anyLeaked = false + for result in results { + let status = result.found ? "LEAKED" : "clean" + print(" [\(status)] \(result.type)") + if result.found { anyLeaked = true } + } + + if anyLeaked { + let leaked = results.filter { $0.found }.count + FileHandle.standardError.write( + Data("WARNING: \(leaked) canary value(s) found in log — secrets leaked\n".utf8) + ) + throw ExitCode(rawValue: 1) + } else { + print("\nNo canary values found in log. Clean.") + } + } + } +} diff --git a/Sources/PastewatchCLI/ConfigCommand.swift b/Sources/PastewatchCLI/ConfigCommand.swift new file mode 100644 index 0000000..b7d2ad1 --- /dev/null +++ b/Sources/PastewatchCLI/ConfigCommand.swift @@ -0,0 +1,34 @@ +import ArgumentParser +import Foundation +import PastewatchCore + +struct ConfigGroup: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "config", + abstract: "Configuration management", + subcommands: [Check.self] + ) +} + +extension ConfigGroup { + struct Check: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Validate configuration files" + ) + + @Option(name: .long, help: "Path to config file (uses resolved config if omitted)") + var file: String? + + func run() throws { + let result = ConfigValidator.validate(path: file) + if result.isValid { + print("config: valid") + } else { + for error in result.errors { + FileHandle.standardError.write(Data("error: \(error)\n".utf8)) + } + throw ExitCode(rawValue: 2) + } + } + } +} diff --git a/Sources/PastewatchCLI/DashboardCommand.swift b/Sources/PastewatchCLI/DashboardCommand.swift new file mode 100644 index 0000000..1dfb59c --- /dev/null +++ b/Sources/PastewatchCLI/DashboardCommand.swift @@ -0,0 +1,148 @@ +import ArgumentParser +import Foundation +import PastewatchCore + +struct DashboardCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "dashboard", + abstract: "Aggregate view across multiple audit log sessions" + ) + + @Option(name: .long, help: "Directory containing audit log files") + var dir: String = "/tmp" + + @Option(name: .long, help: "Only include entries since date (ISO format)") + var since: String? + + @Option(name: .long, help: "Output format: text, json, markdown") + var format: DashboardFormat = .text + + @Option(name: .long, help: "Write output to file instead of stdout") + var output: String? + + func run() throws { + var sinceDate: Date? + if let since { + let df = ISO8601DateFormatter() + df.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + sinceDate = df.date(from: since) + if sinceDate == nil { + let df2 = ISO8601DateFormatter() + sinceDate = df2.date(from: since) + } + } + + let dashboard = DashboardBuilder.build(logDirectory: dir, since: sinceDate) + + if let outputPath = output { + FileManager.default.createFile(atPath: outputPath, contents: nil) + guard let handle = FileHandle(forWritingAtPath: outputPath) else { + FileHandle.standardError.write(Data("error: could not write to \(outputPath)\n".utf8)) + throw ExitCode(rawValue: 2) + } + freopen(outputPath, "w", stdout) + _ = handle + } + + switch format { + case .text: + printText(dashboard) + case .json: + printJSON(dashboard) + case .markdown: + printMarkdown(dashboard) + } + } + + // MARK: - Text + + private func printText(_ d: Dashboard) { + print("Pastewatch Dashboard") + print("====================\n") + print("Sessions: \(d.sessions)") + if let earliest = d.period.earliest, let latest = d.period.latest { + print("Period: \(earliest) — \(latest)") + } + print("") + print("Files read: \(d.summary.filesRead)") + print("Files written: \(d.summary.filesWritten)") + print("Secrets redacted: \(d.summary.secretsRedacted)") + print("Placeholders resolved:\(d.summary.placeholdersResolved)") + print("Unresolved: \(d.summary.unresolvedPlaceholders)") + print("Scans: \(d.summary.scans)") + print("Scan findings: \(d.summary.scanFindings)") + + if !d.topTypes.isEmpty { + print("\nTop secret types:") + for tc in d.topTypes.prefix(10) { + print(" \(tc.type): \(tc.count) (\(tc.severity))") + } + } + + if !d.hotFiles.isEmpty { + print("\nHot files:") + for fa in d.hotFiles.prefix(10) { + print(" \(fa.file): \(fa.reads)R \(fa.writes)W \(fa.secretsRedacted) redacted") + } + } + + print("\nVerdict: \(d.verdict)") + } + + // MARK: - JSON + + private func printJSON(_ d: Dashboard) { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + if let data = try? encoder.encode(d) { + print(String(data: data, encoding: .utf8)!) + } + } + + // MARK: - Markdown + + private func printMarkdown(_ d: Dashboard) { + print("# Pastewatch Dashboard\n") + print("**Generated:** \(d.generatedAt) ") + print("**Sessions:** \(d.sessions) ") + if let earliest = d.period.earliest, let latest = d.period.latest { + print("**Period:** \(earliest) — \(latest) ") + } + + print("\n## Summary\n") + print("| Metric | Count |") + print("|--------|-------|") + print("| Files read | \(d.summary.filesRead) |") + print("| Files written | \(d.summary.filesWritten) |") + print("| Secrets redacted | \(d.summary.secretsRedacted) |") + print("| Placeholders resolved | \(d.summary.placeholdersResolved) |") + print("| Unresolved | \(d.summary.unresolvedPlaceholders) |") + print("| Scans | \(d.summary.scans) |") + print("| Scan findings | \(d.summary.scanFindings) |") + + if !d.topTypes.isEmpty { + print("\n## Top Secret Types\n") + print("| Type | Count | Severity |") + print("|------|-------|----------|") + for tc in d.topTypes.prefix(10) { + print("| \(tc.type) | \(tc.count) | \(tc.severity) |") + } + } + + if !d.hotFiles.isEmpty { + print("\n## Hot Files\n") + print("| File | Reads | Writes | Redacted |") + print("|------|-------|--------|----------|") + for fa in d.hotFiles.prefix(10) { + print("| \(fa.file) | \(fa.reads) | \(fa.writes) | \(fa.secretsRedacted) |") + } + } + + print("\n## Verdict\n") + print("**\(d.verdict)**") + } +} + +enum DashboardFormat: String, ExpressibleByArgument { + case text, json, markdown +} diff --git a/Sources/PastewatchCLI/DoctorCommand.swift b/Sources/PastewatchCLI/DoctorCommand.swift new file mode 100644 index 0000000..d9fc5c4 --- /dev/null +++ b/Sources/PastewatchCLI/DoctorCommand.swift @@ -0,0 +1,300 @@ +import ArgumentParser +import Foundation +import PastewatchCore + +private struct CheckResult { + let check: String + let status: String + let detail: String +} + +struct Doctor: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Check installation health and show active configuration" + ) + + @Flag(name: .long, help: "Output results as JSON") + var json = false + + func run() throws { + var checks: [CheckResult] = [] + + // 1. CLI version and binary path + let version = "0.24.1" + let binaryPath = ProcessInfo.processInfo.arguments.first ?? "unknown" + checks.append(CheckResult(check: "cli", status: "ok", detail: "v\(version) at \(binaryPath)")) + + // 2. PATH check — is pastewatch-cli on PATH? + checks.append(checkOnPath()) + + // 3. Config resolution + checks.append(contentsOf: checkConfig()) + + // 4. Pre-commit hook + let hookResult = checkHook() + checks.append(CheckResult(check: "hook", status: hookResult.status, detail: hookResult.detail)) + + // 5. Allowlist file + checks.append(checkFile(".pastewatch-allow", label: "allowlist")) + + // 6. Ignore file + checks.append(checkFile(".pastewatchignore", label: "ignore")) + + // 7. Baseline file + checks.append(checkFile(".pastewatch-baseline.json", label: "baseline")) + + // 8. MCP server processes + checks.append(contentsOf: checkMCPProcesses()) + + // 9. Homebrew + let brewResult = checkHomebrew(currentVersion: version) + checks.append(CheckResult(check: "homebrew", status: brewResult.status, detail: brewResult.detail)) + + if json { + printJSON(checks) + } else { + printText(checks) + } + } + + private func checkOnPath() -> CheckResult { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/which") + process.arguments = ["pastewatch-cli"] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + do { + try process.run() + process.waitUntilExit() + if process.terminationStatus == 0 { + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let path = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return CheckResult(check: "path", status: "ok", detail: path) + } + } catch {} + return CheckResult(check: "path", status: "warn", detail: "pastewatch-cli not found on PATH") + } + + private func checkConfig() -> [CheckResult] { + var results: [CheckResult] = [] + let fm = FileManager.default + let cwd = fm.currentDirectoryPath + + let systemPath = PastewatchConfig.systemConfigPath + let projectPath = cwd + "/.pastewatch.json" + let userPath = PastewatchConfig.configPath.path + + let systemExists = fm.fileExists(atPath: systemPath) + let projectExists = fm.fileExists(atPath: projectPath) + let userExists = fm.fileExists(atPath: userPath) + + if systemExists { + results.append(CheckResult(check: "config", status: "ok", detail: "system (admin): \(systemPath)")) + let validation = ConfigValidator.validate(path: systemPath) + if !validation.isValid { + for err in validation.errors { + results.append(CheckResult(check: "config", status: "warn", detail: err)) + } + } + if projectExists { + results.append(CheckResult( + check: "config", status: "info", + detail: "project config exists but overridden by system: \(projectPath)" + )) + } + if userExists { + results.append(CheckResult( + check: "config", status: "info", + detail: "user config exists but overridden by system: \(userPath)" + )) + } + } else if projectExists { + results.append(CheckResult(check: "config", status: "ok", detail: "project: \(projectPath)")) + let validation = ConfigValidator.validate(path: projectPath) + if !validation.isValid { + for err in validation.errors { + results.append(CheckResult(check: "config", status: "warn", detail: err)) + } + } + } else if userExists { + results.append(CheckResult(check: "config", status: "ok", detail: "user: \(userPath)")) + let validation = ConfigValidator.validate(path: userPath) + if !validation.isValid { + for err in validation.errors { + results.append(CheckResult(check: "config", status: "warn", detail: err)) + } + } + } else { + results.append(CheckResult(check: "config", status: "ok", detail: "defaults (no config file found)")) + } + + if !systemExists && projectExists && userExists { + results.append(CheckResult(check: "config", status: "info", detail: "user config exists but overridden: \(userPath)")) + } + + // Show mcpMinSeverity from resolved config + let config = PastewatchConfig.resolve() + results.append(CheckResult(check: "config", status: "info", detail: "mcpMinSeverity: \(config.mcpMinSeverity)")) + + return results + } + + private func checkHook() -> (status: String, detail: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = ["rev-parse", "--git-path", "hooks"] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + do { + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + return ("info", "not a git repository") + } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + var hooksDir = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !hooksDir.hasPrefix("/") { + hooksDir = FileManager.default.currentDirectoryPath + "/" + hooksDir + } + let hookPath = hooksDir + "/pre-commit" + guard FileManager.default.fileExists(atPath: hookPath) else { + return ("warn", "no pre-commit hook") + } + let content = (try? String(contentsOfFile: hookPath, encoding: .utf8)) ?? "" + if content.contains("BEGIN PASTEWATCH") { + return ("ok", "installed at \(hookPath)") + } + return ("warn", "pre-commit hook exists but no pastewatch section") + } catch { + return ("info", "not a git repository") + } + } + + private func checkFile(_ name: String, label: String) -> CheckResult { + let cwd = FileManager.default.currentDirectoryPath + let path = cwd + "/" + name + if FileManager.default.fileExists(atPath: path) { + return CheckResult(check: label, status: "ok", detail: path) + } + return CheckResult(check: label, status: "info", detail: "not found") + } + + private func checkMCPProcesses() -> [CheckResult] { + var results: [CheckResult] = [] + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep") + process.arguments = ["-fl", "pastewatch-cli.*mcp"] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + do { + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + results.append(CheckResult(check: "mcp", status: "info", detail: "no MCP server processes found")) + return results + } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let lines = output.split(separator: "\n") + .filter { $0.contains("pastewatch-cli mcp") } + + if lines.isEmpty { + results.append(CheckResult(check: "mcp", status: "info", detail: "no MCP server processes found")) + return results + } + + results.append(CheckResult(check: "mcp", status: "ok", detail: "\(lines.count) running")) + + for line in lines { + let parts = line.split(separator: " ", maxSplits: 1) + let pid = parts.first.map(String.init) ?? "?" + let cmdLine = parts.count > 1 ? String(parts[1]) : "" + + let severity = extractFlag(cmdLine, flag: "--min-severity") ?? "high (default)" + let auditLog = extractFlag(cmdLine, flag: "--audit-log") ?? "none" + + results.append(CheckResult( + check: "mcp", + status: "info", + detail: "PID \(pid): min-severity=\(severity), audit-log=\(auditLog)" + )) + } + } catch { + results.append(CheckResult(check: "mcp", status: "info", detail: "no MCP server processes found")) + } + return results + } + + private func extractFlag(_ cmdLine: String, flag: String) -> String? { + guard let flagRange = cmdLine.range(of: flag) else { return nil } + let afterFlag = cmdLine[flagRange.upperBound...].trimmingCharacters(in: .whitespaces) + return afterFlag.split(separator: " ").first.map(String.init) + } + + private func checkHomebrew(currentVersion: String) -> (status: String, detail: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["brew", "info", "--json=v2", "ppiankov/tap/pastewatch"] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + do { + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + return ("info", "not installed via Homebrew") + } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let formulae = json["formulae"] as? [[String: Any]], + let formula = formulae.first { + let formulaVersion = formula["versions"] as? [String: Any] + let stable = formulaVersion?["stable"] as? String ?? "unknown" + let installed = formula["installed"] as? [[String: Any]] + let installedVersion = installed?.first?["version"] as? String ?? "not installed" + var detail = "formula: \(stable), installed: \(installedVersion)" + if stable != currentVersion { + detail += " (formula outdated — CLI is \(currentVersion))" + return ("warn", detail) + } + if installedVersion != stable { + detail += " (run: brew upgrade ppiankov/tap/pastewatch)" + return ("warn", detail) + } + return ("ok", detail) + } + } catch {} + return ("info", "not installed via Homebrew") + } + + private func printText(_ checks: [CheckResult]) { + for entry in checks { + let icon: String + switch entry.status { + case "ok": icon = "ok" + case "warn": icon = "WARN" + case "info": icon = "--" + default: icon = "??" + } + let paddedLabel = entry.check.padding(toLength: 12, withPad: " ", startingAt: 0) + print(" [\(icon)] \(paddedLabel) \(entry.detail)") + } + } + + private func printJSON(_ checks: [CheckResult]) { + var entries: [String] = [] + for entry in checks { + let escapedDetail = entry.detail + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + entries.append(" {\"check\": \"\(entry.check)\", \"status\": \"\(entry.status)\", \"detail\": \"\(escapedDetail)\"}") + } + print("[\n\(entries.joined(separator: ",\n"))\n]") + } +} diff --git a/Sources/PastewatchCLI/ExplainCommand.swift b/Sources/PastewatchCLI/ExplainCommand.swift new file mode 100644 index 0000000..33e58ca --- /dev/null +++ b/Sources/PastewatchCLI/ExplainCommand.swift @@ -0,0 +1,43 @@ +import ArgumentParser +import Foundation +import PastewatchCore + +struct Explain: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Show detection type details" + ) + + @Argument(help: "Type name to explain (omit to list all)") + var typeName: String? + + func run() throws { + if let name = typeName { + guard let type = SensitiveDataType.allCases.first(where: { + $0.rawValue.lowercased() == name.lowercased() + }) else { + FileHandle.standardError.write(Data("error: unknown type '\(name)'\n".utf8)) + FileHandle.standardError.write(Data("available types: \(SensitiveDataType.allCases.map { $0.rawValue }.joined(separator: ", "))\n".utf8)) + throw ExitCode(rawValue: 2) + } + printDetail(type) + } else { + printAll() + } + } + + private func printAll() { + for type in SensitiveDataType.allCases { + print("\(type.rawValue) [\(type.severity.rawValue)]: \(type.explanation)") + } + } + + private func printDetail(_ type: SensitiveDataType) { + print("Type: \(type.rawValue)") + print("Severity: \(type.severity.rawValue)") + print("About: \(type.explanation)") + print("Examples:") + for example in type.examples { + print(" \(example)") + } + } +} diff --git a/Sources/PastewatchCLI/FixCommand.swift b/Sources/PastewatchCLI/FixCommand.swift new file mode 100644 index 0000000..7da95c4 --- /dev/null +++ b/Sources/PastewatchCLI/FixCommand.swift @@ -0,0 +1,168 @@ +import ArgumentParser +import Foundation +import PastewatchCore + +struct Fix: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Externalize secrets to environment variables" + ) + + @Option(name: .long, help: "Directory to fix") + var dir: String + + @Flag(name: .long, help: "Show fix plan without applying changes") + var dryRun = false + + @Option(name: .long, help: "Minimum severity to fix: critical, high, medium, low") + var minSeverity: Severity = .high + + @Option(name: .long, help: "Path for generated .env file (default: .env)") + var envFile: String = ".env" + + @Option(name: .long, parsing: .singleValue, help: "Glob pattern to ignore (can be repeated)") + var ignore: [String] = [] + + @Flag(name: .long, help: "Encrypt secrets to vault instead of plaintext .env") + var encrypt = false + + @Flag(name: .long, help: "Generate encryption key if none exists") + var initKey = false + + func run() throws { + guard FileManager.default.fileExists(atPath: dir) else { + FileHandle.standardError.write(Data("error: directory not found: \(dir)\n".utf8)) + throw ExitCode(rawValue: 2) + } + + let config = PastewatchConfig.resolve() + let allowlist = Allowlist.fromConfig(config) + + // Scan directory + let ignoreFile = IgnoreFile.load(from: dir) + let fileResults = try DirectoryScanner.scan( + directory: dir, config: config, + ignoreFile: ignoreFile, extraIgnorePatterns: ignore + ) + + // Apply allowlist filtering + var filteredResults: [FileScanResult] = [] + for fr in fileResults { + let filtered = allowlist.values.isEmpty && allowlist.patterns.isEmpty + ? fr.matches + : allowlist.filter(fr.matches) + if !filtered.isEmpty { + filteredResults.append(FileScanResult( + filePath: fr.filePath, matches: filtered, content: fr.content + )) + } + } + + // Build fix plan + let plan = Remediation.buildPlan(results: filteredResults, minSeverity: minSeverity) + + if plan.actions.isEmpty { + FileHandle.standardError.write(Data("No fixable secrets found.\n".utf8)) + return + } + + // Always print plan + printPlan(plan) + + // Apply if not dry-run + if !dryRun { + if encrypt { + try applyWithVault(plan: plan) + } else { + try Remediation.apply(plan: plan, dirPath: dir, envFilePath: envFile) + FileHandle.standardError.write(Data("\nApplied \(plan.actions.count) fixes.\n".utf8)) + + if !Remediation.gitignoreContainsEnv(dirPath: dir) { + FileHandle.standardError.write( + Data("warning: \(envFile) not in .gitignore — secrets may be committed\n".utf8) + ) + } + } + } + } + + private func applyWithVault(plan: FixPlan) throws { + let keyPath = (dir as NSString).appendingPathComponent(".pastewatch-key") + let vaultPath = (dir as NSString).appendingPathComponent(".pastewatch-vault") + + // Resolve or generate key + let keyHex: String + if FileManager.default.fileExists(atPath: keyPath) { + keyHex = try Vault.readKey(from: keyPath) + } else if initKey { + keyHex = Vault.generateKey() + try Vault.writeKey(keyHex, to: keyPath) + FileHandle.standardError.write(Data("Generated key: \(keyPath)\n".utf8)) + } else { + FileHandle.standardError.write( + Data("error: no key file at \(keyPath) — use --init-key to generate\n".utf8) + ) + throw ExitCode(rawValue: 2) + } + + // Build new vault entries + var newVault = try Vault.buildVault(plan: plan, keyHex: keyHex) + + // Merge with existing vault if present + if FileManager.default.fileExists(atPath: vaultPath) { + let existing = try Vault.load(from: vaultPath) + newVault = Vault.merge(existing: existing, new: newVault) + } + + try Vault.save(newVault, to: vaultPath) + + // Patch source files (same as regular fix, minus .env generation) + try Remediation.patchFiles(plan: plan, dirPath: dir) + + FileHandle.standardError.write( + Data("\nEncrypted \(plan.envEntries.count) secrets → \(vaultPath)\n".utf8) + ) + + // Warn about key in gitignore + let gitignorePath = (dir as NSString).appendingPathComponent(".gitignore") + if FileManager.default.fileExists(atPath: gitignorePath) { + let gitignore = (try? String(contentsOfFile: gitignorePath, encoding: .utf8)) ?? "" + if !gitignore.contains(".pastewatch-key") { + FileHandle.standardError.write( + Data("warning: .pastewatch-key not in .gitignore — key may be committed\n".utf8) + ) + } + } else { + FileHandle.standardError.write( + Data("warning: no .gitignore found — .pastewatch-key may be committed\n".utf8) + ) + } + } + + private func printPlan(_ plan: FixPlan) { + FileHandle.standardError.write(Data("Fix plan:\n\n".utf8)) + + for action in plan.actions { + let truncated = String(action.secretValue.prefix(16)) + let display = action.secretValue.count > 16 ? "\(truncated)..." : truncated + let target = action.replacement.isEmpty ? "(moved to \(envFile))" : action.replacement + + let line = " \(action.filePath):\(action.line) \(action.type.rawValue) (\(action.severity.rawValue))\n" + let detail = " \(display) -> \(target)\n\n" + FileHandle.standardError.write(Data(line.utf8)) + FileHandle.standardError.write(Data(detail.utf8)) + } + + let envCount = plan.envEntries.count + FileHandle.standardError.write(Data(" .env file: \(envCount) entries to generate\n".utf8)) + + let gitignoreStatus = Remediation.gitignoreContainsEnv(dirPath: dir) + ? ".env in .gitignore" + : ".env not in .gitignore (warning)" + FileHandle.standardError.write(Data(" .gitignore: \(gitignoreStatus)\n\n".utf8)) + + let verb = dryRun ? "Run without --dry-run to apply." : "" + FileHandle.standardError.write( + Data("\(plan.actions.count) secrets -> \(envCount) env vars. \(verb)\n".utf8) + ) + } +} diff --git a/Sources/PastewatchCLI/GuardCommand.swift b/Sources/PastewatchCLI/GuardCommand.swift new file mode 100644 index 0000000..7d5ca3f --- /dev/null +++ b/Sources/PastewatchCLI/GuardCommand.swift @@ -0,0 +1,156 @@ +import ArgumentParser +import Foundation +import PastewatchCore + +struct Guard: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "guard", + abstract: "Check if a shell command would access files containing secrets" + ) + + @Argument(help: "Shell command to check") + var command: String + + @Option(name: .long, help: "Minimum severity to block: critical, high, medium, low") + var failOnSeverity: Severity = .high + + @Flag(name: .long, help: "Machine-readable JSON output") + var json = false + + @Flag(name: .long, help: "Exit code only, no output") + var quiet = false + + func run() throws { + if ProcessInfo.processInfo.environment["PW_GUARD"] == "0" { return } + + let config = PastewatchConfig.resolve() + let paths = CommandParser.extractFilePaths(from: command) + let inlineValues = CommandParser.extractInlineValues(from: command) + + if paths.isEmpty && inlineValues.isEmpty { + if json { + printJSON(GuardResult(blocked: false, command: command, files: [], inlineFindings: [])) + } + return + } + + var allFileResults: [FileResult] = [] + var allInlineResults: [InlineResult] = [] + var shouldBlock = false + + // Scan referenced files + for path in paths { + guard FileManager.default.fileExists(atPath: path), + let content = try? String(contentsOfFile: path, encoding: .utf8) else { + continue + } + + let matches = DetectionRules.scan(content, config: config) + let filtered = matches.filter { $0.effectiveSeverity >= failOnSeverity } + + if !filtered.isEmpty { + shouldBlock = true + let bySeverity = Dictionary(grouping: filtered, by: { $0.effectiveSeverity }) + let counts = bySeverity.map { "\($0.value.count) \($0.key.rawValue)" } + .sorted() + allFileResults.append(FileResult( + path: path, + findings: filtered.count, + severityCounts: counts.joined(separator: ", "), + types: Set(filtered.map { $0.displayName }).sorted() + )) + } + } + + // Scan inline values (connection strings, passwords in command args) + for value in inlineValues { + let matches = DetectionRules.scan(value, config: config) + let filtered = matches.filter { $0.effectiveSeverity >= failOnSeverity } + + if !filtered.isEmpty { + shouldBlock = true + let bySeverity = Dictionary(grouping: filtered, by: { $0.effectiveSeverity }) + let counts = bySeverity.map { "\($0.value.count) \($0.key.rawValue)" } + .sorted() + allInlineResults.append(InlineResult( + findings: filtered.count, + severityCounts: counts.joined(separator: ", "), + types: Set(filtered.map { $0.displayName }).sorted() + )) + } + } + + if shouldBlock { + if json { + let result = GuardResult( + blocked: true, + command: command, + files: allFileResults.map { + .init(path: $0.path, findings: $0.findings, types: $0.types) + }, + inlineFindings: allInlineResults.map { + .init(findings: $0.findings, types: $0.types) + } + ) + printJSON(result) + } else if !quiet { + for fr in allFileResults { + let msg = "BLOCKED: \(fr.path) contains \(fr.findings) secret(s) (\(fr.severityCounts))\n" + FileHandle.standardError.write(Data(msg.utf8)) + } + for ir in allInlineResults { + let msg = "BLOCKED: command contains inline secret(s) (\(ir.severityCounts): \(ir.types.joined(separator: ", ")))\n" + FileHandle.standardError.write(Data(msg.utf8)) + } + FileHandle.standardError.write(Data("Use pastewatch MCP tools for files with secrets.\n".utf8)) + } + throw ExitCode(rawValue: 1) + } + + if json { + printJSON(GuardResult(blocked: false, command: command, files: [], inlineFindings: [])) + } + } + + private func printJSON(_ result: GuardResult) { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + if let data = try? encoder.encode(result), + let str = String(data: data, encoding: .utf8) { + print(str) + } + } +} + +// MARK: - Output types + +private struct FileResult { + let path: String + let findings: Int + let severityCounts: String + let types: [String] +} + +private struct InlineResult { + let findings: Int + let severityCounts: String + let types: [String] +} + +private struct GuardResult: Codable { + let blocked: Bool + let command: String + let files: [GuardFileEntry] + let inlineFindings: [InlineEntry] + + struct GuardFileEntry: Codable { + let path: String + let findings: Int + let types: [String] + } + + struct InlineEntry: Codable { + let findings: Int + let types: [String] + } +} diff --git a/Sources/PastewatchCLI/GuardReadCommand.swift b/Sources/PastewatchCLI/GuardReadCommand.swift new file mode 100644 index 0000000..c7be29a --- /dev/null +++ b/Sources/PastewatchCLI/GuardReadCommand.swift @@ -0,0 +1,61 @@ +import ArgumentParser +import Foundation +import PastewatchCore + +struct GuardRead: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "guard-read", + abstract: "Check if a file contains secrets before allowing Read tool access" + ) + + @Argument(help: "File path to check") + var filePath: String + + @Option(name: .long, help: "Minimum severity to block: critical, high, medium, low") + var failOnSeverity: Severity = .high + + func run() throws { + if ProcessInfo.processInfo.environment["PW_GUARD"] == "0" { return } + + let config = PastewatchConfig.resolve() + if config.isPathProtected(filePath) { + let msg = "BLOCKED: \(filePath) is inside a protected directory\n" + FileHandle.standardError.write(Data(msg.utf8)) + print("You MUST use pastewatch_read_file instead of Read for files in protected directories.") + throw ExitCode(rawValue: 2) + } + + guard FileManager.default.fileExists(atPath: filePath) else { return } + + guard let content = try? String(contentsOfFile: filePath, encoding: .utf8), + !content.isEmpty else { + return + } + + let fileName = URL(fileURLWithPath: filePath).lastPathComponent + let isEnvFile = fileName == ".env" || fileName.hasSuffix(".env") + let ext = isEnvFile ? "env" : URL(fileURLWithPath: filePath).pathExtension.lowercased() + + var matches = DirectoryScanner.scanFileContent( + content: content, ext: ext, + relativePath: filePath, config: config + ) + matches = Allowlist.filterInlineAllow(matches: matches, content: content) + + let configAllowlist = Allowlist.fromConfig(config) + matches = configAllowlist.filter(matches) + + let filtered = matches.filter { $0.effectiveSeverity >= failOnSeverity } + guard !filtered.isEmpty else { return } + + let bySeverity = Dictionary(grouping: filtered, by: { $0.effectiveSeverity }) + let counts = bySeverity.map { "\($0.value.count) \($0.key.rawValue)" }.sorted() + + let msg = "BLOCKED: \(filePath) contains \(filtered.count) secret(s) (\(counts.joined(separator: ", ")))\n" + FileHandle.standardError.write(Data(msg.utf8)) + + print("You MUST use pastewatch_read_file instead of Read for files containing secrets.") + + throw ExitCode(rawValue: 2) + } +} diff --git a/Sources/PastewatchCLI/GuardWriteCommand.swift b/Sources/PastewatchCLI/GuardWriteCommand.swift new file mode 100644 index 0000000..dae39a7 --- /dev/null +++ b/Sources/PastewatchCLI/GuardWriteCommand.swift @@ -0,0 +1,60 @@ +import ArgumentParser +import Foundation +import PastewatchCore + +struct GuardWrite: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "guard-write", + abstract: "Check if a file contains secrets before allowing Write tool access" + ) + + @Argument(help: "File path to check") + var filePath: String + + @Option(name: .long, help: "Minimum severity to block: critical, high, medium, low") + var failOnSeverity: Severity = .high + + func run() throws { + if ProcessInfo.processInfo.environment["PW_GUARD"] == "0" { return } + + let config = PastewatchConfig.resolve() + if config.isPathProtected(filePath) { + let msg = "BLOCKED: \(filePath) is inside a protected directory\n" + FileHandle.standardError.write(Data(msg.utf8)) + print("You MUST use pastewatch_write_file instead of Write for files in protected directories.") + throw ExitCode(rawValue: 2) + } + + guard FileManager.default.fileExists(atPath: filePath) else { return } + + guard let content = try? String(contentsOfFile: filePath, encoding: .utf8), + !content.isEmpty else { + return + } + let fileName = URL(fileURLWithPath: filePath).lastPathComponent + let isEnvFile = fileName == ".env" || fileName.hasSuffix(".env") + let ext = isEnvFile ? "env" : URL(fileURLWithPath: filePath).pathExtension.lowercased() + + var matches = DirectoryScanner.scanFileContent( + content: content, ext: ext, + relativePath: filePath, config: config + ) + matches = Allowlist.filterInlineAllow(matches: matches, content: content) + + let configAllowlist = Allowlist.fromConfig(config) + matches = configAllowlist.filter(matches) + + let filtered = matches.filter { $0.effectiveSeverity >= failOnSeverity } + guard !filtered.isEmpty else { return } + + let bySeverity = Dictionary(grouping: filtered, by: { $0.effectiveSeverity }) + let counts = bySeverity.map { "\($0.value.count) \($0.key.rawValue)" }.sorted() + + let msg = "BLOCKED: \(filePath) contains \(filtered.count) secret(s) (\(counts.joined(separator: ", ")))\n" + FileHandle.standardError.write(Data(msg.utf8)) + + print("You MUST use pastewatch_write_file instead of Write for files containing secrets.") + + throw ExitCode(rawValue: 2) + } +} diff --git a/Sources/PastewatchCLI/HookCommand.swift b/Sources/PastewatchCLI/HookCommand.swift new file mode 100644 index 0000000..b27c5d2 --- /dev/null +++ b/Sources/PastewatchCLI/HookCommand.swift @@ -0,0 +1,149 @@ +import ArgumentParser +import Foundation + +struct HookGroup: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "hook", + abstract: "Manage git pre-commit hook", + subcommands: [Install.self, Uninstall.self] + ) +} + +extension HookGroup { + struct Install: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Install pre-commit hook" + ) + + @Flag(name: .long, help: "Append to existing hook instead of failing") + var append = false + + func run() throws { + let hooksDir = try findGitHooksDir() + let hookPath = hooksDir + "/pre-commit" + let fm = FileManager.default + + // Create hooks directory if needed + if !fm.fileExists(atPath: hooksDir) { + try fm.createDirectory(atPath: hooksDir, withIntermediateDirectories: true) + } + + let hookContent = """ + # BEGIN PASTEWATCH + git diff --cached --diff-filter=d --no-color | pastewatch-cli scan --check + PASTEWATCH_RESULT=$? + if [ $PASTEWATCH_RESULT -eq 6 ]; then + echo "pastewatch: sensitive data detected in staged changes" >&2 + exit 1 + fi + # END PASTEWATCH + """ + + if fm.fileExists(atPath: hookPath) { + let existing = try String(contentsOfFile: hookPath, encoding: .utf8) + if existing.contains("BEGIN PASTEWATCH") { + FileHandle.standardError.write(Data("error: pastewatch hook already installed\n".utf8)) + throw ExitCode(rawValue: 2) + } + if !append { + FileHandle.standardError.write(Data("error: pre-commit hook already exists (use --append to add pastewatch)\n".utf8)) + throw ExitCode(rawValue: 2) + } + // Append to existing hook + let updated = existing.trimmingCharacters(in: .whitespacesAndNewlines) + "\n\n" + hookContent + "\n" + try updated.write(toFile: hookPath, atomically: true, encoding: .utf8) + } else { + // Create new hook with shebang + let fullHook = "#!/bin/sh\n\n" + hookContent + "\n" + try fullHook.write(toFile: hookPath, atomically: true, encoding: .utf8) + } + + // Make executable (chmod +x) + try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: hookPath) + + print("installed pre-commit hook at \(hookPath)") + } + } + + struct Uninstall: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Remove pre-commit hook" + ) + + func run() throws { + let hooksDir = try findGitHooksDir() + let hookPath = hooksDir + "/pre-commit" + let fm = FileManager.default + + guard fm.fileExists(atPath: hookPath) else { + FileHandle.standardError.write(Data("error: no pre-commit hook found\n".utf8)) + throw ExitCode(rawValue: 2) + } + + let content = try String(contentsOfFile: hookPath, encoding: .utf8) + + guard content.contains("BEGIN PASTEWATCH") else { + FileHandle.standardError.write(Data("error: pre-commit hook does not contain pastewatch section\n".utf8)) + throw ExitCode(rawValue: 2) + } + + // Remove pastewatch section between markers + var lines = content.components(separatedBy: "\n") + var inSection = false + lines.removeAll { line in + if line.contains("BEGIN PASTEWATCH") { inSection = true; return true } + if line.contains("END PASTEWATCH") { inSection = false; return true } + return inSection + } + + // Clean up: remove consecutive empty lines at the end + while lines.last?.trimmingCharacters(in: .whitespaces).isEmpty == true { + lines.removeLast() + } + + let remaining = lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + + // If only the shebang remains (or empty), remove the file + if remaining.isEmpty || remaining == "#!/bin/sh" || remaining == "#!/bin/bash" { + try fm.removeItem(atPath: hookPath) + print("removed pre-commit hook") + } else { + try (remaining + "\n").write(toFile: hookPath, atomically: true, encoding: .utf8) + print("removed pastewatch section from pre-commit hook") + } + } + } +} + +/// Find the git hooks directory using git rev-parse. +private func findGitHooksDir() throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = ["rev-parse", "--git-path", "hooks"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + FileHandle.standardError.write(Data("error: not a git repository\n".utf8)) + throw ExitCode(rawValue: 2) + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let path = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + guard !path.isEmpty else { + FileHandle.standardError.write(Data("error: could not determine git hooks path\n".utf8)) + throw ExitCode(rawValue: 2) + } + + // If relative, make absolute from CWD + if path.hasPrefix("/") { + return path + } + return FileManager.default.currentDirectoryPath + "/" + path +} diff --git a/Sources/PastewatchCLI/InitCommand.swift b/Sources/PastewatchCLI/InitCommand.swift new file mode 100644 index 0000000..8a0a861 --- /dev/null +++ b/Sources/PastewatchCLI/InitCommand.swift @@ -0,0 +1,125 @@ +import ArgumentParser +import Foundation +import PastewatchCore + +struct Init: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Generate project configuration files" + ) + + @Flag(name: .long, help: "Overwrite existing files") + var force = false + + @Option(name: .long, help: "Configuration profile (default, banking)") + var profile: String? + + func run() throws { + let fm = FileManager.default + let cwd = fm.currentDirectoryPath + + let configPath = cwd + "/.pastewatch.json" + let allowPath = cwd + "/.pastewatch-allow" + + // Check for existing files + if !force { + if fm.fileExists(atPath: configPath) { + FileHandle.standardError.write(Data("error: .pastewatch.json already exists (use --force to overwrite)\n".utf8)) + throw ExitCode(rawValue: 2) + } + if fm.fileExists(atPath: allowPath) { + FileHandle.standardError.write(Data("error: .pastewatch-allow already exists (use --force to overwrite)\n".utf8)) + throw ExitCode(rawValue: 2) + } + } + + let configTemplate: String + if let profile = profile { + guard let tmpl = Self.profileTemplate(profile) else { + FileHandle.standardError.write(Data("error: unknown profile '\(profile)' (available: banking)\n".utf8)) + throw ExitCode(rawValue: 2) + } + configTemplate = tmpl + } else { + configTemplate = Self.defaultTemplate() + } + + try configTemplate.write(toFile: configPath, atomically: true, encoding: .utf8) + + // Write .pastewatch-allow + let allowTemplate = """ + # Pastewatch allowlist + # One value per line. Lines starting with # are comments. + # Values listed here will be excluded from scan results. + # + # Examples: + # test@example.com + # 192.168.1.1 + # MYCO-000000 + + """ + try allowTemplate.write(toFile: allowPath, atomically: true, encoding: .utf8) + + let profileLabel = profile.map { " (profile: \($0))" } ?? "" + print("created .pastewatch.json\(profileLabel)") + print("created .pastewatch-allow") + } + + private static func defaultTemplate() -> String { + return """ + { + "enabled": true, + "enabledTypes": \(defaultEnabledTypesJSON()), + "showNotifications": true, + "soundEnabled": false, + "allowedValues": [], + "allowedPatterns": [], + "customRules": [], + "safeHosts": [], + "sensitiveHosts": [], + "sensitiveIPPrefixes": [], + "mcpMinSeverity": "high", + "xmlSensitiveTags": [], + "placeholderPrefix": null + } + """ + } + + private static func profileTemplate(_ name: String) -> String? { + switch name { + case "banking": + return bankingTemplate() + default: + return nil + } + } + + private static func bankingTemplate() -> String { + return """ + { + "enabled": true, + "enabledTypes": \(defaultEnabledTypesJSON()), + "showNotifications": true, + "soundEnabled": false, + "allowedValues": [], + "allowedPatterns": [], + "customRules": [ + {"name": "Service Account", "pattern": "svc_[a-zA-Z0-9_]+@[a-zA-Z0-9.-]+", "severity": "high"}, + {"name": "Internal URI", "pattern": "https?://[a-zA-Z0-9.-]+\\\\.internal\\\\.[a-zA-Z0-9.-]+[^\\\\s]*", "severity": "high"} + ], + "safeHosts": [], + "sensitiveHosts": [".internal.YOURBANK.com", ".corp.YOURBANK.net"], + "sensitiveIPPrefixes": ["10.", "172.16.", "172.17.", "172.18.", "172.19.", "172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.", "172.26.", "172.27.", "172.28.", "172.29.", "172.30.", "172.31.", "192.168."], + "mcpMinSeverity": "medium", + "xmlSensitiveTags": ["password", "connectionString", "jdbcUrl", "datasource"], + "placeholderPrefix": null + } + """ + } + + private static func defaultEnabledTypesJSON() -> String { + let types = SensitiveDataType.allCases + .filter { $0 != .highEntropyString } + .map { "\"\($0.rawValue)\"" } + return "[\(types.joined(separator: ", "))]" + } +} diff --git a/Sources/PastewatchCLI/InventoryCommand.swift b/Sources/PastewatchCLI/InventoryCommand.swift new file mode 100644 index 0000000..6289465 --- /dev/null +++ b/Sources/PastewatchCLI/InventoryCommand.swift @@ -0,0 +1,162 @@ +import ArgumentParser +import Foundation +import PastewatchCore + +struct Inventory: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Generate a structured inventory of all detected secrets" + ) + + @Option(name: .long, help: "Directory to scan") + var dir: String + + @Option(name: .long, help: "Output format: text, json, markdown, csv") + var format: InventoryFormat = .text + + @Option(name: .long, help: "Write report to file instead of stdout") + var output: String? + + @Option(name: .long, help: "Compare with previous inventory JSON file") + var compare: String? + + @Option(name: .long, help: "Path to allowlist file (one value per line)") + var allowlist: String? + + @Option(name: .long, help: "Path to custom rules JSON file") + var rules: String? + + @Option(name: .long, parsing: .singleValue, help: "Glob pattern to ignore (can be repeated)") + var ignore: [String] = [] + + func run() throws { + guard FileManager.default.fileExists(atPath: dir) else { + FileHandle.standardError.write(Data("error: directory not found: \(dir)\n".utf8)) + throw ExitCode(rawValue: 2) + } + + let config = PastewatchConfig.resolve() + let mergedAllowlist = try loadAllowlist(config: config) + let customRulesList = try loadCustomRules(config: config) + + let ignoreFile = IgnoreFile.load(from: dir) + let fileResults = try DirectoryScanner.scan( + directory: dir, config: config, + ignoreFile: ignoreFile, extraIgnorePatterns: ignore + ) + + // Apply allowlist filtering + var filteredResults: [FileScanResult] = [] + for fr in fileResults { + var matches = fr.matches + if !mergedAllowlist.values.isEmpty || !mergedAllowlist.patterns.isEmpty || !customRulesList.isEmpty { + matches = mergedAllowlist.filter(matches) + } + if !matches.isEmpty { + filteredResults.append(FileScanResult( + filePath: fr.filePath, matches: matches, content: fr.content + )) + } + } + + let report = InventoryReport.build(from: filteredResults, directory: dir) + + try redirectStdoutIfNeeded() + + // Output report + let reportOutput: String + switch format { + case .text: reportOutput = InventoryFormatter.formatText(report) + case .json: reportOutput = InventoryFormatter.formatJSON(report) + case .markdown: reportOutput = InventoryFormatter.formatMarkdown(report) + case .csv: reportOutput = InventoryFormatter.formatCSV(report) + } + print(reportOutput, terminator: "") + + // Compare mode + if let comparePath = compare { + try runCompare(comparePath: comparePath, report: report) + } + } + + private func runCompare(comparePath: String, report: InventoryReport) throws { + guard FileManager.default.fileExists(atPath: comparePath) else { + FileHandle.standardError.write(Data("error: compare file not found: \(comparePath)\n".utf8)) + throw ExitCode(rawValue: 2) + } + let previous: InventoryReport + do { + previous = try InventoryReport.load(from: comparePath) + } catch { + FileHandle.standardError.write(Data("error: invalid inventory file: \(comparePath)\n".utf8)) + throw ExitCode(rawValue: 2) + } + let delta = InventoryReport.compare(current: report, previous: previous) + let deltaOutput = formatDelta(delta) + if !deltaOutput.isEmpty { + print(deltaOutput, terminator: "") + } + } + + private func formatDelta(_ delta: InventoryDelta) -> String { + switch format { + case .text: return InventoryFormatter.formatDeltaText(delta) + case .json: + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + if let data = try? encoder.encode(delta), + let str = String(data: data, encoding: .utf8) { + return "\n" + str + } + return "" + case .markdown: return InventoryFormatter.formatDeltaMarkdown(delta) + case .csv: return "" + } + } + + // MARK: - Helpers + + private func loadAllowlist(config: PastewatchConfig) throws -> Allowlist { + var merged = Allowlist.fromConfig(config) + if let allowlistPath = allowlist { + guard FileManager.default.fileExists(atPath: allowlistPath) else { + FileHandle.standardError.write(Data("error: allowlist file not found: \(allowlistPath)\n".utf8)) + throw ExitCode(rawValue: 2) + } + merged = merged.merged(with: try Allowlist.load(from: allowlistPath)) + } + return merged + } + + private func loadCustomRules(config: PastewatchConfig) throws -> [CustomRule] { + var list: [CustomRule] = [] + if !config.customRules.isEmpty { + list = try CustomRule.compile(config.customRules) + } + if let rulesPath = rules { + guard FileManager.default.fileExists(atPath: rulesPath) else { + FileHandle.standardError.write(Data("error: rules file not found: \(rulesPath)\n".utf8)) + throw ExitCode(rawValue: 2) + } + list.append(contentsOf: try CustomRule.load(from: rulesPath)) + } + return list + } + + private func redirectStdoutIfNeeded() throws { + guard let outputPath = output else { return } + FileManager.default.createFile(atPath: outputPath, contents: nil) + guard let handle = FileHandle(forWritingAtPath: outputPath) else { + FileHandle.standardError.write(Data("error: could not write to \(outputPath)\n".utf8)) + throw ExitCode(rawValue: 2) + } + dup2(handle.fileDescriptor, STDOUT_FILENO) + handle.closeFile() + } +} + +enum InventoryFormat: String, ExpressibleByArgument { + case text + case json + case markdown + case csv +} diff --git a/Sources/PastewatchCLI/MCPCommand.swift b/Sources/PastewatchCLI/MCPCommand.swift new file mode 100644 index 0000000..2c58dfe --- /dev/null +++ b/Sources/PastewatchCLI/MCPCommand.swift @@ -0,0 +1,568 @@ +import ArgumentParser +import Foundation +import PastewatchCore + +struct MCP: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Run as MCP server (stdio transport)" + ) + + @Option(name: .long, help: "Path to audit log file (append mode)") + var auditLog: String? + + @Option(name: .long, help: "Default minimum severity for redacted reads (critical, high, medium, low)") + var minSeverity: String? + + func run() throws { + let logger = auditLog.map { MCPAuditLogger(path: $0) } + let config = PastewatchConfig.resolve() + let server = MCPServer( + auditLogger: logger, + defaultMinSeverity: minSeverity, + placeholderPrefix: config.placeholderPrefix + ) + server.start() + } +} + +/// Stateful MCP server that holds redaction mappings for the session. +final class MCPServer { + private let store: RedactionStore + private let auditLogger: MCPAuditLogger? + private let defaultMinSeverity: String? + + init(auditLogger: MCPAuditLogger? = nil, defaultMinSeverity: String? = nil, placeholderPrefix: String? = nil) { + self.store = RedactionStore(placeholderPrefix: placeholderPrefix) + self.auditLogger = auditLogger + self.defaultMinSeverity = defaultMinSeverity + } + + func start() { + FileHandle.standardError.write(Data("pastewatch-cli: MCP server started\n".utf8)) + + while let line = readLine(strippingNewline: true) { + guard !line.isEmpty else { continue } + guard let data = line.data(using: .utf8) else { continue } + + let response: JSONRPCResponse? + do { + let request = try JSONDecoder().decode(JSONRPCRequest.self, from: data) + response = handleRequest(request) + } catch { + response = JSONRPCResponse( + jsonrpc: "2.0", id: nil, + result: nil, + error: JSONRPCError(code: -32700, message: "Parse error") + ) + } + + guard let response else { continue } + + let encoder = JSONEncoder() + if let responseData = try? encoder.encode(response), + let responseStr = String(data: responseData, encoding: .utf8) { + print(responseStr) + fflush(stdout) + } + } + } + + // MARK: - Request dispatch + + private func handleRequest(_ request: JSONRPCRequest) -> JSONRPCResponse? { + switch request.method { + case "initialize": + return initializeResponse(id: request.id) + case "notifications/initialized": + return nil + case "tools/list": + return toolsListResponse(id: request.id) + case "tools/call": + return toolsCallResponse(id: request.id, params: request.params) + default: + if request.method.hasPrefix("notifications/") { + return nil + } + return JSONRPCResponse( + jsonrpc: "2.0", id: request.id, + result: nil, + error: JSONRPCError(code: -32601, message: "Method not found: \(request.method)") + ) + } + } + + // MARK: - Handlers + + private func initializeResponse(id: JSONRPCId?) -> JSONRPCResponse { + let result: JSONValue = .object([ + "protocolVersion": .string("2024-11-05"), + "capabilities": .object([ + "tools": .object([:]) + ]), + "serverInfo": .object([ + "name": .string("pastewatch-cli"), + "version": .string("0.24.1") + ]) + ]) + return JSONRPCResponse(jsonrpc: "2.0", id: id, result: result, error: nil) + } + + private func toolsListResponse(id: JSONRPCId?) -> JSONRPCResponse { + let tools: JSONValue = .object([ + "tools": .array([ + .object([ + "name": .string("pastewatch_scan"), + "description": .string("Scan text for sensitive data patterns"), + "inputSchema": .object([ + "type": .string("object"), + "properties": .object([ + "text": .object([ + "type": .string("string"), + "description": .string("Text content to scan") + ]) + ]), + "required": .array([.string("text")]) + ]) + ]), + .object([ + "name": .string("pastewatch_scan_file"), + "description": .string("Scan a file for sensitive data patterns"), + "inputSchema": .object([ + "type": .string("object"), + "properties": .object([ + "path": .object([ + "type": .string("string"), + "description": .string("File path to scan") + ]) + ]), + "required": .array([.string("path")]) + ]) + ]), + .object([ + "name": .string("pastewatch_scan_dir"), + "description": .string("Scan a directory recursively for sensitive data patterns"), + "inputSchema": .object([ + "type": .string("object"), + "properties": .object([ + "path": .object([ + "type": .string("string"), + "description": .string("Directory path to scan") + ]) + ]), + "required": .array([.string("path")]) + ]) + ]), + .object([ + "name": .string("pastewatch_read_file"), + "description": .string("Read a file with sensitive values replaced by placeholders. Secrets stay local — only placeholders reach the AI. Use pastewatch_write_file to write back with originals restored."), + "inputSchema": .object([ + "type": .string("object"), + "properties": .object([ + "path": .object([ + "type": .string("string"), + "description": .string("File path to read") + ]), + "min_severity": .object([ + "type": .string("string"), + "description": .string("Minimum severity to redact: critical, high, medium, low (default: high)"), + "enum": .array([ + .string("critical"), + .string("high"), + .string("medium"), + .string("low") + ]) + ]) + ]), + "required": .array([.string("path")]) + ]) + ]), + .object([ + "name": .string("pastewatch_write_file"), + "description": .string("Write file contents, resolving any placeholders back to original values locally. Pair with pastewatch_read_file for safe round-trip editing."), + "inputSchema": .object([ + "type": .string("object"), + "properties": .object([ + "path": .object([ + "type": .string("string"), + "description": .string("File path to write") + ]), + "content": .object([ + "type": .string("string"), + "description": .string("File content (may contain placeholders from pastewatch_read_file)") + ]) + ]), + "required": .array([.string("path"), .string("content")]) + ]) + ]), + .object([ + "name": .string("pastewatch_check_output"), + "description": .string("Check if text contains raw sensitive data. Use before writing or returning code to verify no secrets leak."), + "inputSchema": .object([ + "type": .string("object"), + "properties": .object([ + "text": .object([ + "type": .string("string"), + "description": .string("Text to check for sensitive data") + ]) + ]), + "required": .array([.string("text")]) + ]) + ]) + ]) + ]) + return JSONRPCResponse(jsonrpc: "2.0", id: id, result: tools, error: nil) + } + + private func toolsCallResponse(id: JSONRPCId?, params: JSONValue?) -> JSONRPCResponse { + guard case .object(let paramsDict) = params, + case .string(let toolName) = paramsDict["name"] else { + return JSONRPCResponse( + jsonrpc: "2.0", id: id, result: nil, + error: JSONRPCError(code: -32602, message: "Invalid params: missing tool name") + ) + } + + let arguments: [String: JSONValue] + if case .object(let args) = paramsDict["arguments"] { + arguments = args + } else { + arguments = [:] + } + + let config = PastewatchConfig.resolve() + + switch toolName { + case "pastewatch_scan": + return handleScanText(id: id, arguments: arguments, config: config) + case "pastewatch_scan_file": + return handleScanFile(id: id, arguments: arguments, config: config) + case "pastewatch_scan_dir": + return handleScanDir(id: id, arguments: arguments, config: config) + case "pastewatch_read_file": + return handleReadFile(id: id, arguments: arguments, config: config) + case "pastewatch_write_file": + return handleWriteFile(id: id, arguments: arguments) + case "pastewatch_check_output": + return handleCheckOutput(id: id, arguments: arguments, config: config) + default: + return JSONRPCResponse( + jsonrpc: "2.0", id: id, result: nil, + error: JSONRPCError(code: -32602, message: "Unknown tool: \(toolName)") + ) + } + } + + // MARK: - Scan tools (existing) + + private func handleScanText(id: JSONRPCId?, arguments: [String: JSONValue], config: PastewatchConfig) -> JSONRPCResponse { + guard case .string(let text) = arguments["text"] else { + return errorResult(id: id, text: "Missing required parameter: text") + } + + let matches = DetectionRules.scan(text, config: config) + auditLogger?.log("SCAN (inline) findings=\(matches.count)") + return successResult(id: id, matches: matches) + } + + private func handleScanFile(id: JSONRPCId?, arguments: [String: JSONValue], config: PastewatchConfig) -> JSONRPCResponse { + guard case .string(let path) = arguments["path"] else { + return errorResult(id: id, text: "Missing required parameter: path") + } + + guard FileManager.default.fileExists(atPath: path) else { + return errorResult(id: id, text: "File not found: \(path)") + } + + guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { + return errorResult(id: id, text: "Could not read file: \(path)") + } + + let ext: String + if path.hasSuffix(".env") || URL(fileURLWithPath: path).lastPathComponent == ".env" { + ext = "env" + } else { + ext = URL(fileURLWithPath: path).pathExtension.lowercased() + } + + var matches: [DetectedMatch] + if let parser = parserForExtension(ext) { + let parsedValues = parser.parseValues(from: content) + matches = [] + for pv in parsedValues { + let valueMatches = DetectionRules.scan(pv.value, config: config) + for vm in valueMatches { + matches.append(DetectedMatch( + type: vm.type, value: vm.value, range: vm.range, + line: pv.line, filePath: path, customRuleName: vm.customRuleName, + customSeverity: vm.customSeverity + )) + } + } + } else { + matches = DetectionRules.scan(content, config: config) + } + + auditLogger?.log("SCAN \(path) findings=\(matches.count)") + return successResult(id: id, matches: matches, filePath: path) + } + + private func handleScanDir(id: JSONRPCId?, arguments: [String: JSONValue], config: PastewatchConfig) -> JSONRPCResponse { + guard case .string(let path) = arguments["path"] else { + return errorResult(id: id, text: "Missing required parameter: path") + } + + guard FileManager.default.fileExists(atPath: path) else { + return errorResult(id: id, text: "Directory not found: \(path)") + } + + do { + let fileResults = try DirectoryScanner.scan(directory: path, config: config) + let allMatches = fileResults.flatMap { $0.matches } + let filesScanned = fileResults.count + let totalFindings = allMatches.count + + var findingsArray: [JSONValue] = [] + for fr in fileResults { + for match in fr.matches { + findingsArray.append(.object([ + "type": .string(match.displayName), + "value": .string(match.value), + "file": .string(fr.filePath), + "line": .number(Double(match.line)) + ])) + } + } + + auditLogger?.log("SCAN \(path) files=\(filesScanned) findings=\(totalFindings)") + let resultText = "Scanned \(filesScanned) files. Found \(totalFindings) findings." + + let content: JSONValue = .array([ + .object([ + "type": .string("text"), + "text": .string(resultText) + ]), + .object([ + "type": .string("text"), + "text": .string(encodeJSON(.array(findingsArray))) + ]) + ]) + + return JSONRPCResponse( + jsonrpc: "2.0", id: id, + result: .object(["content": content]), + error: nil + ) + } catch { + return errorResult(id: id, text: "Scan error: \(error.localizedDescription)") + } + } + + // MARK: - Redacted read/write tools + + private func handleReadFile(id: JSONRPCId?, arguments: [String: JSONValue], config: PastewatchConfig) -> JSONRPCResponse { + guard case .string(let path) = arguments["path"] else { + return errorResult(id: id, text: "Missing required parameter: path") + } + + guard FileManager.default.fileExists(atPath: path) else { + return errorResult(id: id, text: "File not found: \(path)") + } + + guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { + return errorResult(id: id, text: "Could not read file: \(path)") + } + + // Precedence: per-request > CLI flag > config > default (high) + let minSeverity: Severity + if case .string(let severityStr) = arguments["min_severity"], + let parsed = Severity(rawValue: severityStr) { + minSeverity = parsed + } else if let flagStr = defaultMinSeverity, let parsed = Severity(rawValue: flagStr) { + minSeverity = parsed + } else { + minSeverity = Severity(rawValue: config.mcpMinSeverity) ?? .high + } + + let allMatches = DetectionRules.scan(content, config: config) + let matches = allMatches.filter { $0.effectiveSeverity >= minSeverity } + + if matches.isEmpty { + auditLogger?.log("READ \(path) clean") + let result: JSONValue = .array([ + .object([ + "type": .string("text"), + "text": .string(encodeJSON(.object([ + "content": .string(content), + "redactions": .array([]), + "clean": .bool(true) + ]))) + ]) + ]) + return JSONRPCResponse(jsonrpc: "2.0", id: id, result: .object(["content": result]), error: nil) + } + + let (redacted, entries) = store.redact(content: content, matches: matches, filePath: path) + + let typeNames = Set(entries.map { $0.type }).sorted() + auditLogger?.log("READ \(path) redacted=\(entries.count) [\(typeNames.joined(separator: ", "))]") + + var redactionsArray: [JSONValue] = [] + for entry in entries { + redactionsArray.append(.object([ + "type": .string(entry.type), + "severity": .string(entry.severity), + "line": .number(Double(entry.line)), + "placeholder": .string(entry.placeholder) + ])) + } + + let result: JSONValue = .array([ + .object([ + "type": .string("text"), + "text": .string(encodeJSON(.object([ + "content": .string(redacted), + "redactions": .array(redactionsArray), + "clean": .bool(false) + ]))) + ]) + ]) + + return JSONRPCResponse(jsonrpc: "2.0", id: id, result: .object(["content": result]), error: nil) + } + + private func handleWriteFile(id: JSONRPCId?, arguments: [String: JSONValue]) -> JSONRPCResponse { + guard case .string(let path) = arguments["path"] else { + return errorResult(id: id, text: "Missing required parameter: path") + } + + guard case .string(let content) = arguments["content"] else { + return errorResult(id: id, text: "Missing required parameter: content") + } + + // Resolve placeholders using all file mappings (agent may move values between files) + let resolved = store.resolveAll(content: content) + + do { + try resolved.content.write(toFile: path, atomically: true, encoding: .utf8) + } catch { + return errorResult(id: id, text: "Could not write file: \(error.localizedDescription)") + } + + auditLogger?.log("WRITE \(path) resolved=\(resolved.resolved) unresolved=\(resolved.unresolved)") + + var responseObj: [String: JSONValue] = [ + "written": .bool(true), + "path": .string(path), + "resolved": .number(Double(resolved.resolved)), + "unresolved": .number(Double(resolved.unresolved)) + ] + + if !resolved.unresolvedPlaceholders.isEmpty { + responseObj["unresolvedPlaceholders"] = .array(resolved.unresolvedPlaceholders.map { .string($0) }) + } + + let result: JSONValue = .array([ + .object([ + "type": .string("text"), + "text": .string(encodeJSON(.object(responseObj))) + ]) + ]) + + return JSONRPCResponse(jsonrpc: "2.0", id: id, result: .object(["content": result]), error: nil) + } + + private func handleCheckOutput(id: JSONRPCId?, arguments: [String: JSONValue], config: PastewatchConfig) -> JSONRPCResponse { + guard case .string(let text) = arguments["text"] else { + return errorResult(id: id, text: "Missing required parameter: text") + } + + let matches = DetectionRules.scan(text, config: config) + auditLogger?.log("CHECK (inline) clean=\(matches.isEmpty)") + + var findingsArray: [JSONValue] = [] + for match in matches { + findingsArray.append(.object([ + "type": .string(match.displayName), + "severity": .string(match.effectiveSeverity.rawValue), + "line": .number(Double(match.line)) + ])) + } + + let result: JSONValue = .array([ + .object([ + "type": .string("text"), + "text": .string(encodeJSON(.object([ + "clean": .bool(matches.isEmpty), + "findings": .array(findingsArray) + ]))) + ]) + ]) + + return JSONRPCResponse(jsonrpc: "2.0", id: id, result: .object(["content": result]), error: nil) + } + + // MARK: - Result helpers + + private func successResult(id: JSONRPCId?, matches: [DetectedMatch], filePath: String? = nil) -> JSONRPCResponse { + var findingsArray: [JSONValue] = [] + for match in matches { + var entry: [String: JSONValue] = [ + "type": .string(match.displayName), + "value": .string(match.value), + "line": .number(Double(match.line)) + ] + if let fp = filePath ?? match.filePath { + entry["file"] = .string(fp) + } + findingsArray.append(.object(entry)) + } + + let summary = matches.isEmpty + ? "No sensitive data found." + : "Found \(matches.count) finding(s)." + + let content: JSONValue = .array([ + .object([ + "type": .string("text"), + "text": .string(summary) + ]), + .object([ + "type": .string("text"), + "text": .string(encodeJSON(.array(findingsArray))) + ]) + ]) + + return JSONRPCResponse( + jsonrpc: "2.0", id: id, + result: .object(["content": content]), + error: nil + ) + } + + private func errorResult(id: JSONRPCId?, text: String) -> JSONRPCResponse { + let content: JSONValue = .array([ + .object([ + "type": .string("text"), + "text": .string(text) + ]) + ]) + return JSONRPCResponse( + jsonrpc: "2.0", id: id, + result: .object([ + "content": content, + "isError": .bool(true) + ]), + error: nil + ) + } + + private func encodeJSON(_ value: JSONValue) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + guard let data = try? encoder.encode(value), + let str = String(data: data, encoding: .utf8) else { + return "[]" + } + return str + } +} diff --git a/Sources/PastewatchCLI/PastewatchCLI.swift b/Sources/PastewatchCLI/PastewatchCLI.swift new file mode 100644 index 0000000..b6b54a9 --- /dev/null +++ b/Sources/PastewatchCLI/PastewatchCLI.swift @@ -0,0 +1,12 @@ +import ArgumentParser + +@main +struct PastewatchCLI: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "pastewatch-cli", + abstract: "Scan text for sensitive data patterns", + version: "0.24.1", + subcommands: [Scan.self, Fix.self, Version.self, Init.self, BaselineGroup.self, HookGroup.self, MCP.self, Explain.self, ConfigGroup.self, Guard.self, GuardRead.self, GuardWrite.self, Inventory.self, Doctor.self, Setup.self, Report.self, CanaryGroup.self, VaultGroup.self, Posture.self, Watch.self, DashboardCommand.self, Proxy.self], + defaultSubcommand: Scan.self + ) +} diff --git a/Sources/PastewatchCLI/PostureCommand.swift b/Sources/PastewatchCLI/PostureCommand.swift new file mode 100644 index 0000000..d661f57 --- /dev/null +++ b/Sources/PastewatchCLI/PostureCommand.swift @@ -0,0 +1,142 @@ +import ArgumentParser +import Foundation +import PastewatchCore + +struct Posture: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Scan multiple repositories for secret posture across an organization" + ) + + @Option(name: .long, help: "GitHub org or user to enumerate repos from") + var org: String? + + @Option(name: .long, parsing: .singleValue, help: "Specific repos to scan (org/repo format, can be repeated)") + var repos: [String] = [] + + @Option(name: .long, help: "Output format: text, json, markdown") + var format: PostureFormat = .text + + @Option(name: .long, help: "Write report to file instead of stdout") + var output: String? + + @Option(name: .long, help: "Compare with previous posture JSON file") + var compare: String? + + @Flag(name: .long, help: "Only show repositories with findings") + var findingsOnly = false + + func run() throws { + guard org != nil || !repos.isEmpty else { + FileHandle.standardError.write(Data("error: provide --org or --repos\n".utf8)) + throw ExitCode(rawValue: 2) + } + + let config = PastewatchConfig.resolve() + let orgName: String + var repoNames: [String] + + if !repos.isEmpty { + // Parse org/repo format + orgName = repos.first.map { String($0.split(separator: "/").first ?? "") } ?? "multi" + repoNames = repos.map { String($0.split(separator: "/").last ?? Substring($0)) } + } else { + orgName = org! + FileHandle.standardError.write(Data("Enumerating repos for \(orgName)...\n".utf8)) + repoNames = try PostureScanner.enumerateRepos(org: orgName) + if repoNames.isEmpty { + throw PostureError.noReposFound(orgName) + } + FileHandle.standardError.write(Data("Found \(repoNames.count) repos\n".utf8)) + } + + let totalRepos = repoNames.count + let tempDir = NSTemporaryDirectory() + "pastewatch-posture-\(ProcessInfo.processInfo.processIdentifier)" + try FileManager.default.createDirectory(atPath: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: tempDir) } + + var summaries: [RepositorySummary] = [] + let resolvedOrg = repos.isEmpty ? orgName : repos.first.map { String($0.split(separator: "/").first ?? "") } ?? orgName + + for (index, name) in repoNames.enumerated() { + let cloneOrg = repos.isEmpty ? orgName : repos[index].split(separator: "/").first.map(String.init) ?? orgName + FileHandle.standardError.write(Data("[\(index + 1)/\(totalRepos)] Scanning \(name)...\n".utf8)) + do { + let repoPath = try PostureScanner.cloneRepo(org: cloneOrg, name: name, into: tempDir) + let summary = try PostureScanner.scanRepo(at: repoPath, name: name, config: config) + summaries.append(summary) + } catch { + FileHandle.standardError.write(Data(" warning: \(name) skipped (\(error))\n".utf8)) + summaries.append(RepositorySummary( + name: name, totalFindings: 0, filesAffected: 0, + severityBreakdown: SeverityBreakdown(critical: 0, high: 0, medium: 0, low: 0), + typeGroups: [], hotSpots: [] + )) + } + } + + let report = PostureScanner.aggregate(org: resolvedOrg, summaries: summaries, totalRepos: totalRepos) + + try redirectStdoutIfNeeded() + + let reportOutput: String + switch format { + case .text: reportOutput = PostureFormatter.formatText(report, findingsOnly: findingsOnly) + case .json: reportOutput = PostureFormatter.formatJSON(report) + case .markdown: reportOutput = PostureFormatter.formatMarkdown(report, findingsOnly: findingsOnly) + } + print(reportOutput, terminator: "") + + if let comparePath = compare { + try runCompare(comparePath: comparePath, report: report) + } + } + + private func runCompare(comparePath: String, report: PostureReport) throws { + guard FileManager.default.fileExists(atPath: comparePath) else { + FileHandle.standardError.write(Data("error: compare file not found: \(comparePath)\n".utf8)) + throw ExitCode(rawValue: 2) + } + let previous: PostureReport + do { + previous = try PostureScanner.load(from: comparePath) + } catch { + FileHandle.standardError.write(Data("error: invalid posture file: \(comparePath)\n".utf8)) + throw ExitCode(rawValue: 2) + } + let delta = PostureScanner.compare(current: report, previous: previous) + let deltaOutput: String + switch format { + case .text: deltaOutput = PostureFormatter.formatDeltaText(delta) + case .json: + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + if let data = try? encoder.encode(delta), + let str = String(data: data, encoding: .utf8) { + deltaOutput = "\n" + str + } else { + deltaOutput = "" + } + case .markdown: deltaOutput = PostureFormatter.formatDeltaMarkdown(delta) + } + if !deltaOutput.isEmpty { + print(deltaOutput, terminator: "") + } + } + + private func redirectStdoutIfNeeded() throws { + guard let outputPath = output else { return } + FileManager.default.createFile(atPath: outputPath, contents: nil) + guard let handle = FileHandle(forWritingAtPath: outputPath) else { + FileHandle.standardError.write(Data("error: could not write to \(outputPath)\n".utf8)) + throw ExitCode(rawValue: 2) + } + dup2(handle.fileDescriptor, STDOUT_FILENO) + handle.closeFile() + } +} + +enum PostureFormat: String, ExpressibleByArgument { + case text + case json + case markdown +} diff --git a/Sources/PastewatchCLI/ProxyCommand.swift b/Sources/PastewatchCLI/ProxyCommand.swift new file mode 100644 index 0000000..a0c57a5 --- /dev/null +++ b/Sources/PastewatchCLI/ProxyCommand.swift @@ -0,0 +1,77 @@ +import ArgumentParser +import Foundation +import PastewatchCore +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + +struct Proxy: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Start API proxy that scans and redacts secrets from outbound requests" + ) + + @Option(name: .long, help: "Port to listen on") + var port: UInt16 = 8443 + + @Option(name: .long, help: "Upstream API URL") + var upstream: String = "https://api.anthropic.com" + + @Option(name: .long, help: "Forward through corporate proxy (e.g., http://proxy.corp:8080)") + var forwardProxy: String? + + @Option(name: .long, help: "Minimum severity to redact: critical, high, medium, low") + var severity: Severity = .high + + @Option(name: .long, help: "Audit log file path") + var auditLog: String? + + @Flag(name: .long, inversion: .prefixedNo, help: "Inject alert into response when secrets are redacted") + var alert: Bool = true + + func run() throws { + guard let upstreamURL = URL(string: upstream) else { + FileHandle.standardError.write(Data("error: invalid upstream URL: \(upstream)\n".utf8)) + throw ExitCode(rawValue: 2) + } + + var forwardProxyURL: URL? + if let fp = forwardProxy { + guard let url = URL(string: fp) else { + FileHandle.standardError.write(Data("error: invalid forward proxy URL: \(fp)\n".utf8)) + throw ExitCode(rawValue: 2) + } + forwardProxyURL = url + } + + let config = PastewatchConfig.resolve() + let server = ProxyServer( + port: port, + upstream: upstreamURL, + forwardProxy: forwardProxyURL, + config: config, + severity: severity, + auditLogPath: auditLog, + injectAlert: alert + ) + + FileHandle.standardError.write(Data("pastewatch proxy listening on http://127.0.0.1:\(port)\n".utf8)) + FileHandle.standardError.write(Data("upstream: \(upstream)\n".utf8)) + if let fp = forwardProxy { + FileHandle.standardError.write(Data("forward-proxy: \(fp)\n".utf8)) + } + FileHandle.standardError.write(Data("severity: \(severity.rawValue)\n".utf8)) + FileHandle.standardError.write(Data("alert-injection: \(alert ? "on" : "off")\n".utf8)) + FileHandle.standardError.write(Data("\nusage:\n".utf8)) + FileHandle.standardError.write(Data(" ANTHROPIC_BASE_URL=http://127.0.0.1:\(port) claude\n".utf8)) + FileHandle.standardError.write(Data("\nctrl-c to stop\n\n".utf8)) + + signal(SIGINT) { _ in + FileHandle.standardError.write(Data("\nstopped.\n".utf8)) + _exit(0) + } + + try server.start() + } +} diff --git a/Sources/PastewatchCLI/ReportCommand.swift b/Sources/PastewatchCLI/ReportCommand.swift new file mode 100644 index 0000000..b0b96d3 --- /dev/null +++ b/Sources/PastewatchCLI/ReportCommand.swift @@ -0,0 +1,80 @@ +import ArgumentParser +import Foundation +import PastewatchCore + +struct Report: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Generate session report from MCP audit log" + ) + + @Option(name: .long, help: "Path to MCP audit log file") + var auditLog: String + + @Option(name: .long, help: "Output format: text, json, markdown (default: text)") + var format: ReportFormat = .text + + @Option(name: .long, help: "Write report to file instead of stdout") + var output: String? + + @Option(name: .long, help: "Only entries after this ISO timestamp") + var since: String? + + func validate() throws { + guard FileManager.default.fileExists(atPath: auditLog) else { + throw ValidationError("audit log file not found: \(auditLog)") + } + if let since = since { + let df = ISO8601DateFormatter() + df.formatOptions = [.withInternetDateTime] + guard df.date(from: since) != nil else { + throw ValidationError("invalid ISO timestamp for --since: \(since)") + } + } + } + + func run() throws { + let content = try String(contentsOfFile: auditLog, encoding: .utf8) + + var sinceDate: Date? + if let since = since { + let df = ISO8601DateFormatter() + df.formatOptions = [.withInternetDateTime] + sinceDate = df.date(from: since) + } + + let report = SessionReportBuilder.build( + content: content, + logPath: auditLog, + since: sinceDate + ) + + try redirectStdoutIfNeeded() + + let reportOutput: String + switch format { + case .text: reportOutput = SessionReportBuilder.formatText(report) + case .json: reportOutput = SessionReportBuilder.formatJSON(report) + case .markdown: reportOutput = SessionReportBuilder.formatMarkdown(report) + } + print(reportOutput, terminator: "") + } + + private func redirectStdoutIfNeeded() throws { + guard let outputPath = output else { return } + FileManager.default.createFile(atPath: outputPath, contents: nil) + guard let handle = FileHandle(forWritingAtPath: outputPath) else { + FileHandle.standardError.write( + Data("error: could not write to \(outputPath)\n".utf8) + ) + throw ExitCode(rawValue: 2) + } + dup2(handle.fileDescriptor, STDOUT_FILENO) + handle.closeFile() + } +} + +enum ReportFormat: String, ExpressibleByArgument { + case text + case json + case markdown +} diff --git a/Sources/PastewatchCLI/ScanCommand.swift b/Sources/PastewatchCLI/ScanCommand.swift new file mode 100644 index 0000000..39d1e0c --- /dev/null +++ b/Sources/PastewatchCLI/ScanCommand.swift @@ -0,0 +1,720 @@ +import ArgumentParser +import Foundation +import PastewatchCore + +struct Scan: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Scan text for sensitive data" + ) + + @Option(name: .long, help: "File to scan (reads from stdin if omitted)") + var file: String? + + @Option(name: .long, help: "Directory to scan recursively") + var dir: String? + + @Option(name: .long, help: "Output format: text, json, sarif, markdown") + var format: OutputFormat = .text + + @Flag(name: .long, help: "Check mode: exit code only, no output modification") + var check = false + + @Option(name: .long, help: "Path to allowlist file (one value per line)") + var allowlist: String? + + @Option(name: .long, help: "Path to custom rules JSON file") + var rules: String? + + @Option(name: .long, help: "Path to baseline file (only report new findings)") + var baseline: String? + + @Option(name: .long, help: "Filename hint for stdin format-aware parsing") + var stdinFilename: String? + + @Option(name: .long, help: "Minimum severity for non-zero exit: critical, high, medium, low") + var failOnSeverity: Severity? + + @Option(name: .long, parsing: .singleValue, help: "Glob pattern to ignore (can be repeated)") + var ignore: [String] = [] + + @Flag(name: .long, help: "Stop at first finding (fast pre-dispatch gate)") + var bail = false + + @Flag(name: .long, help: "Include gitignored files in exit code (default: warn only)") + var includeGitignored = false + + @Flag(name: .long, help: "Scan git diff changes (staged by default)") + var gitDiff = false + + @Flag(name: .long, help: "Include unstaged changes (requires --git-diff)") + var unstaged = false + + @Flag(name: .long, help: "Scan git commit history for secrets") + var gitLog = false + + @Option(name: .long, help: "Git revision range (e.g., HEAD~50..HEAD)") + var range: String? + + @Option(name: .long, help: "Only commits since date (ISO format, requires --git-log)") + var since: String? + + @Option(name: .long, help: "Scan specific branch (requires --git-log)") + var branch: String? + + @Option(name: .long, help: "Write report to file instead of stdout") + var output: String? + + func validate() throws { + if file != nil && dir != nil { + throw ValidationError("--file and --dir are mutually exclusive") + } + if gitDiff && (file != nil || dir != nil) { + throw ValidationError("--git-diff is mutually exclusive with --file and --dir") + } + if gitLog && (file != nil || dir != nil || gitDiff) { + throw ValidationError("--git-log is mutually exclusive with --file, --dir, and --git-diff") + } + if stdinFilename != nil && (file != nil || dir != nil) { + throw ValidationError("--stdin-filename is only valid when reading from stdin") + } + if unstaged && !gitDiff { + throw ValidationError("--unstaged requires --git-diff") + } + if (range != nil || since != nil || branch != nil) && !gitLog { + throw ValidationError("--range, --since, and --branch require --git-log") + } + if bail && dir == nil && !gitDiff && !gitLog { + throw ValidationError("--bail is only valid with --dir, --git-diff, or --git-log") + } + } + + func run() throws { + if check && ProcessInfo.processInfo.environment["PW_GUARD"] == "0" { return } + + let config = PastewatchConfig.resolve() + let mergedAllowlist = try loadAllowlist(config: config) + let customRulesList = try loadCustomRules(config: config) + let baselineFile = try loadBaseline() + + // Git history scanning mode + if gitLog { + try runGitLogScan(config: config, allowlist: mergedAllowlist, + customRules: customRulesList, baseline: baselineFile) + return + } + + // Git diff scanning mode + if gitDiff { + try runGitDiffScan(config: config, allowlist: mergedAllowlist, + customRules: customRulesList, baseline: baselineFile) + return + } + + // Directory scanning mode + if let dirPath = dir { + guard FileManager.default.fileExists(atPath: dirPath) else { + FileHandle.standardError.write(Data("error: directory not found: \(dirPath)\n".utf8)) + throw ExitCode(rawValue: 2) + } + try runDirectoryScan(dirPath: dirPath, config: config, + allowlist: mergedAllowlist, customRules: customRulesList, + baseline: baselineFile) + return + } + + // Single file or stdin mode + let input = try readInput() + guard !input.isEmpty else { return } + + var matches = scanInput(input, config: config, + allowlist: mergedAllowlist, customRules: customRulesList) + matches = Allowlist.filterInlineAllow(matches: matches, content: input) + + // Apply baseline filtering + if let bl = baselineFile { + matches = bl.filterNew(matches: matches, filePath: file ?? "stdin") + } + + if matches.isEmpty { + if !check { print(input, terminator: "") } + return + } + + try redirectStdoutIfNeeded() + + if check { + outputCheckMode(matches: matches, filePath: file) + } else { + let obfuscated = Obfuscator.obfuscate(input, matches: matches) + outputFindings(matches: matches, filePath: file, obfuscated: obfuscated) + } + if shouldFail(matches: matches) { + throw ExitCode(rawValue: 6) + } + } + + private func shouldFail(matches: [DetectedMatch]) -> Bool { + guard !matches.isEmpty else { return false } + guard let threshold = failOnSeverity else { return true } + return matches.contains { $0.effectiveSeverity >= threshold } + } + + private func redirectStdoutIfNeeded() throws { + guard let outputPath = output else { return } + FileManager.default.createFile(atPath: outputPath, contents: nil) + guard let handle = FileHandle(forWritingAtPath: outputPath) else { + FileHandle.standardError.write(Data("error: could not write to \(outputPath)\n".utf8)) + throw ExitCode(rawValue: 2) + } + dup2(handle.fileDescriptor, STDOUT_FILENO) + handle.closeFile() + } + + // MARK: - Input loading + + private func loadAllowlist(config: PastewatchConfig) throws -> Allowlist { + var merged = Allowlist.fromConfig(config) + if let allowlistPath = allowlist { + guard FileManager.default.fileExists(atPath: allowlistPath) else { + FileHandle.standardError.write(Data("error: allowlist file not found: \(allowlistPath)\n".utf8)) + throw ExitCode(rawValue: 2) + } + merged = merged.merged(with: try Allowlist.load(from: allowlistPath)) + } + return merged + } + + private func loadCustomRules(config: PastewatchConfig) throws -> [CustomRule] { + var list: [CustomRule] = [] + if !config.customRules.isEmpty { + list = try CustomRule.compile(config.customRules) + } + if let rulesPath = rules { + guard FileManager.default.fileExists(atPath: rulesPath) else { + FileHandle.standardError.write(Data("error: rules file not found: \(rulesPath)\n".utf8)) + throw ExitCode(rawValue: 2) + } + list.append(contentsOf: try CustomRule.load(from: rulesPath)) + } + return list + } + + private func loadBaseline() throws -> BaselineFile? { + guard let baselinePath = baseline else { return nil } + guard FileManager.default.fileExists(atPath: baselinePath) else { + FileHandle.standardError.write(Data("error: baseline file not found: \(baselinePath)\n".utf8)) + throw ExitCode(rawValue: 2) + } + return try BaselineFile.load(from: baselinePath) + } + + private func readInput() throws -> String { + if let filePath = file { + guard FileManager.default.fileExists(atPath: filePath) else { + FileHandle.standardError.write(Data("error: file not found: \(filePath)\n".utf8)) + throw ExitCode(rawValue: 2) + } + return try String(contentsOfFile: filePath, encoding: .utf8) + } + var lines: [String] = [] + while let line = readLine(strippingNewline: false) { + lines.append(line) + } + return lines.joined() + } + + private func scanInput( + _ input: String, + config: PastewatchConfig, + allowlist: Allowlist, + customRules: [CustomRule] + ) -> [DetectedMatch] { + let sourcePath = file ?? stdinFilename + + guard let filePath = sourcePath else { + return DetectionRules.scan(input, config: config, + allowlist: allowlist, customRules: customRules) + } + + let ext: String + if filePath.hasSuffix(".env") || URL(fileURLWithPath: filePath).lastPathComponent == ".env" { + ext = "env" + } else { + ext = URL(fileURLWithPath: filePath).pathExtension.lowercased() + } + + guard let parser = parserForExtension(ext) else { + return DetectionRules.scan(input, config: config, + allowlist: allowlist, customRules: customRules) + } + + let parsedValues = parser.parseValues(from: input) + var collected: [DetectedMatch] = [] + for pv in parsedValues { + let valueMatches = DetectionRules.scan( + pv.value, config: config, + allowlist: allowlist, customRules: customRules + ) + for vm in valueMatches { + collected.append(DetectedMatch( + type: vm.type, value: vm.value, range: vm.range, + line: pv.line, filePath: file, customRuleName: vm.customRuleName, + customSeverity: vm.customSeverity + )) + } + } + return collected + } + + // MARK: - Directory scanning + + private func runDirectoryScan( + dirPath: String, + config: PastewatchConfig, + allowlist: Allowlist, + customRules: [CustomRule], + baseline: BaselineFile? = nil + ) throws { + let ignoreFile = IgnoreFile.load(from: dirPath) + let fileResults = try DirectoryScanner.scan( + directory: dirPath, config: config, + ignoreFile: ignoreFile, extraIgnorePatterns: ignore, + bail: bail + ) + + // Apply allowlist and custom rules to each file's matches + var filteredResults: [FileScanResult] = [] + for fr in fileResults { + var allMatches: [DetectedMatch] = fr.matches + + // Re-scan with allowlist/custom rules if either is provided + if !allowlist.values.isEmpty || !allowlist.patterns.isEmpty || !customRules.isEmpty { + allMatches = allowlist.filter(allMatches) + } + + if !allMatches.isEmpty { + filteredResults.append(FileScanResult( + filePath: fr.filePath, matches: allMatches, content: fr.content + )) + } + } + + // Apply baseline filtering + if let bl = baseline { + filteredResults = bl.filterNewResults(results: filteredResults) + } + + if filteredResults.isEmpty { + return + } + + try redirectStdoutIfNeeded() + + if check { + outputDirCheckMode(results: filteredResults) + } else { + outputDirFindings(results: filteredResults) + } + + // Only non-gitignored findings count toward exit code (unless --include-gitignored) + let exitResults = includeGitignored + ? filteredResults + : filteredResults.filter { !$0.gitignored } + let allMatches = exitResults.flatMap { $0.matches } + if shouldFail(matches: allMatches) { + throw ExitCode(rawValue: 6) + } + } + + // MARK: - Git diff scanning + + private func runGitDiffScan( + config: PastewatchConfig, + allowlist: Allowlist, + customRules: [CustomRule], + baseline: BaselineFile? = nil + ) throws { + let fileResults: [FileScanResult] + do { + fileResults = try GitDiffScanner.scan( + staged: !unstaged, unstaged: unstaged, + config: config, bail: bail + ) + } catch let error as GitDiffError { + FileHandle.standardError.write(Data("error: \(error.description)\n".utf8)) + throw ExitCode(rawValue: 2) + } + + // Apply allowlist filtering + var filteredResults: [FileScanResult] = [] + for fr in fileResults { + var allMatches = fr.matches + if !allowlist.values.isEmpty || !allowlist.patterns.isEmpty || !customRules.isEmpty { + allMatches = allowlist.filter(allMatches) + } + if !allMatches.isEmpty { + filteredResults.append(FileScanResult( + filePath: fr.filePath, matches: allMatches, content: fr.content + )) + } + } + + // Apply baseline filtering + if let bl = baseline { + filteredResults = bl.filterNewResults(results: filteredResults) + } + + guard !filteredResults.isEmpty else { return } + + try redirectStdoutIfNeeded() + + if check { + outputDirCheckMode(results: filteredResults) + } else { + outputDirFindings(results: filteredResults) + } + let allMatches = filteredResults.flatMap { $0.matches } + if shouldFail(matches: allMatches) { + throw ExitCode(rawValue: 6) + } + } + + // MARK: - Git log scanning + + private func runGitLogScan( + config: PastewatchConfig, + allowlist: Allowlist, + customRules: [CustomRule], + baseline: BaselineFile? = nil + ) throws { + let result: GitLogScanResult + do { + result = try GitHistoryScanner.scan( + range: range, since: since, branch: branch, + config: config, bail: bail + ) + } catch let error as GitDiffError { + FileHandle.standardError.write(Data("error: \(error.description)\n".utf8)) + throw ExitCode(rawValue: 2) + } + + // Apply allowlist filtering + var filteredFindings: [CommitFinding] = [] + for cf in result.findings { + var allMatches = cf.matches + if !allowlist.values.isEmpty || !allowlist.patterns.isEmpty || !customRules.isEmpty { + allMatches = allowlist.filter(allMatches) + } + if !allMatches.isEmpty { + filteredFindings.append(CommitFinding( + commitHash: cf.commitHash, author: cf.author, + date: cf.date, filePath: cf.filePath, matches: allMatches + )) + } + } + + // Apply baseline filtering + if let bl = baseline { + filteredFindings = filteredFindings.compactMap { cf in + let filtered = bl.filterNew(matches: cf.matches, filePath: cf.filePath) + guard !filtered.isEmpty else { return nil } + return CommitFinding( + commitHash: cf.commitHash, author: cf.author, + date: cf.date, filePath: cf.filePath, matches: filtered + ) + } + } + + guard !filteredFindings.isEmpty else { return } + + try redirectStdoutIfNeeded() + + if check { + outputGitLogCheckMode(findings: filteredFindings, result: result) + } else { + outputGitLogFindings(findings: filteredFindings, result: result) + } + let allMatches = filteredFindings.flatMap { $0.matches } + if shouldFail(matches: allMatches) { + throw ExitCode(rawValue: 6) + } + } + + private func outputGitLogFindings(findings: [CommitFinding], result: GitLogScanResult) { + switch format { + case .text: + outputGitLogText(findings: findings, result: result) + case .json: + outputGitLogJSON(findings: findings, result: result) + case .sarif: + let pairs = findings.map { ($0.filePath, $0.matches) } + let data = SarifFormatter.formatMultiFile(fileResults: pairs, version: "0.24.1") + print(String(data: data, encoding: .utf8)!) + case .markdown: + outputGitLogMarkdown(findings: findings, result: result) + } + } + + private func outputGitLogCheckMode(findings: [CommitFinding], result: GitLogScanResult) { + switch format { + case .text: + let totalFindings = findings.flatMap { $0.matches }.count + let commitCount = Set(findings.map { $0.commitHash }).count + let msg = "\(totalFindings) finding(s) in \(commitCount) commit(s) (scanned \(result.commitsScanned) commits)\n" + FileHandle.standardError.write(Data(msg.utf8)) + case .json: + outputGitLogJSON(findings: findings, result: result) + case .sarif: + let pairs = findings.map { ($0.filePath, $0.matches) } + let data = SarifFormatter.formatMultiFile(fileResults: pairs, version: "0.24.1") + print(String(data: data, encoding: .utf8)!) + case .markdown: + outputGitLogMarkdown(findings: findings, result: result) + } + } + + private func outputGitLogText(findings: [CommitFinding], result: GitLogScanResult) { + // Group by commit + let grouped = Dictionary(grouping: findings, by: { $0.commitHash }) + let commitOrder = findings.map { $0.commitHash }.reduce(into: [String]()) { + if !$0.contains($1) { $0.append($1) } + } + + for hash in commitOrder { + guard let commitFindings = grouped[hash] else { continue } + let first = commitFindings[0] + let shortHash = String(hash.prefix(7)) + print("commit \(shortHash) (\(first.date), \(first.author))") + for cf in commitFindings { + for match in cf.matches { + print(" \(cf.filePath):\(match.line) \(match.displayName) \(match.value) (\(match.effectiveSeverity.rawValue))") + } + } + print() + } + + let totalFindings = findings.flatMap { $0.matches }.count + let commitCount = commitOrder.count + print("\(totalFindings) finding(s) in \(commitCount) commit(s) (scanned \(result.commitsScanned) commits, \(result.filesScanned) files)") + } + + private func outputGitLogJSON(findings: [CommitFinding], result: GitLogScanResult) { + let output = GitLogOutput( + commitsScanned: result.commitsScanned, + filesScanned: result.filesScanned, + findings: findings.map { cf in + GitLogFindingOutput( + commit: cf.commitHash, + author: cf.author, + date: cf.date, + file: cf.filePath, + matches: cf.matches.map { + GitLogMatchOutput( + type: $0.displayName, line: $0.line, + severity: $0.effectiveSeverity.rawValue, value: $0.value + ) + } + ) + } + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + if let data = try? encoder.encode(output) { + print(String(data: data, encoding: .utf8)!) + } + } + + private func outputGitLogMarkdown(findings: [CommitFinding], result: GitLogScanResult) { + var lines = [ + "| Commit | File | Line | Type | Severity |", + "|--------|------|------|------|----------|", + ] + for cf in findings { + let shortHash = String(cf.commitHash.prefix(7)) + for match in cf.matches { + lines.append("| \(shortHash) | \(cf.filePath) | \(match.line) | \(match.displayName) | \(match.effectiveSeverity.rawValue) |") + } + } + let totalFindings = findings.flatMap { $0.matches }.count + let commitCount = Set(findings.map { $0.commitHash }).count + lines.append("") + lines.append("\(totalFindings) finding(s) in \(commitCount) commit(s) (scanned \(result.commitsScanned) commits)") + print(lines.joined(separator: "\n")) + } + + private func gitignorePrefix(_ fr: FileScanResult) -> String { + fr.gitignored ? "[gitignored] " : "" + } + + private func outputDirCheckMode(results: [FileScanResult]) { + switch format { + case .text: + for fr in results { + let prefix = gitignorePrefix(fr) + let summary = Dictionary(grouping: fr.matches, by: { $0.type }) + .sorted { $0.value.count > $1.value.count } + .map { "\($0.key.rawValue): \($0.value.count)" } + .joined(separator: ", ") + FileHandle.standardError.write(Data("\(prefix)\(fr.filePath): \(summary)\n".utf8)) + } + case .json: + let output = results.map { fr in + DirScanFileOutput( + file: fr.filePath, + findings: fr.matches.map { Finding(type: $0.displayName, value: $0.value, severity: $0.effectiveSeverity.rawValue) }, + count: fr.matches.count, + gitignored: fr.gitignored + ) + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + if let data = try? encoder.encode(output) { + print(String(data: data, encoding: .utf8)!) + } + case .sarif: + let pairs = results.map { ($0.filePath, $0.matches) } + let data = SarifFormatter.formatMultiFile(fileResults: pairs, version: "0.24.1") + print(String(data: data, encoding: .utf8)!) + case .markdown: + print(MarkdownFormatter.formatDirectory(results: results), terminator: "") + } + } + + private func outputDirFindings(results: [FileScanResult]) { + switch format { + case .text: + for fr in results { + let prefix = gitignorePrefix(fr) + print("--- \(prefix)\(fr.filePath) ---") + for match in fr.matches { + print(" line \(match.line): \(match.displayName): \(match.value)") + } + } + case .json: + let output = results.map { fr in + DirScanFileOutput( + file: fr.filePath, + findings: fr.matches.map { Finding(type: $0.displayName, value: $0.value, severity: $0.effectiveSeverity.rawValue) }, + count: fr.matches.count, + gitignored: fr.gitignored + ) + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + if let data = try? encoder.encode(output) { + print(String(data: data, encoding: .utf8)!) + } + case .sarif: + let pairs = results.map { ($0.filePath, $0.matches) } + let data = SarifFormatter.formatMultiFile(fileResults: pairs, version: "0.24.1") + print(String(data: data, encoding: .utf8)!) + case .markdown: + print(MarkdownFormatter.formatDirectory(results: results), terminator: "") + } + } + + // MARK: - Single file/stdin output helpers + + private func outputCheckMode(matches: [DetectedMatch], filePath: String?) { + switch format { + case .text: + let summary = Dictionary(grouping: matches, by: { $0.type }) + .sorted { $0.value.count > $1.value.count } + .map { "\($0.key.rawValue): \($0.value.count)" } + .joined(separator: ", ") + FileHandle.standardError.write(Data("findings: \(summary)\n".utf8)) + case .json: + let output = ScanOutput( + findings: matches.map { Finding(type: $0.type.rawValue, value: $0.value, severity: $0.effectiveSeverity.rawValue) }, + count: matches.count, + obfuscated: nil + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + if let data = try? encoder.encode(output) { + print(String(data: data, encoding: .utf8)!) + } + case .sarif: + let data = SarifFormatter.format( + matches: matches, filePath: filePath, version: "0.24.1" + ) + print(String(data: data, encoding: .utf8)!) + case .markdown: + print(MarkdownFormatter.formatSingle(matches: matches, filePath: filePath, obfuscated: nil), terminator: "") + } + } + + private func outputFindings(matches: [DetectedMatch], filePath: String?, obfuscated: String) { + switch format { + case .text: + print(obfuscated, terminator: "") + case .json: + let output = ScanOutput( + findings: matches.map { Finding(type: $0.type.rawValue, value: $0.value, severity: $0.effectiveSeverity.rawValue) }, + count: matches.count, + obfuscated: obfuscated + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + if let data = try? encoder.encode(output) { + print(String(data: data, encoding: .utf8)!) + } + case .sarif: + let data = SarifFormatter.format( + matches: matches, filePath: filePath, version: "0.24.1" + ) + print(String(data: data, encoding: .utf8)!) + case .markdown: + print(MarkdownFormatter.formatSingle(matches: matches, filePath: filePath, obfuscated: obfuscated), terminator: "") + } + } +} + +extension Severity: ExpressibleByArgument {} + +enum OutputFormat: String, ExpressibleByArgument { + case text + case json + case sarif + case markdown +} + +struct Finding: Codable { + let type: String + let value: String + let severity: String? +} + +struct ScanOutput: Codable { + let findings: [Finding] + let count: Int + let obfuscated: String? +} + +struct DirScanFileOutput: Codable { + let file: String + let findings: [Finding] + let count: Int + let gitignored: Bool +} + +struct GitLogOutput: Codable { + let commitsScanned: Int + let filesScanned: Int + let findings: [GitLogFindingOutput] +} + +struct GitLogFindingOutput: Codable { + let commit: String + let author: String + let date: String + let file: String + let matches: [GitLogMatchOutput] +} + +struct GitLogMatchOutput: Codable { + let type: String + let line: Int + let severity: String + let value: String +} diff --git a/Sources/PastewatchCLI/SetupCommand.swift b/Sources/PastewatchCLI/SetupCommand.swift new file mode 100644 index 0000000..d9bf5ae --- /dev/null +++ b/Sources/PastewatchCLI/SetupCommand.swift @@ -0,0 +1,223 @@ +import ArgumentParser +import Foundation +import PastewatchCore + +struct Setup: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Configure AI agent integration (MCP server, hooks, severity)" + ) + + @Argument(help: "Agent to configure: claude-code, cline, cursor") + var agent: String + + @Option(name: .long, help: "Severity threshold for hook blocking and MCP redaction (default: high)") + var severity: String = "high" + + @Flag(name: .long, help: "Write to project config instead of global (claude-code only)") + var project = false + + func validate() throws { + let validAgents = ["claude-code", "cline", "cursor"] + guard validAgents.contains(agent) else { + throw ValidationError( + "Unknown agent '\(agent)'. Valid: \(validAgents.joined(separator: ", "))" + ) + } + let validSeverities = ["critical", "high", "medium", "low"] + guard validSeverities.contains(severity) else { + throw ValidationError( + "Invalid severity '\(severity)'. Valid: \(validSeverities.joined(separator: ", "))" + ) + } + if project && agent != "claude-code" { + throw ValidationError("--project is only supported for claude-code") + } + } + + func run() throws { + switch agent { + case "claude-code": + try setupClaudeCode() + case "cline": + try setupCline() + case "cursor": + try setupCursor() + default: + break + } + } + + // MARK: - Claude Code + + private func setupClaudeCode() throws { + let fm = FileManager.default + let home = fm.homeDirectoryForCurrentUser.path + + let configDir: String + if project { + configDir = fm.currentDirectoryPath + "/.claude" + } else { + configDir = home + "/.claude" + } + let hooksDir = configDir + "/hooks" + let hookPath = hooksDir + "/pastewatch-guard.sh" + let settingsPath = configDir + "/settings.json" + + print("setup: claude-code\n") + + // 1. Write hook script + if !fm.fileExists(atPath: hooksDir) { + try fm.createDirectory(atPath: hooksDir, withIntermediateDirectories: true) + } + + let script = AgentSetup.claudeCodeGuardScript(severity: severity) + try script.write(toFile: hookPath, atomically: true, encoding: .utf8) + try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: hookPath) + print(" hook \(hookPath) (created)") + + // 2. Merge settings.json + var json = AgentSetup.readJSON(at: settingsPath) + let configExisted = fm.fileExists(atPath: settingsPath) + + AgentSetup.mergeMCPServer(into: &json, severity: severity) + AgentSetup.mergeClaudeCodeHooks(into: &json, hookPath: hookPath) + try AgentSetup.writeJSON(json, to: settingsPath) + + // 3. Inject CLAUDE.md snippet + let claudeMdPath: String + if project { + claudeMdPath = fm.currentDirectoryPath + "/CLAUDE.md" + } else { + claudeMdPath = home + "/.claude/CLAUDE.md" + } + let (_, snippetAction) = try AgentSetup.injectClaudeSnippet(at: claudeMdPath) + print(" claude \(claudeMdPath) (\(snippetAction))") + + // 4. Print summary + var mcpArgs = "pastewatch-cli mcp --audit-log /tmp/pastewatch-audit.log" + if severity != "high" { + mcpArgs += " --min-severity \(severity)" + } + print(" mcp \(mcpArgs)") + + let configStatus = configExisted ? "updated" : "created" + print(" config \(settingsPath) (\(configStatus))") + print(" severity \(severity) (hook and MCP aligned)") + print("\ndone. restart claude code to activate.") + + runDoctor() + } + + // MARK: - Cline + + private func setupCline() throws { + let fm = FileManager.default + let home = fm.homeDirectoryForCurrentUser.path + + print("setup: cline\n") + + // 1. Merge MCP config + let mcpPath = home + + "/Library/Application Support/Code/User/globalStorage" + + "/saoudrizwan.claude-dev/settings/cline_mcp_settings.json" + + var json = AgentSetup.readJSON(at: mcpPath) + let configExisted = fm.fileExists(atPath: mcpPath) + AgentSetup.mergeMCPServer(into: &json, severity: severity, disabled: false) + try AgentSetup.writeJSON(json, to: mcpPath) + + let configStatus = configExisted ? "updated" : "created" + print(" mcp \(mcpPath) (\(configStatus))") + + // 2. Write hook script + let hooksDir = home + "/.config/pastewatch/hooks" + let hookPath = hooksDir + "/cline-hook.sh" + + if !fm.fileExists(atPath: hooksDir) { + try fm.createDirectory(atPath: hooksDir, withIntermediateDirectories: true) + } + + let script = AgentSetup.clineHookScript(severity: severity) + try script.write(toFile: hookPath, atomically: true, encoding: .utf8) + try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: hookPath) + print(" hook \(hookPath) (created)") + + // 3. Summary + print(" severity \(severity) (hook and MCP aligned)") + print("") + print(" next: register the hook in Cline settings as a PreToolUse hook.") + print(" path: \(hookPath)") + print("\ndone. restart VS Code to activate MCP server.") + + runDoctor() + } + + // MARK: - Cursor + + private func setupCursor() throws { + let fm = FileManager.default + let home = fm.homeDirectoryForCurrentUser.path + + print("setup: cursor\n") + + // 1. Merge MCP config + let mcpPath = home + "/.cursor/mcp.json" + + var json = AgentSetup.readJSON(at: mcpPath) + let configExisted = fm.fileExists(atPath: mcpPath) + AgentSetup.mergeMCPServer(into: &json, severity: severity) + try AgentSetup.writeJSON(json, to: mcpPath) + + let configStatus = configExisted ? "updated" : "created" + print(" mcp \(mcpPath) (\(configStatus))") + print(" severity \(severity)") + print("") + print(" note: Cursor has no structural hook enforcement.") + print(" add to .cursorrules in your project root:") + print(" When reading or writing files that may contain secrets,") + print(" use pastewatch MCP tools (pastewatch_read_file, pastewatch_write_file).") + print("\ndone. restart Cursor to activate MCP server.") + + runDoctor() + } + + // MARK: - Post-setup checks + + /// Run `pastewatch-cli doctor` as a health check after setup. + private func runDoctor() { + print("\n--- health check ---\n") + + let binaryPath = ProcessInfo.processInfo.arguments.first ?? "pastewatch-cli" + let process = Process() + process.executableURL = URL(fileURLWithPath: binaryPath) + process.arguments = ["doctor"] + + do { + try process.run() + process.waitUntilExit() + } catch { + print(" (skipped: could not run doctor)") + } + + runCanaryVerify() + } + + /// Quick canary smoke test — generate canaries, verify all detected. + private func runCanaryVerify() { + print("\n--- detection smoke test ---\n") + + let manifest = CanaryGenerator.generate(prefix: "setup-verify") + let results = CanaryGenerator.verify(manifest: manifest) + let allPassed = results.allSatisfy { $0.detected } + + if allPassed { + print(" canary \(results.count)/\(results.count) detection types verified") + } else { + let failed = results.filter { !$0.detected } + print(" canary WARNING: \(failed.count) detection type(s) not working:") + for result in failed { + print(" - \(result.type)") + } + } + } +} diff --git a/Sources/PastewatchCLI/VaultCommand.swift b/Sources/PastewatchCLI/VaultCommand.swift new file mode 100644 index 0000000..891d787 --- /dev/null +++ b/Sources/PastewatchCLI/VaultCommand.swift @@ -0,0 +1,128 @@ +import ArgumentParser +import Foundation +import PastewatchCore + +struct VaultGroup: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "vault", + abstract: "Encrypted secret vault management", + subcommands: [Decrypt.self, Export.self, RotateKey.self, List.self] + ) +} + +extension VaultGroup { + struct Decrypt: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Decrypt vault to .env file" + ) + + @Option(name: .long, help: "Path to vault file (default: .pastewatch-vault)") + var vault: String = ".pastewatch-vault" + + @Option(name: .long, help: "Path to key file (default: .pastewatch-key)") + var key: String = ".pastewatch-key" + + @Option(name: .long, help: "Output .env file path (default: .env)") + var output: String = ".env" + + func run() throws { + let keyHex = try Vault.readKey(from: key) + let vaultFile = try Vault.load(from: vault) + let entries = try Vault.decryptAll(vault: vaultFile, keyHex: keyHex) + + var lines: [String] = [] + for (name, value) in entries { + lines.append("\(name)=\(value)") + } + let content = lines.joined(separator: "\n") + "\n" + try content.write(toFile: output, atomically: true, encoding: .utf8) + print("Decrypted \(entries.count) entries → \(output)") + } + } + + struct Export: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Export vault as shell environment variables" + ) + + @Option(name: .long, help: "Path to vault file (default: .pastewatch-vault)") + var vault: String = ".pastewatch-vault" + + @Option(name: .long, help: "Path to key file (default: .pastewatch-key)") + var key: String = ".pastewatch-key" + + func run() throws { + let keyHex = try Vault.readKey(from: key) + let vaultFile = try Vault.load(from: vault) + let entries = try Vault.decryptAll(vault: vaultFile, keyHex: keyHex) + + for (name, value) in entries { + // Shell-safe quoting: single quotes, escape embedded single quotes + let escaped = value.replacingOccurrences(of: "'", with: "'\\''") + print("export \(name)='\(escaped)'") + } + } + } + + struct RotateKey: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "rotate-key", + abstract: "Re-encrypt vault with a new key" + ) + + @Option(name: .long, help: "Path to vault file (default: .pastewatch-vault)") + var vault: String = ".pastewatch-vault" + + @Option(name: .long, help: "Path to key file (default: .pastewatch-key)") + var key: String = ".pastewatch-key" + + func run() throws { + let oldKeyHex = try Vault.readKey(from: key) + let vaultFile = try Vault.load(from: vault) + let entries = try Vault.decryptAll(vault: vaultFile, keyHex: oldKeyHex) + + let newKeyHex = Vault.generateKey() + + var newEntries: [VaultEntry] = [] + for (idx, (name, value)) in entries.enumerated() { + let original = vaultFile.entries[idx] + let encrypted = try Vault.encrypt(value: value, keyHex: newKeyHex) + newEntries.append(VaultEntry( + varName: name, + type: original.type, + sourceFile: original.sourceFile, + sourceLine: original.sourceLine, + nonce: encrypted.nonce, + ciphertext: encrypted.ciphertext + )) + } + + let newVault = VaultFile( + version: 1, + keyFingerprint: Vault.keyFingerprint(newKeyHex), + entries: newEntries + ) + + try Vault.save(newVault, to: vault) + try Vault.writeKey(newKeyHex, to: key) + print("Rotated key and re-encrypted \(entries.count) entries.") + } + } + + struct List: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "List vault entries (no decryption)" + ) + + @Option(name: .long, help: "Path to vault file (default: .pastewatch-vault)") + var vault: String = ".pastewatch-vault" + + func run() throws { + let vaultFile = try Vault.load(from: vault) + print("Vault: \(vaultFile.entries.count) entries (key: \(vaultFile.keyFingerprint))") + for entry in vaultFile.entries { + print(" \(entry.varName) [\(entry.type)] from \(entry.sourceFile):\(entry.sourceLine)") + } + } + } +} diff --git a/Sources/PastewatchCLI/VersionCommand.swift b/Sources/PastewatchCLI/VersionCommand.swift new file mode 100644 index 0000000..b69b947 --- /dev/null +++ b/Sources/PastewatchCLI/VersionCommand.swift @@ -0,0 +1,11 @@ +import ArgumentParser + +struct Version: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Print version information" + ) + + func run() { + print("pastewatch-cli \(PastewatchCLI.configuration.version)") + } +} diff --git a/Sources/PastewatchCLI/WatchCommand.swift b/Sources/PastewatchCLI/WatchCommand.swift new file mode 100644 index 0000000..e83d070 --- /dev/null +++ b/Sources/PastewatchCLI/WatchCommand.swift @@ -0,0 +1,53 @@ +import ArgumentParser +import Foundation +import PastewatchCore +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + +struct Watch: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Watch directory for file changes and scan continuously" + ) + + @Option(name: .long, help: "Directory to watch") + var dir: String = "." + + @Option(name: .long, help: "Minimum severity to report: critical, high, medium, low") + var severity: Severity? + + @Flag(name: .long, help: "Output newline-delimited JSON") + var json = false + + func run() throws { + let fm = FileManager.default + let dirPath = (dir as NSString).standardizingPath + guard fm.fileExists(atPath: dirPath) else { + FileHandle.standardError.write(Data("error: directory not found: \(dirPath)\n".utf8)) + throw ExitCode(rawValue: 2) + } + + let config = PastewatchConfig.resolve() + + if !json { + FileHandle.standardError.write(Data("watching \(dirPath) (ctrl-c to stop)\n".utf8)) + } + + let watcher = FileWatcher( + directory: dirPath, + config: config, + severity: severity, + jsonOutput: json + ) + + // Handle SIGINT for graceful shutdown + signal(SIGINT) { _ in + FileHandle.standardError.write(Data("\nstopped.\n".utf8)) + _exit(0) + } + + watcher.start() + } +} diff --git a/Sources/PastewatchCore/AgentSetup.swift b/Sources/PastewatchCore/AgentSetup.swift new file mode 100644 index 0000000..2aa29dd --- /dev/null +++ b/Sources/PastewatchCore/AgentSetup.swift @@ -0,0 +1,364 @@ +import Foundation + +/// Reusable logic for agent auto-setup: JSON config merging, hook script generation. +public enum AgentSetup { + + // MARK: - JSON Helpers + + /// Read JSON from file path, returning empty dict if file doesn't exist. + public static func readJSON(at path: String) -> [String: Any] { + guard let data = FileManager.default.contents(atPath: path), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return [:] + } + return json + } + + /// Write JSON to file path, creating parent directories as needed. + public static func writeJSON(_ json: [String: Any], to path: String) throws { + let dir = (path as NSString).deletingLastPathComponent + let fm = FileManager.default + if !fm.fileExists(atPath: dir) { + try fm.createDirectory(atPath: dir, withIntermediateDirectories: true) + } + let data = try JSONSerialization.data( + withJSONObject: json, + options: [.prettyPrinted, .sortedKeys] + ) + try data.write(to: URL(fileURLWithPath: path)) + } + + // MARK: - Config Merge + + /// Merge pastewatch MCP server entry into JSON config. + public static func mergeMCPServer( + into json: inout [String: Any], + severity: String, + disabled: Bool? = nil + ) { + var mcpServers = json["mcpServers"] as? [String: Any] ?? [:] + var args: [String] = ["mcp", "--audit-log", "/tmp/pastewatch-audit.log"] + if severity != "high" { + args.append(contentsOf: ["--min-severity", severity]) + } + var entry: [String: Any] = [ + "command": "pastewatch-cli", + "args": args, + ] + if let disabled = disabled { + entry["disabled"] = disabled + } + mcpServers["pastewatch"] = entry + json["mcpServers"] = mcpServers + } + + /// Merge pastewatch PreToolUse hook entry into Claude Code settings JSON. + public static func mergeClaudeCodeHooks(into json: inout [String: Any], hookPath: String) { + var hooks = json["hooks"] as? [String: Any] ?? [:] + var preToolUse = hooks["PreToolUse"] as? [[String: Any]] ?? [] + + let newEntry: [String: Any] = [ + "matcher": "Read|Write|Edit", + "hooks": [ + ["type": "command", "command": hookPath] as [String: Any], + ], + ] + + // Find existing pastewatch entry by hook command containing "pastewatch-guard" + if let idx = preToolUse.firstIndex(where: { entry in + guard let innerHooks = entry["hooks"] as? [[String: Any]] else { return false } + return innerHooks.contains { + ($0["command"] as? String)?.contains("pastewatch-guard") == true + } + }) { + preToolUse[idx] = newEntry + } else { + preToolUse.append(newEntry) + } + + hooks["PreToolUse"] = preToolUse + json["hooks"] = hooks + } + + // MARK: - CLAUDE.md Snippet + + /// The pastewatch snippet to inject into CLAUDE.md files. + public static let claudeSnippet = """ + ## Pastewatch — Secret Redaction + + This project uses [pastewatch](https://github.com/ppiankov/pastewatch) to prevent secrets from reaching AI APIs. + + ### When hooks block you + + If a pastewatch hook blocks `Read`, `Write`, or `Edit`: + - Use `pastewatch_read_file` MCP tool to read the file (secrets are replaced with `__PW_TYPE_N__` placeholders) + - Use `pastewatch_write_file` MCP tool to write back (placeholders are resolved to real values locally) + - NEVER use `cat`, `head`, `python3`, or any Bash workaround to read blocked files + - NEVER delete or rename files to bypass the hook + + ### Round-trip workflow + + 1. Read: `pastewatch_read_file` → you see `password: __PW_CREDENTIAL_1__` + 2. Edit: work with placeholders in your code changes + 3. Write: `pastewatch_write_file` → real values restored on disk + + ### Rules + + - Same secret always maps to the same placeholder within a session + - Placeholders are in-memory only — they die when the MCP server stops + - If you see `__PW_` prefixed values, those are redacted secrets — do not treat them as real values + - When writing files that contain `__PW_` placeholders, always use `pastewatch_write_file` — native Write will be blocked + """ + + /// Sentinel line used to detect existing snippet in CLAUDE.md. + private static let snippetSentinel = "## Pastewatch — Secret Redaction" + + /// Inject the pastewatch snippet into a CLAUDE.md file. + /// Creates the file if it doesn't exist, appends if no existing snippet, replaces if found. + /// Returns (path, action) where action is "created", "updated", or "already present". + @discardableResult + public static func injectClaudeSnippet(at path: String) throws -> (String, String) { + let fm = FileManager.default + let dir = (path as NSString).deletingLastPathComponent + if !dir.isEmpty && !fm.fileExists(atPath: dir) { + try fm.createDirectory(atPath: dir, withIntermediateDirectories: true) + } + + if fm.fileExists(atPath: path), + let existing = try? String(contentsOfFile: path, encoding: .utf8) { + if existing.contains(snippetSentinel) { + // Replace existing snippet (everything from sentinel to next ## or end) + let lines = existing.components(separatedBy: "\n") + var result: [String] = [] + var inSnippet = false + for line in lines { + if line.hasPrefix(snippetSentinel) { + inSnippet = true + continue + } + if inSnippet { + // Next top-level heading ends the snippet + if line.hasPrefix("## ") && !line.hasPrefix("### ") { + inSnippet = false + result.append(claudeSnippet) + result.append("") + result.append(line) + } + continue + } + result.append(line) + } + // If snippet was at end of file + if inSnippet { + result.append(claudeSnippet) + result.append("") + } + let updated = result.joined(separator: "\n") + try updated.write(toFile: path, atomically: true, encoding: .utf8) + return (path, "updated") + } else { + // Append snippet + var content = existing + if !content.hasSuffix("\n") { + content += "\n" + } + content += "\n" + claudeSnippet + "\n" + try content.write(toFile: path, atomically: true, encoding: .utf8) + return (path, "appended") + } + } else { + // Create new file + let content = claudeSnippet + "\n" + try content.write(toFile: path, atomically: true, encoding: .utf8) + return (path, "created") + } + } + + // MARK: - Embedded Templates + + /// Generate Claude Code guard script with configured severity. + public static func claudeCodeGuardScript(severity: String) -> String { + return """ + #!/bin/bash + # Claude Code PreToolUse hook: enforce pastewatch MCP tools for files with secrets + # + # Protocol: exit 0 = allow, exit 2 = block + # stdout = message shown to Claude + # stderr = notification shown to the human + # + # Configuration: + # PW_SEVERITY — severity threshold for blocking (default: "\(severity)") + # Must match the --min-severity flag on your MCP server registration. + + PW_SEVERITY="${PW_SEVERITY:-\(severity)}" + + # --- Session check --- + # Only enforce if pastewatch MCP is running in THIS Claude Code session. + # Hooks and MCP are both children of the same Claude process. + # If MCP is not running, allow native tools (fail-open). + _claude_pid=${PPID:-0} + pgrep -P "$_claude_pid" -qf 'pastewatch-cli mcp' 2>/dev/null || exit 0 + + input=$(cat) + tool=$(echo "$input" | jq -r '.tool_name // empty') + file_path=$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.filePath // empty') + + # Only check Read, Write, Edit tools + case "$tool" in + Read|Write|Edit) ;; + *) exit 0 ;; + esac + + # Skip if no file path + [ -z "$file_path" ] && exit 0 + + # Skip binary/non-text files + case "$file_path" in + *.png|*.jpg|*.jpeg|*.gif|*.ico|*.bmp|*.webp|*.svg) exit 0 ;; + *.woff|*.woff2|*.ttf|*.eot|*.otf) exit 0 ;; + *.zip|*.tar|*.gz|*.bz2|*.xz|*.7z|*.rar) exit 0 ;; + *.exe|*.dll|*.so|*.dylib|*.a|*.o|*.class|*.pyc) exit 0 ;; + *.pdf|*.doc|*.docx|*.xls|*.xlsx) exit 0 ;; + *.mp3|*.mp4|*.wav|*.avi|*.mov|*.mkv) exit 0 ;; + *.sqlite|*.db) exit 0 ;; + esac + + # Skip .git internals + echo "$file_path" | grep -qF '/.git/' && exit 0 + + # --- WRITE: Check for pastewatch placeholders in content --- + if [ "$tool" = "Write" ]; then + content=$(echo "$input" | jq -r '.tool_input.content // empty') + if [ -n "$content" ] && echo "$content" | grep -qE '__PW_[A-Z][A-Z0-9_]*_[0-9]+__'; then + echo "BLOCKED: content contains pastewatch placeholders (__PW_...__). Use pastewatch_write_file to resolve placeholders back to real values." + echo "Blocked: pastewatch placeholders in Write" >&2 + exit 2 + fi + fi + + # --- READ/WRITE/EDIT: Scan the file on disk for secrets --- + # Only scan existing files (new files won't have secrets on disk) + [ ! -f "$file_path" ] && exit 0 + + # Fail-open if pastewatch-cli not installed + command -v pastewatch-cli &>/dev/null || exit 0 + + # Scan file at configured severity threshold + pastewatch-cli scan --check --fail-on-severity "$PW_SEVERITY" --file "$file_path" >/dev/null 2>&1 + scan_exit=$? + + if [ "$scan_exit" -eq 6 ]; then + case "$tool" in + Read) + echo "BLOCKED: $file_path contains secrets. You MUST use pastewatch_read_file instead. Do NOT use python3, cat, or any workaround." + echo "Blocked: secrets in Read target — use pastewatch_read_file" >&2 + ;; + Write) + echo "BLOCKED: $file_path contains secrets on disk. You MUST use pastewatch_write_file instead. Do NOT delete the file or use python3 as a workaround." + echo "Blocked: secrets in Write target — use pastewatch_write_file" >&2 + ;; + Edit) + echo "BLOCKED: $file_path contains secrets. You MUST use pastewatch_read_file to read, then pastewatch_write_file to write back. Do NOT use any workaround." + echo "Blocked: secrets in Edit target — use pastewatch_read_file + pastewatch_write_file" >&2 + ;; + esac + exit 2 + fi + + # Clean file or scan error — allow native tool + exit 0 + """ + } + + /// Generate Cline hook script with configured severity. + public static func clineHookScript(severity: String) -> String { + return """ + #!/bin/bash + # Cline PreToolUse hook: enforce pastewatch MCP tools for files with secrets + # + # Protocol: JSON stdout + # {"cancel": true, "errorMessage": "..."} = block + # {"cancel": false} = allow + # Non-zero exit without valid JSON = allow (fail-open) + # + # Configuration: + # PW_SEVERITY — severity threshold for blocking (default: "\(severity)") + # Must match the --min-severity flag on your MCP server registration. + + PW_SEVERITY="${PW_SEVERITY:-\(severity)}" + + block() { + local msg="$1" + printf '{\"cancel\": true, \"errorMessage\": \"%s\"}\\n' "$msg" + exit 0 + } + + input=$(cat) + tool_name=$(echo "$input" | jq -r '.preToolUse.toolName // empty') + + # --- Session check --- + # Only enforce if pastewatch MCP is running in THIS Cline session. + _pw_mcp_ok=false + _cline_pid=${PPID:-0} + if command -v pastewatch-cli &>/dev/null && pgrep -P "$_cline_pid" -qf 'pastewatch-cli mcp' 2>/dev/null; then + _pw_mcp_ok=true + fi + + # If MCP not available, allow everything (fail-open) + $_pw_mcp_ok || { echo '{\"cancel\": false}'; exit 0; } + + # ====== BASH GUARD (execute_command) ====== + if [ "$tool_name" = "execute_command" ]; then + command=$(echo "$input" | jq -r '.preToolUse.parameters.command // empty') + [ -z "$command" ] && { echo '{\"cancel\": false}'; exit 0; } + + guard_output=$(pastewatch-cli guard "$command" 2>&1) + if [ $? -ne 0 ]; then + block "$guard_output" + fi + fi + + # ====== FILE GUARD (read_file, write_to_file, edit_file) ====== + if [ "$tool_name" = "read_file" ] || [ "$tool_name" = "write_to_file" ] || [ "$tool_name" = "edit_file" ]; then + pw_path=$(echo "$input" | jq -r '.preToolUse.parameters.path // empty') + + if [ -n "$pw_path" ]; then + # Skip binary files + case "$pw_path" in + *.png|*.jpg|*.jpeg|*.gif|*.ico|*.bmp|*.webp|*.svg|*.woff|*.woff2|*.ttf|\\ + *.zip|*.tar|*.gz|*.bz2|*.exe|*.dll|*.so|*.dylib|*.pdf|*.mp3|*.mp4|\\ + *.sqlite|*.db|*.pyc|*.o|*.a|*.class) + ;; # skip binary — fall through to allow + *) + # Check for placeholder leak in write content + if [ "$tool_name" = "write_to_file" ]; then + pw_content=$(echo "$input" | jq -r '.preToolUse.parameters.content // empty') + if [ -n "$pw_content" ] && echo "$pw_content" | grep -qE '__PW\\{[A-Z][A-Z0-9_]*_[0-9]+\\}__'; then + block "BLOCKED: content contains pastewatch placeholders. Use pastewatch_write_file to resolve them." + fi + fi + + # Scan file on disk for secrets + if [ -f "$pw_path" ] && command -v pastewatch-cli &>/dev/null; then + if ! echo "$pw_path" | grep -qF '/.git/'; then + pastewatch-cli scan --check --fail-on-severity "$PW_SEVERITY" --file "$pw_path" >/dev/null 2>&1 + if [ $? -eq 6 ]; then + case "$tool_name" in + read_file) block "BLOCKED: $pw_path contains secrets. You MUST use pastewatch_read_file instead. Do NOT use any workaround." ;; + write_to_file) block "BLOCKED: $pw_path contains secrets. You MUST use pastewatch_write_file instead. Do NOT delete the file or use any workaround." ;; + edit_file) block "BLOCKED: $pw_path contains secrets. You MUST use pastewatch_read_file then pastewatch_write_file. Do NOT use any workaround." ;; + esac + fi + fi + fi + ;; + esac + fi + fi + + # Allow by default + echo '{\"cancel\": false}' + exit 0 + """ + } +} diff --git a/Sources/PastewatchCore/Allowlist.swift b/Sources/PastewatchCore/Allowlist.swift new file mode 100644 index 0000000..30265fd --- /dev/null +++ b/Sources/PastewatchCore/Allowlist.swift @@ -0,0 +1,63 @@ +import Foundation + +/// Manages allowed values that should be excluded from scan results. +public struct Allowlist { + public let values: Set + public let patterns: [NSRegularExpression] + + public init(values: Set = [], patterns: [NSRegularExpression] = []) { + self.values = values + self.patterns = patterns + } + + /// Load allowlist from a file (one value per line, # comments). + public static func load(from path: String) throws -> Allowlist { + let content = try String(contentsOfFile: path, encoding: .utf8) + let values = content + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty && !$0.hasPrefix("#") } + return Allowlist(values: Set(values)) + } + + /// Merge multiple allowlists. + public func merged(with other: Allowlist) -> Allowlist { + Allowlist(values: values.union(other.values), patterns: patterns + other.patterns) + } + + /// Merge with config's allowedValues and allowedPatterns. + public static func fromConfig(_ config: PastewatchConfig) -> Allowlist { + let compiled = config.allowedPatterns.compactMap { + try? NSRegularExpression(pattern: "^(\($0))$") + } + return Allowlist(values: Set(config.allowedValues), patterns: compiled) + } + + /// Filter matches, removing any whose value is in the allowlist or matches a pattern. + public func filter(_ matches: [DetectedMatch]) -> [DetectedMatch] { + matches.filter { match in + if values.contains(match.value) { return false } + for pattern in patterns { + let range = NSRange(match.value.startIndex..., in: match.value) + if pattern.firstMatch(in: match.value, range: range) != nil { return false } + } + return true + } + } + + /// Check if a value is allowed (should be skipped). + public func contains(_ value: String) -> Bool { + values.contains(value) + } + + /// Filter out matches on lines that contain a pastewatch:allow comment. + public static func filterInlineAllow(matches: [DetectedMatch], content: String) -> [DetectedMatch] { + guard !matches.isEmpty else { return [] } + let lines = content.components(separatedBy: "\n") + return matches.filter { match in + let lineIndex = match.line - 1 + guard lineIndex >= 0, lineIndex < lines.count else { return true } + return !lines[lineIndex].contains("pastewatch:allow") + } + } +} diff --git a/Sources/PastewatchCore/Baseline.swift b/Sources/PastewatchCore/Baseline.swift new file mode 100644 index 0000000..3680da3 --- /dev/null +++ b/Sources/PastewatchCore/Baseline.swift @@ -0,0 +1,77 @@ +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif +import Foundation + +/// A single baseline entry — a fingerprint of a known finding. +public struct BaselineEntry: Codable, Equatable { + public let fingerprint: String + public let filePath: String + + public init(fingerprint: String, filePath: String) { + self.fingerprint = fingerprint + self.filePath = filePath + } + + /// Create a fingerprint from a match: SHA256(type + ":" + value). + public static func from(match: DetectedMatch, filePath: String) -> BaselineEntry { + let input = match.type.rawValue + ":" + match.value + let digest = SHA256.hash(data: Data(input.utf8)) + let hex = digest.map { String(format: "%02x", $0) }.joined() + return BaselineEntry(fingerprint: hex, filePath: filePath) + } +} + +/// A baseline file containing known findings. +public struct BaselineFile: Codable { + public let version: String + public let entries: [BaselineEntry] + + public init(version: String = "1", entries: [BaselineEntry]) { + self.version = version + self.entries = entries + } + + /// Load a baseline from a JSON file. + public static func load(from path: String) throws -> BaselineFile { + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + return try JSONDecoder().decode(BaselineFile.self, from: data) + } + + /// Save baseline to a JSON file. + public func save(to path: String) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(self) + try data.write(to: URL(fileURLWithPath: path)) + } + + /// Filter out matches that exist in the baseline, returning only new findings. + public func filterNew(matches: [DetectedMatch], filePath: String) -> [DetectedMatch] { + let baselineFingerprints = Set(entries.map { $0.fingerprint }) + return matches.filter { match in + let entry = BaselineEntry.from(match: match, filePath: filePath) + return !baselineFingerprints.contains(entry.fingerprint) + } + } + + /// Filter file scan results, returning only files with new findings. + public func filterNewResults(results: [FileScanResult]) -> [FileScanResult] { + let baselineFingerprints = Set(entries.map { $0.fingerprint }) + var filtered: [FileScanResult] = [] + for fr in results { + let newMatches = fr.matches.filter { match in + let entry = BaselineEntry.from(match: match, filePath: fr.filePath) + return !baselineFingerprints.contains(entry.fingerprint) + } + if !newMatches.isEmpty { + filtered.append(FileScanResult( + filePath: fr.filePath, matches: newMatches, content: fr.content + )) + } + } + return filtered + } +} diff --git a/Sources/PastewatchCore/CanaryGenerator.swift b/Sources/PastewatchCore/CanaryGenerator.swift new file mode 100644 index 0000000..ea25047 --- /dev/null +++ b/Sources/PastewatchCore/CanaryGenerator.swift @@ -0,0 +1,169 @@ +import Foundation + +// MARK: - Types + +public struct CanaryToken: Codable { + public let type: String + public let value: String + + public init(type: String, value: String) { + self.type = type + self.value = value + } +} + +public struct CanaryManifest: Codable { + public let generatedAt: String + public let prefix: String + public let canaries: [CanaryToken] + + public init(generatedAt: String, prefix: String, canaries: [CanaryToken]) { + self.generatedAt = generatedAt + self.prefix = prefix + self.canaries = canaries + } +} + +public struct CanaryVerifyResult { + public let type: String + public let value: String + public let detected: Bool + public let detectedAs: String? +} + +public struct CanaryLeakResult { + public let type: String + public let value: String + public let found: Bool +} + +// MARK: - Generator + +public enum CanaryGenerator { + + /// Generate canary tokens for all critical types. + public static func generate(prefix: String = "canary") -> CanaryManifest { + let df = ISO8601DateFormatter() + df.formatOptions = [.withInternetDateTime] + let now = df.string(from: Date()) + + let canaries = [ + generateAWSKey(prefix: prefix), + generateGitHubToken(prefix: prefix), + generateOpenAIKey(prefix: prefix), + generateAnthropicKey(prefix: prefix), + generateDBURL(prefix: prefix), + generateStripeKey(prefix: prefix), + generateGenericAPIKey(prefix: prefix) + ] + + return CanaryManifest(generatedAt: now, prefix: prefix, canaries: canaries) + } + + /// Verify all canaries in manifest are detected by DetectionRules. + public static func verify(manifest: CanaryManifest) -> [CanaryVerifyResult] { + manifest.canaries.map { token in + let matches = DetectionRules.scan(token.value, config: .defaultConfig) + let firstMatch = matches.first + return CanaryVerifyResult( + type: token.type, + value: token.value, + detected: !matches.isEmpty, + detectedAs: firstMatch?.type.rawValue + ) + } + } + + /// Search log content for canary values. + public static func checkLog( + manifest: CanaryManifest, + logContent: String + ) -> [CanaryLeakResult] { + manifest.canaries.map { token in + CanaryLeakResult( + type: token.type, + value: token.value, + found: logContent.contains(token.value) + ) + } + } + + // MARK: - Per-type Generators + + /// AWS Key: AKIA + 16 uppercase alphanumeric chars. + /// Prefix is uppercased and truncated to fit within the 16-char suffix. + static func generateAWSKey(prefix: String) -> CanaryToken { + let upper = prefix.uppercased().filter { $0.isLetter || $0.isNumber } + let truncated = String(upper.prefix(10)) + let remaining = 16 - truncated.count + let value = "AKIA" + truncated + randomUpperAlphanumeric(count: remaining) + return CanaryToken(type: "AWS Key", value: value) + } + + /// GitHub Token: ghp_ + 36 alphanumeric chars. + static func generateGitHubToken(prefix: String) -> CanaryToken { + let safe = prefix.filter { $0.isLetter || $0.isNumber } + let truncated = String(safe.prefix(20)) + let remaining = 36 - truncated.count + let value = "ghp_" + truncated + randomAlphanumeric(count: remaining) + return CanaryToken(type: "GitHub Token", value: value) + } + + /// OpenAI Key: sk-proj- + 20+ alphanumeric/dash/underscore chars. + static func generateOpenAIKey(prefix: String) -> CanaryToken { + let safe = prefix.filter { $0.isLetter || $0.isNumber } + let truncated = String(safe.prefix(10)) + let remaining = 24 - truncated.count + let value = "sk-proj-" + truncated + randomAlphanumeric(count: remaining) + return CanaryToken(type: "OpenAI Key", value: value) + } + + /// Anthropic Key: sk-ant-api03- + 20+ alphanumeric/dash/underscore chars. + static func generateAnthropicKey(prefix: String) -> CanaryToken { + let safe = prefix.filter { $0.isLetter || $0.isNumber } + let truncated = String(safe.prefix(10)) + let remaining = 24 - truncated.count + let value = "sk-ant-api03-" + truncated + randomAlphanumeric(count: remaining) + return CanaryToken(type: "Anthropic Key", value: value) + } + + /// DB Connection String: protocol://prefix_user:prefix_pw_RANDOM@host:port/prefix_db + static func generateDBURL(prefix: String) -> CanaryToken { + let safe = prefix.filter { $0.isLetter || $0.isNumber } + let pw = randomAlphanumeric(count: 12) + let proto = ["postgres", "://"].joined() + let value = "\(proto)\(safe)_user:\(safe)_pw_\(pw)@canary.internal:5432/\(safe)_db" + return CanaryToken(type: "DB Connection", value: value) + } + + /// Stripe Key: sk_test_ + 24+ alphanumeric chars. + static func generateStripeKey(prefix: String) -> CanaryToken { + let safe = prefix.filter { $0.isLetter || $0.isNumber } + let truncated = String(safe.prefix(10)) + let remaining = 24 - truncated.count + let value = "sk_test_" + truncated + randomAlphanumeric(count: remaining) + return CanaryToken(type: "Stripe Key", value: value) + } + + /// Generic API Key: token_ + 20+ alphanumeric chars. + static func generateGenericAPIKey(prefix: String) -> CanaryToken { + let safe = prefix.filter { $0.isLetter || $0.isNumber } + let truncated = String(safe.prefix(10)) + let remaining = 20 - truncated.count + let value = "token_" + truncated + randomAlphanumeric(count: remaining) + return CanaryToken(type: "API Key", value: value) + } + + // MARK: - Random Helpers + + private static let alphanumericChars = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") + private static let upperAlphanumericChars = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + + static func randomAlphanumeric(count: Int) -> String { + String((0.. String { + String((0.. = [ + "cat", "head", "tail", "less", "more", "bat", "tac", "nl", + ] + + /// Commands that modify files in-place. + private static let fileWriters: Set = [ + "sed", "awk", + ] + + /// Commands that search file contents (output includes matching lines). + private static let fileSearchers: Set = [ + "grep", "egrep", "fgrep", "rg", "ag", + ] + + /// Commands that source/execute a file. + private static let fileSourcers: Set = [ + "source", ".", + ] + + /// Scripting interpreters that execute a script file (first positional arg). + private static let scriptInterpreters: Set = [ + "python3", "python", "python3.11", "python3.12", "python3.13", + "ruby", "node", "perl", "php", "lua", + ] + + /// Flags that take inline code for scripting interpreters (skip — can't parse). + private static let scriptInlineFlags: Set = ["-c", "-e"] + + /// File transfer and remote tools that read local files. + private static let fileTransferTools: Set = [ + "scp", "rsync", "ssh", "ssh-keygen", + ] + + /// Flags that take a file path for transfer/remote tools. + private static let transferFlagsWithFile: [String: Set] = [ + "scp": [], + "rsync": ["--password-file", "--include-from", "--exclude-from"], + "ssh": ["-i", "-F"], + "ssh-keygen": ["-f"], + ] + + /// Database CLI tools that may read credential files or contain inline secrets. + private static let databaseCLIs: Set = [ + "psql", "mysql", "mongosh", "mongo", "redis-cli", "sqlite3", + ] + + /// Flags that take a file path for database CLIs. + private static let dbFlagsWithFile: [String: Set] = [ + "psql": ["-f", "--file"], + "mysql": ["--defaults-file", "--defaults-extra-file"], + "mongosh": [], + "mongo": [], + "redis-cli": [], + "sqlite3": [], + ] + + /// Infrastructure tools that read config/inventory files via flags and positional args. + private static let infraTools: Set = [ + "ansible-playbook", "ansible", "ansible-vault", + "terraform", "docker-compose", "docker", "kubectl", "helm", + ] + + /// Flags that take a file path as their next argument, per infra tool. + private static let infraFlagsWithFile: [String: Set] = [ + "ansible-playbook": ["-i", "--inventory", "--vault-password-file", "--private-key", "-e", "--extra-vars"], + "ansible": ["-i", "--inventory", "--vault-password-file", "--private-key", "-e", "--extra-vars"], + "ansible-vault": ["--vault-password-file"], + "terraform": ["-var-file"], + "docker-compose": ["-f", "--file", "--env-file"], + "docker": ["--env-file"], + "kubectl": ["-f", "--filename", "--kubeconfig"], + "helm": ["-f", "--values", "--kubeconfig"], + ] + + /// Extract file paths from a shell command string. + /// Handles pipe chains (|), command chaining (&&, ||, ;), redirects, and subshells. + /// Returns absolute paths resolved against `workingDirectory`. + /// Returns empty array for unknown commands (allow by default). + public static func extractFilePaths( + from command: String, + workingDirectory: String = FileManager.default.currentDirectoryPath + ) -> [String] { + var allPaths: [String] = [] + + // Process main command segments + let segments = splitCommandChain(command) + for segment in segments { + let (cleaned, inputFiles) = stripRedirects(segment) + allPaths.append(contentsOf: inputFiles.flatMap { + expandAndResolve($0, workingDirectory: workingDirectory) + }) + allPaths.append(contentsOf: extractFilePathsSingle( + from: cleaned, workingDirectory: workingDirectory + )) + } + + // Extract and process subshell commands (one level deep) + let subshellCommands = extractSubshellCommands(command) + for subCmd in subshellCommands { + let subSegments = splitCommandChain(subCmd) + for segment in subSegments { + let (cleaned, inputFiles) = stripRedirects(segment) + allPaths.append(contentsOf: inputFiles.flatMap { + expandAndResolve($0, workingDirectory: workingDirectory) + }) + allPaths.append(contentsOf: extractFilePathsSingle( + from: cleaned, workingDirectory: workingDirectory + )) + } + } + + return allPaths + } + + /// Extract file paths from a single command (no pipes or chaining). + private static func extractFilePathsSingle( + from command: String, + workingDirectory: String + ) -> [String] { + let tokens = tokenize(command) + guard let rawCmd = tokens.first else { return [] } + + let args = Array(tokens.dropFirst()) + + // Check sourcers first (before path stripping, since "." is a valid command name) + if fileSourcers.contains(rawCmd) { + let rawPaths = args.isEmpty ? [] : [args[0]] + return rawPaths.flatMap { expandAndResolve($0, workingDirectory: workingDirectory) } + } + + // Strip path prefix: /usr/bin/cat → cat + let cmd: String + if rawCmd.contains("/") { + cmd = (rawCmd as NSString).lastPathComponent + } else { + cmd = rawCmd + } + + let rawPaths: [String] + + if fileReaders.contains(cmd) { + rawPaths = extractPositionalArgs(args) + } else if fileWriters.contains(cmd) { + rawPaths = extractLastFileArg(args) + } else if fileSearchers.contains(cmd) { + rawPaths = extractGrepFileArgs(args) + } else if scriptInterpreters.contains(cmd) { + rawPaths = extractScriptFileArgs(args) + } else if fileTransferTools.contains(cmd) { + rawPaths = extractTransferFileArgs(cmd, args: args) + } else if infraTools.contains(cmd) { + rawPaths = extractInfraFileArgs(cmd, args: args) + } else if databaseCLIs.contains(cmd) { + rawPaths = extractDBFileArgs(cmd, args: args) + } else { + return [] + } + + return rawPaths.flatMap { expandAndResolve($0, workingDirectory: workingDirectory) } + } + + // MARK: - Command chain splitting + + /// Split a command string on pipes (|) and chain operators (&&, ||, ;). + /// Respects quotes — operators inside quotes are not split on. + static func splitCommandChain(_ command: String) -> [String] { + var segments: [String] = [] + var current = "" + var inSingle = false + var inDouble = false + var escaped = false + let chars = Array(command) + var i = 0 + + while i < chars.count { + let char = chars[i] + + if escaped { + current.append(char) + escaped = false + i += 1 + continue + } + + if char == "\\" && !inSingle { + escaped = true + current.append(char) + i += 1 + continue + } + + if char == "'" && !inDouble { + inSingle.toggle() + current.append(char) + i += 1 + continue + } + + if char == "\"" && !inSingle { + inDouble.toggle() + current.append(char) + i += 1 + continue + } + + // Only split when not inside quotes + if !inSingle && !inDouble { + // Check for && or || + if i + 1 < chars.count { + let next = chars[i + 1] + if (char == "&" && next == "&") || (char == "|" && next == "|") { + let trimmed = current.trimmingCharacters(in: .whitespaces) + if !trimmed.isEmpty { segments.append(trimmed) } + current = "" + i += 2 + continue + } + } + + // Single pipe (not ||) + if char == "|" { + let trimmed = current.trimmingCharacters(in: .whitespaces) + if !trimmed.isEmpty { segments.append(trimmed) } + current = "" + i += 1 + continue + } + + // Semicolon + if char == ";" { + let trimmed = current.trimmingCharacters(in: .whitespaces) + if !trimmed.isEmpty { segments.append(trimmed) } + current = "" + i += 1 + continue + } + } + + current.append(char) + i += 1 + } + + let trimmed = current.trimmingCharacters(in: .whitespaces) + if !trimmed.isEmpty { segments.append(trimmed) } + + return segments + } + + // MARK: - Redirect stripping + + /// Strip redirect operators and their targets from a command segment. + /// Returns the cleaned command and any input redirect source files. + /// Works at the character level to preserve quoting in the original string. + static func stripRedirects(_ segment: String) -> (command: String, inputFiles: [String]) { + var inputFiles: [String] = [] + let chars = Array(segment) + var result: [Character] = [] + var inSingle = false + var inDouble = false + var escaped = false + var i = 0 + + while i < chars.count { + if escaped { + result.append(chars[i]) + escaped = false + i += 1 + continue + } + if chars[i] == "\\" && !inSingle { + escaped = true + result.append(chars[i]) + i += 1 + continue + } + if chars[i] == "'" && !inDouble { + inSingle.toggle() + result.append(chars[i]) + i += 1 + continue + } + if chars[i] == "\"" && !inSingle { + inDouble.toggle() + result.append(chars[i]) + i += 1 + continue + } + + // Only detect redirects outside quotes + if !inSingle && !inDouble { + let remaining = chars[i...] + + // Check for output redirects (order: longest prefix first) + if let skip = matchOutputRedirect(remaining) { + i += skip + continue + } + + // Check for input redirect: < (not <<) + if chars[i] == "<" && !(i + 1 < chars.count && chars[i + 1] == "<") { + i += 1 + // Skip whitespace + while i < chars.count && chars[i] == " " { i += 1 } + // Collect the file path + let file = collectWord(chars, from: &i) + if !file.isEmpty { inputFiles.append(file) } + continue + } + + // Skip heredoc << or <<- + if chars[i] == "<" && i + 1 < chars.count && chars[i + 1] == "<" { + i += 2 + if i < chars.count && chars[i] == "-" { i += 1 } + while i < chars.count && chars[i] == " " { i += 1 } + // Skip the delimiter word + _ = collectWord(chars, from: &i) + continue + } + } + + result.append(chars[i]) + i += 1 + } + + let cleaned = String(result).trimmingCharacters(in: .whitespaces) + // Collapse multiple spaces + let collapsed = cleaned.replacingOccurrences( + of: " +", with: " ", options: .regularExpression + ) + return (command: collapsed, inputFiles: inputFiles) + } + + /// Match an output redirect operator at the current position. + /// Returns the number of characters to skip (operator + whitespace + target word), or nil. + private static func matchOutputRedirect(_ chars: ArraySlice) -> Int? { + let prefixes: [(String, Int)] = [ + ("&>>", 3), ("&>", 2), ("2>>", 3), ("2>", 2), (">>", 2), (">", 1), + ] + let arr = Array(chars) + for (prefix, len) in prefixes where arr.count >= len { + let candidate = String(arr[0.. String { + guard i < chars.count else { return "" } + var word = "" + + // Handle quoted word + if chars[i] == "'" || chars[i] == "\"" { + let quote = chars[i] + i += 1 + while i < chars.count && chars[i] != quote { + word.append(chars[i]) + i += 1 + } + if i < chars.count { i += 1 } // skip closing quote + return word + } + + // Unquoted word — collect until space + while i < chars.count && chars[i] != " " { + word.append(chars[i]) + i += 1 + } + return word + } + + // MARK: - Subshell extraction + + /// Extract commands from $(...) and backtick expressions. + /// Returns inner commands for recursive processing. + /// One level deep only — does not parse nested subshells. + static func extractSubshellCommands(_ command: String) -> [String] { + var commands: [String] = [] + var inSingle = false + var inDouble = false + var escaped = false + let chars = Array(command) + var i = 0 + + while i < chars.count { + let char = chars[i] + + if escaped { + escaped = false + i += 1 + continue + } + + if char == "\\" && !inSingle { + escaped = true + i += 1 + continue + } + + if char == "'" && !inDouble { + inSingle.toggle() + i += 1 + continue + } + + if char == "\"" && !inSingle { + inDouble.toggle() + i += 1 + continue + } + + // $(...) outside quotes (or inside double quotes — subshells expand there) + if !inSingle && char == "$" && i + 1 < chars.count && chars[i + 1] == "(" { + // Find matching closing paren + var depth = 1 + var j = i + 2 + while j < chars.count && depth > 0 { + if chars[j] == "(" { depth += 1 } + if chars[j] == ")" { depth -= 1 } + j += 1 + } + // Extract content between $( and ) + let start = i + 2 + let end = j - 1 + if start < end { + let inner = String(chars[start.. [String] { + var tokens: [String] = [] + var current = "" + var inSingle = false + var inDouble = false + var escaped = false + + for char in command { + if escaped { + current.append(char) + escaped = false + continue + } + + if char == "\\" && !inSingle { + escaped = true + continue + } + + if char == "'" && !inDouble { + inSingle.toggle() + continue + } + + if char == "\"" && !inSingle { + inDouble.toggle() + continue + } + + if char == " " && !inSingle && !inDouble { + if !current.isEmpty { + tokens.append(current) + current = "" + } + continue + } + + current.append(char) + } + + if !current.isEmpty { + tokens.append(current) + } + + return tokens + } + + // MARK: - Argument extractors + + /// Extract positional (non-flag) arguments — used for cat, head, tail, etc. + /// Skips flags (tokens starting with `-`) and their values for known flag patterns. + private static func extractPositionalArgs(_ args: [String]) -> [String] { + // Flags that take a value for file-reader commands (head -n 10, tail -c 100) + let readerFlagsWithValue: Set = ["-n", "-c"] + + var paths: [String] = [] + var skipNext = false + + for arg in args { + if skipNext { + skipNext = false + continue + } + + if arg == "--" { + continue + } + + if arg.hasPrefix("-") { + if readerFlagsWithValue.contains(arg) { + skipNext = true + } + continue + } + + paths.append(arg) + } + + return paths + } + + /// For sed/awk: extract the last non-flag argument (the file path). + /// Skips the script argument and flags. + private static func extractLastFileArg(_ args: [String]) -> [String] { + // Find the last token that looks like a file path (not a flag, not a sed script) + let positional = args.filter { !$0.hasPrefix("-") } + // For sed: first positional is usually the script, rest are files + // For awk: first positional is the script, last is the file + guard positional.count >= 2 else { return [] } + return Array(positional.dropFirst()) + } + + /// For grep/rg: extract file arguments after the pattern. + /// Pattern is the first positional arg; remaining positional args are files. + private static func extractGrepFileArgs(_ args: [String]) -> [String] { + // Flags that consume the next token as a value for grep + let grepFlagsWithValue: Set = [ + "-e", "-f", "-m", + "-A", "-B", "-C", + "--include", "--exclude", "--max-count", + ] + + var positional: [String] = [] + var skipNext = false + + for arg in args { + if skipNext { + skipNext = false + continue + } + if arg.hasPrefix("-") { + if grepFlagsWithValue.contains(arg) { + skipNext = true + } + continue + } + positional.append(arg) + } + + // First positional is the pattern, rest are files + guard positional.count >= 2 else { return [] } + return Array(positional.dropFirst()) + } + + /// For scripting interpreters: extract the script file (first positional arg). + /// Skips -c/-e inline code flags and their arguments. + private static func extractScriptFileArgs(_ args: [String]) -> [String] { + var skipNext = false + + for arg in args { + if skipNext { + skipNext = false + continue + } + + // -c/-e take inline code as next arg — skip both + if scriptInlineFlags.contains(arg) { + skipNext = true + continue + } + + // Skip other flags + if arg.hasPrefix("-") { + continue + } + + // First positional arg is the script file + return [arg] + } + + return [] + } + + /// For file transfer tools: extract file paths from flags and positional args. + private static func extractTransferFileArgs(_ cmd: String, args: [String]) -> [String] { + let flagsWithFile = transferFlagsWithFile[cmd] ?? [] + var paths: [String] = [] + var skipNext = false + + for arg in args { + if skipNext { + paths.append(arg) + skipNext = false + continue + } + + if arg.hasPrefix("-") { + if flagsWithFile.contains(arg) { + skipNext = true + } + continue + } + + // For scp/rsync: positional args that don't contain ":" are local files + if cmd == "scp" || cmd == "rsync" { + if !arg.contains(":") { + paths.append(arg) + } + } + } + + return paths + } + + /// For infrastructure tools: extract file paths from known flags and positional args. + /// Positional args that look like file paths (contain / or .) are included. + private static func extractInfraFileArgs(_ cmd: String, args: [String]) -> [String] { + let flagsWithFile = infraFlagsWithFile[cmd] ?? [] + var paths: [String] = [] + var skipNext = false + + for arg in args { + if skipNext { + // Handle ansible -e @file syntax + if arg.hasPrefix("@") { + paths.append(String(arg.dropFirst())) + } else { + paths.append(arg) + } + skipNext = false + continue + } + + // Check for --flag=value syntax + if arg.contains("=") { + let parts = arg.split(separator: "=", maxSplits: 1) + let flag = String(parts[0]) + if flagsWithFile.contains(flag), parts.count == 2 { + let value = String(parts[1]) + if value.hasPrefix("@") { + paths.append(String(value.dropFirst())) + } else { + paths.append(value) + } + } + continue + } + + if arg.hasPrefix("-") { + if flagsWithFile.contains(arg) { + skipNext = true + } + continue + } + + // Positional args — include if they look like file paths + let lowerArg = arg.lowercased() + let hasPathChars = arg.contains("/") || arg.contains(".") + let isKnownExt = lowerArg.hasSuffix(".yml") || lowerArg.hasSuffix(".yaml") + || lowerArg.hasSuffix(".json") || lowerArg.hasSuffix(".tf") + || lowerArg.hasSuffix(".env") || lowerArg.hasSuffix(".toml") + || lowerArg.hasSuffix(".cfg") || lowerArg.hasSuffix(".ini") + || lowerArg.hasSuffix(".conf") + if hasPathChars || isKnownExt { + paths.append(arg) + } + } + + return paths + } + + /// For database CLIs: extract file paths from known flags. + private static func extractDBFileArgs(_ cmd: String, args: [String]) -> [String] { + let flagsWithFile = dbFlagsWithFile[cmd] ?? [] + var paths: [String] = [] + var skipNext = false + + for arg in args { + if skipNext { + paths.append(arg) + skipNext = false + continue + } + + // Check for --flag=value syntax + if arg.contains("=") { + let parts = arg.split(separator: "=", maxSplits: 1) + let flag = String(parts[0]) + if flagsWithFile.contains(flag), parts.count == 2 { + paths.append(String(parts[1])) + } + continue + } + + if arg.hasPrefix("-") { + if flagsWithFile.contains(arg) { + skipNext = true + } + continue + } + + // For sqlite3: first positional arg is the database file + if cmd == "sqlite3" && paths.isEmpty { + paths.append(arg) + break + } + } + + return paths + } + + // MARK: - Inline value extraction + + /// Extract inline values from a command that may contain secrets. + /// These are argument values (not file paths) to scan with DetectionRules. + /// Returns raw strings to be scanned directly. + public static func extractInlineValues(from command: String) -> [String] { + let segments = splitCommandChain(command) + var allValues: [String] = [] + for segment in segments { + let (cleaned, _) = stripRedirects(segment) + allValues.append(contentsOf: extractInlineValuesSingle(from: cleaned)) + } + return allValues + } + + /// Extract inline values from a single command. + private static func extractInlineValuesSingle(from command: String) -> [String] { + let tokens = tokenize(command) + guard let rawCmd = tokens.first else { return [] } + + let args = Array(tokens.dropFirst()) + + let cmd: String + if rawCmd.contains("/") { + cmd = (rawCmd as NSString).lastPathComponent + } else { + cmd = rawCmd + } + + guard databaseCLIs.contains(cmd) else { return [] } + + if let extractor = inlineExtractors[cmd] { + return extractor(args) + } + return [] + } + + /// Per-CLI inline value extractors, dispatched by command name. + private static let inlineExtractors: [String: ([String]) -> [String]] = [ + "psql": extractPsqlInlineValues, + "mysql": extractMysqlInlineValues, + "mongosh": extractMongoInlineValues, + "mongo": extractMongoInlineValues, + "redis-cli": extractRedisInlineValues, + ] + + /// psql: first positional arg containing :// is a connection string. + private static func extractPsqlInlineValues(_ args: [String]) -> [String] { + var skipNext = false + for arg in args { + if skipNext { skipNext = false; continue } + if arg == "-f" || arg == "--file" || arg == "-h" || arg == "-p" + || arg == "-U" || arg == "-d" || arg == "-o" { + skipNext = true + continue + } + if arg.hasPrefix("-") { continue } + if arg.contains("://") { return [arg] } + } + return [] + } + + /// mysql: -p (attached), --password=, -p (space). + private static func extractMysqlInlineValues(_ args: [String]) -> [String] { + var values: [String] = [] + var i = 0 + while i < args.count { + let arg = args[i] + + // --password=value + if arg.hasPrefix("--password=") { + let value = String(arg.dropFirst("--password=".count)) + if !value.isEmpty { values.append(value) } + i += 1 + continue + } + + // -pPASSWORD (attached, no space) — not -P (port) or --p* long flags + if arg.hasPrefix("-p") && arg.count > 2 && !arg.hasPrefix("-P") + && !arg.hasPrefix("--") { + values.append(String(arg.dropFirst(2))) + i += 1 + continue + } + + // First positional containing :// is a connection string + if !arg.hasPrefix("-") && arg.contains("://") { + values.append(arg) + } + + i += 1 + } + return values + } + + /// mongosh/mongo: first positional arg containing :// is a connection string. + private static func extractMongoInlineValues(_ args: [String]) -> [String] { + for arg in args { + if arg.hasPrefix("-") { continue } + if arg.contains("://") { return [arg] } + } + return [] + } + + /// redis-cli: -a (auth password), -u (URL with auth). + private static func extractRedisInlineValues(_ args: [String]) -> [String] { + var values: [String] = [] + var i = 0 + while i < args.count { + let arg = args[i] + if arg == "-a" && i + 1 < args.count { + values.append(args[i + 1]) + i += 2 + continue + } + if arg == "-u" && i + 1 < args.count { + values.append(args[i + 1]) + i += 2 + continue + } + i += 1 + } + return values + } + + // MARK: - Path resolution + + /// Resolve a raw path to absolute, expanding globs if present. + private static func expandAndResolve( + _ rawPath: String, + workingDirectory: String + ) -> [String] { + // Check for glob characters + if rawPath.contains("*") || rawPath.contains("?") || rawPath.contains("[") { + return expandGlob(rawPath, workingDirectory: workingDirectory) + } + + return [resolvePath(rawPath, workingDirectory: workingDirectory)] + } + + /// Resolve a single path to absolute. + static func resolvePath(_ path: String, workingDirectory: String) -> String { + if path.hasPrefix("/") { + return path + } + if path.hasPrefix("~/") { + return NSString(string: path).expandingTildeInPath + } + let base = URL(fileURLWithPath: workingDirectory) + return base.appendingPathComponent(path).standardized.path + } + + /// Expand a glob pattern to matching file paths. + private static func expandGlob(_ pattern: String, workingDirectory: String) -> [String] { + let resolved = resolvePath(pattern, workingDirectory: workingDirectory) + let nsPattern = resolved as NSString + + let dir = nsPattern.deletingLastPathComponent + let filePattern = nsPattern.lastPathComponent + + guard let enumerator = FileManager.default.enumerator( + atPath: dir.isEmpty ? "." : dir + ) else { + return [] + } + + var matches: [String] = [] + while let file = enumerator.nextObject() as? String { + enumerator.skipDescendants() + if fnmatch(filePattern, file, 0) == 0 { + let fullPath = (dir as NSString).appendingPathComponent(file) + matches.append(fullPath) + } + } + + return matches + } +} diff --git a/Sources/PastewatchCore/ConfigValidator.swift b/Sources/PastewatchCore/ConfigValidator.swift new file mode 100644 index 0000000..caa026a --- /dev/null +++ b/Sources/PastewatchCore/ConfigValidator.swift @@ -0,0 +1,132 @@ +import Foundation + +public struct ConfigValidationResult { + public let errors: [String] + public var isValid: Bool { errors.isEmpty } +} + +public enum ConfigValidator { + /// Validate a config file at the given path, or the resolved config if nil. + public static func validate(path: String? = nil) -> ConfigValidationResult { + let loaded = loadConfigData(path: path) + guard let (data, configPath) = loaded.value else { + return ConfigValidationResult(errors: loaded.errors) + } + + var errors: [String] = [] + + // Validate JSON syntax + let config: PastewatchConfig + do { + config = try JSONDecoder().decode(PastewatchConfig.self, from: data) + } catch { + return ConfigValidationResult(errors: ["\(configPath): invalid JSON: \(error.localizedDescription)"]) + } + + // Validate enabledTypes + let validTypeNames = Set(SensitiveDataType.allCases.map { $0.rawValue }) + for typeName in config.enabledTypes where !validTypeNames.contains(typeName) { + errors.append("unknown type in enabledTypes: '\(typeName)'") + } + + // Validate custom rules + for (i, rule) in config.customRules.enumerated() { + validateRule(rule, index: i, errors: &errors) + } + + // Validate safeHosts / sensitiveHosts + for (i, host) in config.safeHosts.enumerated() + where host.trimmingCharacters(in: .whitespaces).isEmpty { + errors.append("safeHosts[\(i)]: empty value") + } + for (i, host) in config.sensitiveHosts.enumerated() + where host.trimmingCharacters(in: .whitespaces).isEmpty { + errors.append("sensitiveHosts[\(i)]: empty value") + } + let safeSet = Set(config.safeHosts.map { $0.lowercased() }) + let sensitiveSet = Set(config.sensitiveHosts.map { $0.lowercased() }) + let overlap = safeSet.intersection(sensitiveSet) + for host in overlap.sorted() { + errors.append("'\(host)' appears in both safeHosts and sensitiveHosts (sensitiveHosts takes precedence)") + } + + // Validate sensitiveIPPrefixes + for (i, prefix) in config.sensitiveIPPrefixes.enumerated() { + let trimmed = prefix.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { + errors.append("sensitiveIPPrefixes[\(i)]: empty value") + } else if !trimmed.allSatisfy({ $0.isNumber || $0 == "." }) { + errors.append("sensitiveIPPrefixes[\(i)]: must contain only digits and dots") + } + } + + // Validate allowedPatterns + for (i, pattern) in config.allowedPatterns.enumerated() { + if pattern.trimmingCharacters(in: .whitespaces).isEmpty { + errors.append("allowedPatterns[\(i)]: empty pattern") + } else { + do { + _ = try NSRegularExpression(pattern: pattern) + } catch { + errors.append("allowedPatterns[\(i)]: invalid regex: \(error.localizedDescription)") + } + } + } + + // Validate mcpMinSeverity + if Severity(rawValue: config.mcpMinSeverity) == nil { + errors.append("mcpMinSeverity: invalid severity '\(config.mcpMinSeverity)' (use: critical, high, medium, low)") + } + + return ConfigValidationResult(errors: errors) + } + + private static func validateRule(_ rule: CustomRuleConfig, index i: Int, errors: inout [String]) { + if rule.name.isEmpty { + errors.append("customRules[\(i)]: name is empty") + } + if rule.pattern.isEmpty { + errors.append("customRules[\(i)]: pattern is empty") + } else { + do { + _ = try NSRegularExpression(pattern: rule.pattern) + } catch { + errors.append("customRules[\(i)] '\(rule.name)': invalid regex: \(error.localizedDescription)") + } + } + if let sev = rule.severity, Severity(rawValue: sev) == nil { + errors.append("customRules[\(i)] '\(rule.name)': invalid severity '\(sev)' (use: critical, high, medium, low)") + } + } + + private static func loadConfigData(path: String?) -> (value: (Data, String)?, errors: [String]) { + if let path = path { + guard FileManager.default.fileExists(atPath: path) else { + return (nil, ["file not found: \(path)"]) + } + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { + return (nil, ["could not read: \(path)"]) + } + return ((data, path), []) + } + + let cwd = FileManager.default.currentDirectoryPath + let projectPath = cwd + "/.pastewatch.json" + if FileManager.default.fileExists(atPath: projectPath) { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: projectPath)) else { + return (nil, ["could not read: \(projectPath)"]) + } + return ((data, projectPath), []) + } + + if FileManager.default.fileExists(atPath: PastewatchConfig.configPath.path) { + guard let data = try? Data(contentsOf: PastewatchConfig.configPath) else { + return (nil, ["could not read: \(PastewatchConfig.configPath.path)"]) + } + return ((data, PastewatchConfig.configPath.path), []) + } + + // No config file found — using defaults is valid + return (nil, []) + } +} diff --git a/Sources/PastewatchCore/CustomRule.swift b/Sources/PastewatchCore/CustomRule.swift new file mode 100644 index 0000000..3a70a22 --- /dev/null +++ b/Sources/PastewatchCore/CustomRule.swift @@ -0,0 +1,51 @@ +import Foundation + +/// A compiled custom detection rule. +public struct CustomRule { + public let name: String + public let regex: NSRegularExpression + public let severity: Severity + + public init(name: String, regex: NSRegularExpression, severity: Severity = .high) { + self.name = name + self.regex = regex + self.severity = severity + } + + /// Load custom rules from a JSON file. + public static func load(from path: String) throws -> [CustomRule] { + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + let configs = try JSONDecoder().decode([CustomRuleConfig].self, from: data) + return try compile(configs) + } + + /// Compile CustomRuleConfig array into CustomRule array. + public static func compile(_ configs: [CustomRuleConfig]) throws -> [CustomRule] { + try configs.map { config in + do { + let regex = try NSRegularExpression(pattern: config.pattern) + let severity: Severity + if let sevStr = config.severity, let sev = Severity(rawValue: sevStr) { + severity = sev + } else { + severity = .high + } + return CustomRule(name: config.name, regex: regex, severity: severity) + } catch { + throw CustomRuleError.invalidPattern(name: config.name, pattern: config.pattern) + } + } + } +} + +/// Errors for custom rule loading. +public enum CustomRuleError: Error, LocalizedError { + case invalidPattern(name: String, pattern: String) + + public var errorDescription: String? { + switch self { + case .invalidPattern(let name, let pattern): + return "invalid regex in custom rule '\(name)': \(pattern)" + } + } +} diff --git a/Sources/PastewatchCore/DashboardBuilder.swift b/Sources/PastewatchCore/DashboardBuilder.swift new file mode 100644 index 0000000..e4b8a8b --- /dev/null +++ b/Sources/PastewatchCore/DashboardBuilder.swift @@ -0,0 +1,175 @@ +import Foundation + +// MARK: - Dashboard Types + +/// Aggregate dashboard across multiple audit log sessions. +public struct Dashboard: Codable { + public let generatedAt: String + public let sessions: Int + public let period: DashboardPeriod + public let summary: SessionSummary + public let topTypes: [TypeCount] + public let hotFiles: [FileAccess] + public let verdict: String +} + +/// Time range covered by the dashboard. +public struct DashboardPeriod: Codable { + public let earliest: String? + public let latest: String? +} + +// MARK: - Builder + +/// Aggregates multiple audit logs into a single dashboard. +public enum DashboardBuilder { + + /// Build dashboard from all audit log files in a directory. + public static func build(logDirectory: String, since: Date? = nil) -> Dashboard { + let fm = FileManager.default + let df = ISO8601DateFormatter() + df.formatOptions = [.withInternetDateTime] + let now = df.string(from: Date()) + + // Find all audit log files + let logFiles = findAuditLogs(in: logDirectory, fm: fm) + + guard !logFiles.isEmpty else { + return Dashboard( + generatedAt: now, sessions: 0, + period: DashboardPeriod(earliest: nil, latest: nil), + summary: emptySummary(), + topTypes: [], hotFiles: [], verdict: "No audit logs found" + ) + } + + // Build individual reports + var reports: [SessionReport] = [] + for path in logFiles { + guard let content = try? String(contentsOfFile: path, encoding: .utf8), + !content.isEmpty else { continue } + let report = SessionReportBuilder.build(content: content, logPath: path, since: since) + reports.append(report) + } + + guard !reports.isEmpty else { + return Dashboard( + generatedAt: now, sessions: 0, + period: DashboardPeriod(earliest: nil, latest: nil), + summary: emptySummary(), + topTypes: [], hotFiles: [], verdict: "No audit log entries found" + ) + } + + // Aggregate summaries + let summary = aggregateSummaries(reports) + let topTypes = aggregateTypes(reports) + let hotFiles = aggregateFiles(reports) + let period = aggregatePeriod(reports) + let verdict = computeVerdict(summary) + + return Dashboard( + generatedAt: now, + sessions: reports.count, + period: period, + summary: summary, + topTypes: topTypes, + hotFiles: hotFiles, + verdict: verdict + ) + } + + // MARK: - Private + + private static func findAuditLogs(in directory: String, fm: FileManager) -> [String] { + guard let entries = try? fm.contentsOfDirectory(atPath: directory) else { return [] } + return entries + .filter { $0.hasPrefix("pastewatch-audit") && $0.hasSuffix(".log") } + .map { (directory as NSString).appendingPathComponent($0) } + .sorted() + } + + private static func emptySummary() -> SessionSummary { + SessionSummary( + filesRead: 0, filesWritten: 0, secretsRedacted: 0, + placeholdersResolved: 0, unresolvedPlaceholders: 0, + outputChecks: 0, outputChecksDirty: 0, scans: 0, scanFindings: 0 + ) + } + + private static func aggregateSummaries(_ reports: [SessionReport]) -> SessionSummary { + var fr = 0, fw = 0, sr = 0, pr = 0, up = 0, oc = 0, ocd = 0, sc = 0, sf = 0 + for r in reports { + fr += r.summary.filesRead + fw += r.summary.filesWritten + sr += r.summary.secretsRedacted + pr += r.summary.placeholdersResolved + up += r.summary.unresolvedPlaceholders + oc += r.summary.outputChecks + ocd += r.summary.outputChecksDirty + sc += r.summary.scans + sf += r.summary.scanFindings + } + return SessionSummary( + filesRead: fr, filesWritten: fw, secretsRedacted: sr, + placeholdersResolved: pr, unresolvedPlaceholders: up, + outputChecks: oc, outputChecksDirty: ocd, scans: sc, scanFindings: sf + ) + } + + private static func aggregateTypes(_ reports: [SessionReport]) -> [TypeCount] { + var counts: [String: (count: Int, severity: String)] = [:] + for r in reports { + for tc in r.secretsByType { + let existing = counts[tc.type] ?? (count: 0, severity: tc.severity) + counts[tc.type] = (count: existing.count + tc.count, severity: existing.severity) + } + } + return counts + .map { TypeCount(type: $0.key, count: $0.value.count, severity: $0.value.severity) } + .sorted { $0.count > $1.count } + } + + private struct FileStats { + var reads: Int = 0 + var writes: Int = 0 + var secrets: Int = 0 + } + + private static func aggregateFiles(_ reports: [SessionReport]) -> [FileAccess] { + var files: [String: FileStats] = [:] + for r in reports { + for fa in r.filesAccessed { + var existing = files[fa.file] ?? FileStats() + existing.reads += fa.reads + existing.writes += fa.writes + existing.secrets += fa.secretsRedacted + files[fa.file] = existing + } + } + return files + .map { FileAccess(file: $0.key, reads: $0.value.reads, writes: $0.value.writes, secretsRedacted: $0.value.secrets) } + .sorted { $0.secretsRedacted > $1.secretsRedacted } + .prefix(10) + .map { $0 } + } + + private static func aggregatePeriod(_ reports: [SessionReport]) -> DashboardPeriod { + let starts = reports.compactMap { $0.periodStart } + let ends = reports.compactMap { $0.periodEnd } + return DashboardPeriod( + earliest: starts.min(), + latest: ends.max() + ) + } + + private static func computeVerdict(_ summary: SessionSummary) -> String { + if summary.unresolvedPlaceholders > 0 || summary.outputChecksDirty > 0 { + return "WARNING: \(summary.unresolvedPlaceholders) unresolved placeholder(s), \(summary.outputChecksDirty) dirty check(s)" + } + if summary.secretsRedacted == 0 && summary.filesRead == 0 { + return "No MCP activity recorded" + } + return "Zero secrets leaked — \(summary.secretsRedacted) redacted across \(summary.filesRead) file read(s)" + } +} diff --git a/Sources/PastewatchCore/DetectionRules.swift b/Sources/PastewatchCore/DetectionRules.swift new file mode 100644 index 0000000..d5d628b --- /dev/null +++ b/Sources/PastewatchCore/DetectionRules.swift @@ -0,0 +1,850 @@ +import Foundation + +/// Deterministic detection rules for sensitive data. +/// No ML. No confidence scores. No guessing. +/// +/// Each rule is a regex pattern that matches high-confidence patterns only. +/// False negatives are preferred over false positives. +public struct DetectionRules { + + /// Safe hosts that should not trigger hostname detection. + /// Matches chainwatch's safeHosts for consistency across tools. + static let safeHosts: Set = [ + // Common public domains + "example.com", "example.org", "example.net", + "localhost", + "github.com", "google.com", + "cloudflare.com", "amazonaws.com", + "ubuntu.com", "debian.org", "kernel.org", + "wikipedia.org", + "stackexchange.com", "stackoverflow.com", + "apple.com", "microsoft.com", + "npmjs.com", "pypi.org", "swift.org", + "golang.org", + // Badge and CI services + "img.shields.io", "badge.fury.io", + "badgen.net", "codecov.io", + "coveralls.io", "codeclimate.com", + "sonarcloud.io", "snyk.io", + // CI/CD platforms + "travis-ci.org", "travis-ci.com", + "circleci.com", + // Package registries + "crates.io", "rubygems.org", + "pkg.go.dev", "registry.npmjs.org", + "hub.docker.com", "ghcr.io", + // Documentation and hosting + "readthedocs.io", "readthedocs.org", + "docs.aws.amazon.com", "cloud.google.com", + "learn.microsoft.com", + // Dev tools and platforms + "gitlab.com", "bitbucket.org", + "brew.sh", "docker.com", + // CDN and static content + "cdn.jsdelivr.net", "unpkg.com", + "cdnjs.cloudflare.com", + // Project-specific + "raw.githubusercontent.com", + "ancc.dev" + ] + + /// All detection rules, ordered by specificity (most specific first). + public static let rules: [(SensitiveDataType, NSRegularExpression)] = { + var result: [(SensitiveDataType, NSRegularExpression)] = [] + + // SSH Private Key - very high confidence + // Matches the header of SSH private keys + if let regex = try? NSRegularExpression( + pattern: #"-----BEGIN\s+(RSA|DSA|EC|OPENSSH)\s+PRIVATE\s+KEY-----"#, + options: [] + ) { + result.append((.sshPrivateKey, regex)) + } + + // AWS Access Key ID - high confidence + // Format: AKIA followed by 16 alphanumeric characters + if let regex = try? NSRegularExpression( + pattern: #"\b(AKIA|ABIA|ACCA|ASIA)[0-9A-Z]{16}\b"#, + options: [] + ) { + result.append((.awsKey, regex)) + } + + // AWS Secret Access Key - high confidence + // 40 character base64-ish string preceded by AWS-related keyword + // Requires context to avoid matching git SHAs, test function names, etc. + if let regex = try? NSRegularExpression( + pattern: #"(?i)(?:aws.?secret|secret.?access.?key|aws.?key)[ \t]*[=:]\s*[A-Za-z0-9/+=]{40}\b"#, + options: [] + ) { + result.append((.awsKey, regex)) + } + + // JWT Token - high confidence + // Three base64url segments separated by dots + if let regex = try? NSRegularExpression( + pattern: #"\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b"#, + options: [] + ) { + result.append((.jwtToken, regex)) + } + + // Database Connection String - high confidence + // PostgreSQL, MySQL, MongoDB connection strings + if let regex = try? NSRegularExpression( + pattern: #"(postgres|postgresql|mysql|mongodb|redis|clickhouse)://[^\s]+"#, + options: [.caseInsensitive] + ) { + result.append((.dbConnectionString, regex)) + } + + // Slack Webhook URL - high confidence + if let regex = try? NSRegularExpression( + pattern: #"https://hooks\.slack\.com/services/T[A-Z0-9]+/B[A-Z0-9]+/[A-Za-z0-9]+"#, + options: [] + ) { + result.append((.slackWebhook, regex)) + } + + // Discord Webhook URL - high confidence + if let regex = try? NSRegularExpression( + pattern: #"https://discord\.com/api/webhooks/[0-9]+/[A-Za-z0-9_-]+"#, + options: [] + ) { + result.append((.discordWebhook, regex)) + } + + // Azure Storage Connection String - high confidence + if let regex = try? NSRegularExpression( + pattern: #"DefaultEndpointsProtocol=https;AccountName=[^;]+;AccountKey=[^;]+"#, + options: [] + ) { + result.append((.azureConnectionString, regex)) + } + + // GCP Service Account JSON - high confidence + if let regex = try? NSRegularExpression( + pattern: #""type"\s*:\s*"service_account""#, + options: [] + ) { + result.append((.gcpServiceAccount, regex)) + } + + // OpenAI API Key - high confidence + // sk-proj- (project keys), sk-svcacct- (service account keys) + if let regex = try? NSRegularExpression( + pattern: #"\bsk-(?:proj|svcacct)-[A-Za-z0-9_-]{20,}\b"#, + options: [] + ) { + result.append((.openaiKey, regex)) + } + + // Anthropic API Key - high confidence + // sk-ant-api03-, sk-ant-admin01-, sk-ant-oat01- + if let regex = try? NSRegularExpression( + pattern: #"\bsk-ant-(?:api03|admin01|oat01)-[A-Za-z0-9_-]{20,}\b"#, + options: [] + ) { + result.append((.anthropicKey, regex)) + } + + // Groq API Key - high confidence + // gsk_ prefix + if let regex = try? NSRegularExpression( + pattern: #"\bgsk_[A-Za-z0-9]{20,}\b"#, + options: [] + ) { + result.append((.groqKey, regex)) + } + + // Hugging Face Token - high confidence + // hf_ prefix + if let regex = try? NSRegularExpression( + pattern: #"\bhf_[A-Za-z0-9]{20,}\b"#, + options: [] + ) { + result.append((.huggingfaceToken, regex)) + } + + // npm Token - high confidence + // npm_ prefix + if let regex = try? NSRegularExpression( + pattern: #"\bnpm_[A-Za-z0-9]{20,}\b"#, + options: [] + ) { + result.append((.npmToken, regex)) + } + + // PyPI Token - high confidence + // pypi- prefix + if let regex = try? NSRegularExpression( + pattern: #"\bpypi-[A-Za-z0-9_-]{20,}\b"#, + options: [] + ) { + result.append((.pypiToken, regex)) + } + + // RubyGems Token - high confidence + // rubygems_ prefix + if let regex = try? NSRegularExpression( + pattern: #"\brubygems_[A-Za-z0-9]{20,}\b"#, + options: [] + ) { + result.append((.rubygemsToken, regex)) + } + + // GitLab Personal Access Token - high confidence + // glpat- prefix + if let regex = try? NSRegularExpression( + pattern: #"\bglpat-[A-Za-z0-9_-]{20,}\b"#, + options: [] + ) { + result.append((.gitlabToken, regex)) + } + + // Telegram Bot Token - high confidence + // Numeric bot ID (8-10 digits) : AA followed by 33 chars + if let regex = try? NSRegularExpression( + pattern: #"\b[0-9]{8,10}:AA[A-Za-z0-9_-]{33}\b"#, + options: [] + ) { + result.append((.telegramBotToken, regex)) + } + + // SendGrid API Key - high confidence + // SG. followed by two base64 segments separated by a dot + if let regex = try? NSRegularExpression( + pattern: #"\bSG\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\b"#, + options: [] + ) { + result.append((.sendgridKey, regex)) + } + + // Shopify Token - high confidence + // shpat_, shpca_, shppa_ prefixes + if let regex = try? NSRegularExpression( + pattern: #"\bshp(?:at|ca|pa)_[A-Fa-f0-9]{20,}\b"#, + options: [] + ) { + result.append((.shopifyToken, regex)) + } + + // DigitalOcean Token - high confidence + // dop_v1_ (personal), doo_v1_ (OAuth) + if let regex = try? NSRegularExpression( + pattern: #"\bdo[op]_v1_[a-f0-9]{64}\b"#, + options: [] + ) { + result.append((.digitaloceanToken, regex)) + } + + // Workledger API Key - high confidence + // wl_sk_ prefix followed by 32+ base64url characters + if let regex = try? NSRegularExpression( + pattern: #"\bwl_sk_[A-Za-z0-9_-]{32,}\b"#, + options: [] + ) { + result.append((.workledgerKey, regex)) + } + + // Oracul API Key - high confidence + // vc__ prefix followed by 32 hex characters + if let regex = try? NSRegularExpression( + pattern: #"\bvc_(?:admin|beta|pro|enterprise)_[0-9a-f]{32}\b"#, + options: [] + ) { + result.append((.oraculKey, regex)) + } + + // Perplexity API Key - high confidence + // pplx- prefix followed by 48 alphanumeric characters + if let regex = try? NSRegularExpression( + pattern: #"\bpplx-[a-zA-Z0-9]{48}\b"#, + options: [] + ) { + result.append((.perplexityKey, regex)) + } + + // JDBC Connection URL - high confidence + // Covers Oracle (thin/oci), PostgreSQL, MySQL, DB2, SQL Server, AS/400 + if let regex = try? NSRegularExpression( + pattern: #"jdbc:[a-zA-Z0-9]+(?::[a-zA-Z0-9]+)*(?:://|:@|:@//)[^\s\"'<>]{5,}"#, + options: [] + ) { + result.append((.jdbcUrl, regex)) + } + + // XML Credential tags - high confidence + // Catches , , , etc. + if let regex = try? NSRegularExpression( + pattern: #"<(password[^>]*|secret[^>]*|token[^>]*|access_key[^>]*|secret_access_key)>([^<]+)"#, + options: [.caseInsensitive] + ) { + result.append((.xmlCredential, regex)) + } + + // XML Username tags - high confidence + // within config context, + if let regex = try? NSRegularExpression( + pattern: #"<(user|quota_key)>([^<]+)"#, + options: [.caseInsensitive] + ) { + result.append((.xmlUsername, regex)) + } + + // XML Hostname tags - high confidence + // , , + if let regex = try? NSRegularExpression( + pattern: #"<(host|hostname|interserver_http_host)>([^<]+)"#, + options: [.caseInsensitive] + ) { + result.append((.xmlHostname, regex)) + } + + // Generic API Key patterns - high confidence + // Common prefixes: sk-, pk-, api_, key_, token_ + // Placed AFTER specific providers (OpenAI sk-proj-, Anthropic sk-ant-, Groq gsk_) + if let regex = try? NSRegularExpression( + pattern: #"\b(sk|pk|api|key|token|secret|bearer)[_-][A-Za-z0-9]{20,}\b"#, + options: [.caseInsensitive] + ) { + result.append((.genericApiKey, regex)) + } + + // GitHub Token - high confidence + if let regex = try? NSRegularExpression( + pattern: #"\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b"#, + options: [] + ) { + result.append((.genericApiKey, regex)) + } + + // Stripe API Key - high confidence + if let regex = try? NSRegularExpression( + pattern: #"\b(sk|pk|rk)_(test|live)_[A-Za-z0-9]{24,}\b"#, + options: [] + ) { + result.append((.genericApiKey, regex)) + } + + // Credential key=value pairs - high confidence + // Matches password=, secret:, api_key=, etc. + // Placed after API key patterns so specific tokens match first. + // Ported from chainwatch internal/redact/scanner.go + // Excludes: boolean/trivial values (true, false, nil, etc.), + // env-lookup patterns (os.Getenv, process.env, ENV[), Go := declarations + if let regex = try? NSRegularExpression( + pattern: #"(?i)(?:password|passwd|secret|token|api_key|apikey|auth|credentials?)[ \t]*(?::=|[=:])[ \t]*(?!(?:true|false|yes|no|none|null|nil|0|1)(?:\s|$|[,;)\]}]))(?!os\.(?:Getenv|environ)|process\.env|ENV\[|ProcessInfo)\S{3,}"#, + options: [] + ) { + result.append((.credential, regex)) + } + + // File paths revealing infrastructure - high confidence + // Matches /home/..., /var/..., /etc/..., etc. + // Ported from chainwatch internal/redact/scanner.go + if let regex = try? NSRegularExpression( + pattern: #"(/(?:home|var|etc|root|usr|tmp|opt)/\S+)"#, + options: [] + ) { + result.append((.filePath, regex)) + } + + // UUID - high confidence + // Standard UUID v4 format + if let regex = try? NSRegularExpression( + pattern: #"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b"#, + options: [.caseInsensitive] + ) { + result.append((.uuid, regex)) + } + + // Credit Card - high confidence + // Visa, Mastercard, Amex, Discover patterns with optional separators + if let regex = try? NSRegularExpression( + pattern: #"\b(?:4[0-9]{3}|5[1-5][0-9]{2}|3[47][0-9]{2}|6(?:011|5[0-9]{2}))[- ]?[0-9]{4}[- ]?[0-9]{4}[- ]?[0-9]{4}\b"#, + options: [] + ) { + result.append((.creditCard, regex)) + } + + // IP Address - high confidence + // IPv4 with valid octet ranges (not 0.0.0.0 or localhost) + if let regex = try? NSRegularExpression( + pattern: #"\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b"#, + options: [] + ) { + result.append((.ipAddress, regex)) + } + + // Internal hostnames (FQDN) - with safe list filtering + // Matches fully qualified domain names + // Ported from chainwatch internal/redact/scanner.go + if let regex = try? NSRegularExpression( + pattern: #"\b[a-zA-Z0-9][-a-zA-Z0-9]*\.[-a-zA-Z0-9]+\.[a-zA-Z]{2,}\b"#, + options: [] + ) { + result.append((.hostname, regex)) + } + + // Email Address - high confidence + // Standard email format, excludes example.com + if let regex = try? NSRegularExpression( + pattern: #"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"#, + options: [] + ) { + result.append((.email, regex)) + } + + // Phone Number - conservative, high confidence + // International format: +XX XXXX XXXX XXXX (flexible spacing/separators) + // Matches: Malaysian (+60), Indian (+91), Russian (+7), UK (+44), German (+49), etc. + if let regex = try? NSRegularExpression( + pattern: #"\+[1-9][0-9]{0,2}[-.\s]?[0-9]{1,4}[-.\s]?[0-9]{2,4}[-.\s]?[0-9]{2,4}[-.\s]?[0-9]{0,4}"#, + options: [] + ) { + result.append((.phone, regex)) + } + + // US format with area code in parentheses: (XXX) XXX-XXXX + if let regex = try? NSRegularExpression( + pattern: #"\([0-9]{3}\)\s?[0-9]{3}[-.\s]?[0-9]{4}"#, + options: [] + ) { + result.append((.phone, regex)) + } + + // Compact international without spaces (common in logs/configs) + // E.164 format: +XXXXXXXXXXX (10-15 digits after +) + if let regex = try? NSRegularExpression( + pattern: #"\+[1-9][0-9]{9,14}\b"#, + options: [] + ) { + result.append((.phone, regex)) + } + + // Local formats without country code prefix + // Malaysian local: 01X-XXXXXXX or 01XXXXXXXX (10-11 digits starting with 01) + if let regex = try? NSRegularExpression( + pattern: #"\b01[0-9][-.\s]?[0-9]{3,4}[-.\s]?[0-9]{4}\b"#, + options: [] + ) { + result.append((.phone, regex)) + } + + // Malaysian compact: 01XXXXXXXX (10-11 digits, no separators) + if let regex = try? NSRegularExpression( + pattern: #"\b01[0-9]{8,9}\b"#, + options: [] + ) { + result.append((.phone, regex)) + } + + // Russian local: 8XXXXXXXXXX (11 digits starting with 8) + if let regex = try? NSRegularExpression( + pattern: #"\b8[-.\s]?[0-9]{3}[-.\s]?[0-9]{3}[-.\s]?[0-9]{2}[-.\s]?[0-9]{2}\b"#, + options: [] + ) { + result.append((.phone, regex)) + } + + // Thai international dial: 00X-XXXXXXXXX (starts with 00) + if let regex = try? NSRegularExpression( + pattern: #"\b00[0-9]{1,3}[-.\s]?[0-9]{2,4}[-.\s]?[0-9]{3,4}[-.\s]?[0-9]{3,4}\b"#, + options: [] + ) { + result.append((.phone, regex)) + } + + return result + }() + + /// Patterns to exclude from detection (reduce false positives). + /// Note: We intentionally do NOT exclude test/example domains for emails + /// because in production, all emails should be detected. + static let exclusionPatterns: [NSRegularExpression] = { + var patterns: [NSRegularExpression] = [] + + // Exclude localhost IP only (not general private ranges) + if let regex = try? NSRegularExpression( + pattern: #"^(127\.0\.0\.1|0\.0\.0\.0)$"#, + options: [] + ) { + patterns.append(regex) + } + + return patterns + }() + + /// Scan content for sensitive data. + /// Returns all matches found. + public static func scan(_ content: String, config: PastewatchConfig) -> [DetectedMatch] { + var matches: [DetectedMatch] = [] + var matchedRanges: [Range] = [] + + for (type, regex) in rules { + // Skip disabled types + guard config.isTypeEnabled(type) else { continue } + + let nsRange = NSRange(content.startIndex..., in: content) + let regexMatches = regex.matches(in: content, options: [], range: nsRange) + + for match in regexMatches { + guard let range = Range(match.range, in: content) else { continue } + + // Skip if this range overlaps with an already matched range + let overlaps = matchedRanges.contains { existingRange in + range.overlaps(existingRange) + } + if overlaps { continue } + + let value = String(content[range]) + + // Check exclusion patterns + if shouldExclude(value) { continue } + + // Additional validation per type + if !isValidMatch(value, type: type, config: config) { continue } + + let line = lineNumber(of: range.lowerBound, in: content) + matches.append(DetectedMatch(type: type, value: value, range: range, line: line)) + matchedRanges.append(range) + } + } + + // Second pass: entropy-based detection (opt-in) + if config.isTypeEnabled(.highEntropyString) { + let tokens = tokenizeForEntropy(content) + for (token, range) in tokens { + guard token.count >= minimumEntropyLength else { continue } + + let overlaps = matchedRanges.contains { $0.overlaps(range) } + if overlaps { continue } + + guard hasCharacterMix(token) else { continue } + guard !isLikelyGitSHA(token) else { continue } + guard shannonEntropy(token) >= entropyThreshold else { continue } + + let line = lineNumber(of: range.lowerBound, in: content) + matches.append(DetectedMatch(type: .highEntropyString, value: token, range: range, line: line)) + matchedRanges.append(range) + } + } + + // Third pass: 2-segment hostnames for sensitiveHosts only + // The main hostname regex requires 3+ segments (FQDN). This catches + // 2-segment hosts like nas.local or printer.lan when they match a + // sensitiveHosts entry. + if config.isTypeEnabled(.hostname), !config.sensitiveHosts.isEmpty { + scanTwoSegmentHosts(content, config: config, matches: &matches, matchedRanges: &matchedRanges) + } + + return matches + } + + /// Scan with allowlist filtering and custom rules. + public static func scan( + _ content: String, + config: PastewatchConfig, + allowlist: Allowlist = Allowlist(), + customRules: [CustomRule] = [] + ) -> [DetectedMatch] { + // Run built-in rules + var matches = scan(content, config: config) + var matchedRanges = matches.map { $0.range } + + // Run custom rules (after built-in, same overlap logic) + for rule in customRules { + let nsRange = NSRange(content.startIndex..., in: content) + let regexMatches = rule.regex.matches(in: content, options: [], range: nsRange) + + for match in regexMatches { + guard let range = Range(match.range, in: content) else { continue } + + let overlaps = matchedRanges.contains { $0.overlaps(range) } + if overlaps { continue } + + let value = String(content[range]) + let line = lineNumber(of: range.lowerBound, in: content) + matches.append(DetectedMatch( + type: .credential, + value: value, + range: range, + line: line, + customRuleName: rule.name, + customSeverity: rule.severity + )) + matchedRanges.append(range) + } + } + + // Apply allowlist filtering + if !allowlist.values.isEmpty || !allowlist.patterns.isEmpty { + matches = allowlist.filter(matches) + } + + return matches + } + + /// Check if a value should be excluded from detection. + private static func shouldExclude(_ value: String) -> Bool { + for pattern in exclusionPatterns { + let nsRange = NSRange(value.startIndex..., in: value) + if pattern.firstMatch(in: value, options: [], range: nsRange) != nil { + return true + } + } + return false + } + + /// Additional validation for specific types. + private static func isValidMatch(_ value: String, type: SensitiveDataType, config: PastewatchConfig) -> Bool { + switch type { + case .ipAddress: return isValidIP(value, config: config) + case .phone: return isValidPhone(value) + case .creditCard: return isValidLuhn(value) + case .email: return isValidEmail(value) + case .hostname: return isValidHostname(value, config: config) + case .filePath: return isValidFilePath(value) + case .uuid: return isValidUUID(value) + default: return true + } + } + + private static func isValidIP(_ value: String, config: PastewatchConfig) -> Bool { + // sensitiveIPPrefixes override all exclusions (highest precedence) + for prefix in config.sensitiveIPPrefixes where value.hasPrefix(prefix) { + return true + } + + let excluded: Set = [ + "0.0.0.0", "127.0.0.1", "255.255.255.255", + "8.8.8.8", "8.8.4.4", // Google DNS + "1.1.1.1", "1.0.0.1", // Cloudflare DNS + "9.9.9.9", // Quad9 DNS + "208.67.222.222", "208.67.220.220", // OpenDNS + "169.254.169.254", // Cloud metadata endpoint + ] + if excluded.contains(value) { return false } + + // RFC 5737 documentation ranges (192.0.2.x, 198.51.100.x, 203.0.113.x) + if value.hasPrefix("192.0.2.") || value.hasPrefix("198.51.100.") || value.hasPrefix("203.0.113.") { + return false + } + + // Multicast (224.x-239.x) and broadcast + if let first = value.split(separator: ".").first, let octet = Int(first), octet >= 224 { + return false + } + + return true + } + + private static func isValidPhone(_ value: String) -> Bool { + let digitsOnly = value.filter { $0.isNumber } + return digitsOnly.count >= 10 + } + + private static func isValidEmail(_ value: String) -> Bool { + guard value.contains("@") && value.contains(".") else { return false } + let lower = value.lowercased() + let safeEmails: Set = [ + "noreply@github.com", "no-reply@github.com", + "dependabot[bot]@users.noreply.github.com", + "actions@github.com", "github-actions[bot]@users.noreply.github.com", + "noreply@example.com", + ] + if safeEmails.contains(lower) { return false } + if lower.hasPrefix("noreply@") || lower.hasPrefix("no-reply@") { return false } + if lower.hasSuffix("@users.noreply.github.com") { return false } + return true + } + + private static func isValidHostname(_ value: String, config: PastewatchConfig) -> Bool { + let hostLower = value.lowercased() + // sensitiveHosts always flag (highest precedence, exact + suffix) + if hostMatches(hostLower, in: config.sensitiveHosts) { return true } + // Built-in safe hosts (exact only) + user safe hosts (exact + suffix) + if safeHosts.contains(hostLower) || hostMatches(hostLower, in: config.safeHosts) { return false } + if value.allSatisfy({ $0 == "." || $0.isNumber }) { return false } + return true + } + + // Regex for 2-segment hostnames (e.g., nas.local, printer.lan). + // swiftlint:disable:next force_try + private static let twoSegmentHostRegex = try! NSRegularExpression( + pattern: #"\b[a-zA-Z0-9][-a-zA-Z0-9]*\.[a-zA-Z]{2,}\b"# + ) + + /// Scan for 2-segment hostnames and flag only those matching sensitiveHosts. + private static func scanTwoSegmentHosts( + _ content: String, + config: PastewatchConfig, + matches: inout [DetectedMatch], + matchedRanges: inout [Range] + ) { + let nsRange = NSRange(content.startIndex..., in: content) + let regexMatches = twoSegmentHostRegex.matches(in: content, options: [], range: nsRange) + + for match in regexMatches { + guard let range = Range(match.range, in: content) else { continue } + let overlaps = matchedRanges.contains { $0.overlaps(range) } + if overlaps { continue } + + let value = String(content[range]) + // Only flag if it matches a sensitiveHosts entry + guard hostMatches(value.lowercased(), in: config.sensitiveHosts) else { continue } + + let line = lineNumber(of: range.lowerBound, in: content) + matches.append(DetectedMatch(type: .hostname, value: value, range: range, line: line)) + matchedRanges.append(range) + } + } + + /// Check if a hostname matches any entry in a list (exact or suffix with leading dot). + private static func hostMatches(_ host: String, in list: [String]) -> Bool { + let hostLower = host.lowercased() + for entry in list { + let entryLower = entry.lowercased() + if entryLower.hasPrefix(".") { + if hostLower.hasSuffix(entryLower) { return true } + } else { + if hostLower == entryLower { return true } + } + } + return false + } + + private static func isValidFilePath(_ value: String) -> Bool { + let components = value.split(separator: "/").filter { !$0.isEmpty } + if components.count < 3 { return false } + let safePaths: Set = [ + "/dev/null", "/dev/zero", "/dev/stdin", "/dev/stdout", "/dev/stderr", + "/dev/random", "/dev/urandom", + "/bin/sh", "/bin/bash", "/bin/zsh", + "/usr/bin/env", "/usr/bin/make", "/usr/bin/git", + "/usr/local/bin", "/usr/local/lib", + "/etc/hosts", "/etc/resolv.conf", "/etc/passwd", + "/tmp", "/var/tmp", + ] + if safePaths.contains(value) { return false } + if value.hasPrefix("/usr/bin/") || value.hasPrefix("/usr/lib/") { return false } + return true + } + + private static func isValidUUID(_ value: String) -> Bool { + let nilUUIDs: Set = [ + "00000000-0000-0000-0000-000000000000", + "ffffffff-ffff-ffff-ffff-ffffffffffff", + ] + return !nilUUIDs.contains(value.lowercased()) + } + + /// Compute 1-based line number for a string index. + static func lineNumber(of index: String.Index, in content: String) -> Int { + var line = 1 + var current = content.startIndex + while current < index { + if content[current] == "\n" { + line += 1 + } + current = content.index(after: current) + } + return line + } + + // MARK: - Entropy detection + + private static let minimumEntropyLength = 20 + private static let entropyThreshold = 4.0 + + /// Shannon entropy in bits per character. + static func shannonEntropy(_ s: String) -> Double { + guard !s.isEmpty else { return 0.0 } + var freq: [Character: Int] = [:] + for char in s { freq[char, default: 0] += 1 } + let length = Double(s.count) + var entropy = 0.0 + for count in freq.values { + let p = Double(count) / length + entropy -= p * (log(p) / log(2.0)) + } + return entropy + } + + /// Tokenize content for entropy scanning — split on delimiters. + static func tokenizeForEntropy(_ content: String) -> [(token: String, range: Range)] { + let delimiters = CharacterSet.whitespacesAndNewlines + .union(CharacterSet(charactersIn: "\"'`=:;,(){}[]<>")) + var results: [(String, Range)] = [] + var tokenStart: String.Index? + + for i in content.indices { + let char = content[i] + let isDelimiter = char.unicodeScalars.allSatisfy { delimiters.contains($0) } + + if isDelimiter { + if let start = tokenStart { + let token = String(content[start.. Bool { + var hasUpper = false + var hasLower = false + var hasDigit = false + for char in s { + if char.isUppercase { + hasUpper = true + } else if char.isLowercase { + hasLower = true + } else if char.isNumber { + hasDigit = true + } + } + let classes = [hasUpper, hasLower, hasDigit].filter { $0 }.count + return classes >= 2 + } + + /// Check if a string looks like a git SHA (40 hex chars). + private static func isLikelyGitSHA(_ s: String) -> Bool { + guard s.count == 40 else { return false } + return s.allSatisfy { $0.isHexDigit } + } + + /// Luhn algorithm for credit card validation. + private static func isValidLuhn(_ value: String) -> Bool { + let digits = value.compactMap { $0.wholeNumberValue } + guard digits.count >= 13 else { return false } + + var sum = 0 + for (index, digit) in digits.reversed().enumerated() { + if index % 2 == 1 { + let doubled = digit * 2 + sum += doubled > 9 ? doubled - 9 : doubled + } else { + sum += digit + } + } + return sum % 10 == 0 + } +} diff --git a/Sources/PastewatchCore/DirectoryScanner.swift b/Sources/PastewatchCore/DirectoryScanner.swift new file mode 100644 index 0000000..381bb93 --- /dev/null +++ b/Sources/PastewatchCore/DirectoryScanner.swift @@ -0,0 +1,250 @@ +import Foundation + +/// Result of scanning a single file. +public struct FileScanResult { + public let filePath: String + public let matches: [DetectedMatch] + public let content: String + public let gitignored: Bool + + public init(filePath: String, matches: [DetectedMatch], content: String, gitignored: Bool = false) { + self.filePath = filePath + self.matches = matches + self.content = content + self.gitignored = gitignored + } +} + +/// Recursive directory scanner for sensitive data detection. +public struct DirectoryScanner { + + /// File extensions to scan. + public static let allowedExtensions: Set = [ + "env", "yml", "yaml", "json", "toml", "conf", "xml", "tf", + "sh", "py", "go", "js", "ts", "rb", "swift", "java", + "properties", "cfg", "ini", "txt", "md", "pem", "key" + ] + + /// Directories to skip. + public static let skipDirectories: Set = [ + ".git", "node_modules", ".build", "vendor", "DerivedData", + ".swiftpm", "__pycache__", "dist", "build", ".tox" + ] + + /// Scan all files in a directory recursively. + public static func scan( + directory: String, + config: PastewatchConfig, + ignoreFile: IgnoreFile? = nil, + extraIgnorePatterns: [String] = [], + bail: Bool = false + ) throws -> [FileScanResult] { + let dirURL = URL(fileURLWithPath: directory).standardizedFileURL + let dirPath = dirURL.path + var results: [FileScanResult] = [] + + let mergedIgnore: IgnoreFile? + if let ig = ignoreFile { + if extraIgnorePatterns.isEmpty { + mergedIgnore = ig + } else { + mergedIgnore = IgnoreFile(patterns: ig.patterns + extraIgnorePatterns) + } + } else if !extraIgnorePatterns.isEmpty { + mergedIgnore = IgnoreFile(patterns: extraIgnorePatterns) + } else { + mergedIgnore = nil + } + + guard let enumerator = FileManager.default.enumerator( + at: dirURL, + includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey], + options: [] + ) else { + return results + } + + while let fileURL = enumerator.nextObject() as? URL { + let fileName = fileURL.lastPathComponent + + // Skip directories in skiplist + if skipDirectories.contains(fileName) { + enumerator.skipDescendants() + continue + } + + // Check if it's a regular file + guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey]), + resourceValues.isRegularFile == true else { + continue + } + + // Check extension (handle .env as special case -- no extension but starts with dot) + let ext = fileURL.pathExtension.lowercased() + let isEnvFile = fileName == ".env" || fileName.hasSuffix(".env") + + guard isEnvFile || allowedExtensions.contains(ext) else { + continue + } + + // Compute relative path from the directory root + let filePath = fileURL.standardizedFileURL.path + let relativePath = filePath.hasPrefix(dirPath + "/") + ? String(filePath.dropFirst(dirPath.count + 1)) + : fileURL.lastPathComponent + + // Skip files matching ignore patterns + if let ignore = mergedIgnore, ignore.shouldIgnore(relativePath) { + continue + } + + // Skip binary files (check first 8192 bytes for null bytes) + guard !isBinaryFile(at: fileURL) else { + continue + } + + // Read and scan + guard let content = try? String(contentsOf: fileURL, encoding: .utf8), + !content.isEmpty else { + continue + } + + // Format-aware scanning + let parsedExt = isEnvFile ? "env" : fileURL.pathExtension.lowercased() + var fileMatches = scanFileContent( + content: content, ext: parsedExt, + relativePath: relativePath, config: config + ) + + fileMatches = Allowlist.filterInlineAllow(matches: fileMatches, content: content) + + if !fileMatches.isEmpty { + results.append(FileScanResult( + filePath: relativePath, + matches: fileMatches, + content: content + )) + if bail { return results } + } + } + + let sorted = results.sorted { $0.filePath < $1.filePath } + + // Tag gitignored files + let ignoredSet = gitIgnoredFiles(in: directory, paths: sorted.map { $0.filePath }) + if ignoredSet.isEmpty { + return sorted + } + return sorted.map { result in + if ignoredSet.contains(result.filePath) { + return FileScanResult( + filePath: result.filePath, + matches: result.matches, + content: result.content, + gitignored: true + ) + } + return result + } + } + + /// Check which paths are gitignored using `git check-ignore`. + /// Returns empty set if not in a git repo or git is not available. + public static func gitIgnoredFiles(in directory: String, paths: [String]) -> Set { + guard !paths.isEmpty else { return [] } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = ["-C", directory, "check-ignore", "--stdin"] + process.currentDirectoryURL = URL(fileURLWithPath: directory) + + let inputPipe = Pipe() + let outputPipe = Pipe() + process.standardInput = inputPipe + process.standardOutput = outputPipe + process.standardError = FileHandle.nullDevice + + do { + try process.run() + } catch { + return [] + } + + let input = paths.joined(separator: "\n") + "\n" + inputPipe.fileHandleForWriting.write(Data(input.utf8)) + inputPipe.fileHandleForWriting.closeFile() + + process.waitUntilExit() + + let data = outputPipe.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8) else { return [] } + + return Set( + output.components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + ) + } + + /// Scan file content using format-aware parsing when available. + public static func scanFileContent( + content: String, ext: String, + relativePath: String, config: PastewatchConfig + ) -> [DetectedMatch] { + guard let parser = parserForExtension(ext, config: config) else { + return DetectionRules.scan(content, config: config).map { match in + DetectedMatch( + type: match.type, value: match.value, range: match.range, + line: match.line, filePath: relativePath, + customRuleName: match.customRuleName, customSeverity: match.customSeverity + ) + } + } + + // Format-aware: extract values and scan each + var matches: [DetectedMatch] = [] + for pv in parser.parseValues(from: content) { + for vm in DetectionRules.scan(pv.value, config: config) { + matches.append(DetectedMatch( + type: vm.type, value: vm.value, range: vm.range, + line: pv.line, filePath: relativePath, + customRuleName: vm.customRuleName, customSeverity: vm.customSeverity + )) + } + } + + // XML files: also run raw detection for XML-specific tag patterns + // (e.g., plain where the extracted value alone + // wouldn't match any pattern rule) + if ext.lowercased() == "xml" { + let rawMatches = DetectionRules.scan(content, config: config) + for rm in rawMatches { + // Only add XML-specific types not already found + guard rm.type == .xmlCredential || rm.type == .xmlUsername || rm.type == .xmlHostname else { + continue + } + let alreadyFound = matches.contains { $0.line == rm.line && $0.type == rm.type } + if !alreadyFound { + matches.append(DetectedMatch( + type: rm.type, value: rm.value, range: rm.range, + line: rm.line, filePath: relativePath, + customRuleName: rm.customRuleName, customSeverity: rm.customSeverity + )) + } + } + } + + return matches + } + + /// Check if a file appears to be binary by looking for null bytes. + private static func isBinaryFile(at url: URL) -> Bool { + guard let handle = try? FileHandle(forReadingFrom: url) else { + return true + } + defer { handle.closeFile() } + + let data = handle.readData(ofLength: 8192) + return data.contains(0) + } +} diff --git a/Sources/PastewatchCore/EnvParser.swift b/Sources/PastewatchCore/EnvParser.swift new file mode 100644 index 0000000..b6dcdfd --- /dev/null +++ b/Sources/PastewatchCore/EnvParser.swift @@ -0,0 +1,36 @@ +import Foundation + +/// Parser for .env files (KEY=VALUE format). +public struct EnvParser: FormatParser { + public init() {} + + public func parseValues(from content: String) -> [ParsedValue] { + var results: [ParsedValue] = [] + let lines = content.components(separatedBy: .newlines) + + for (index, line) in lines.enumerated() { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + // Skip empty lines and comments + if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } + + // Find KEY=VALUE separator + guard let eqIndex = trimmed.firstIndex(of: "=") else { continue } + + let key = String(trimmed[trimmed.startIndex.. [String: Date] { + var result: [String: Date] = [:] + let fm = FileManager.default + let dirURL = URL(fileURLWithPath: directory) + + guard let enumerator = fm.enumerator( + at: dirURL, + includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], + options: [] + ) else { return result } + + while let url = enumerator.nextObject() as? URL { + let name = url.lastPathComponent + if DirectoryScanner.skipDirectories.contains(name) { + enumerator.skipDescendants() + continue + } + guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey]), + values.isRegularFile == true, + let modDate = values.contentModificationDate else { continue } + + let ext = url.pathExtension.lowercased() + let isEnvFile = name == ".env" || name.hasSuffix(".env") + guard isEnvFile || DirectoryScanner.allowedExtensions.contains(ext) else { continue } + + let path = url.standardizedFileURL.path + let rel = path.hasPrefix(directory + "/") + ? String(path.dropFirst(directory.count + 1)) + : name + result[rel] = modDate + } + return result + } + + private func checkForChanges() { + let current = snapshotModDates() + var changed: [String] = [] + + for (path, modDate) in current { + if let prev = knownModDates[path] { + if modDate > prev { changed.append(path) } + } else { + changed.append(path) // new file + } + } + + knownModDates = current + + for relativePath in changed { + scanFile(relativePath: relativePath) + } + } + + private func scanFile(relativePath: String) { + let fullPath = (directory as NSString).appendingPathComponent(relativePath) + guard let content = try? String(contentsOfFile: fullPath, encoding: .utf8), + !content.isEmpty else { return } + + let ext = (relativePath as NSString).pathExtension.lowercased() + let name = (relativePath as NSString).lastPathComponent + let parsedExt = (name == ".env" || name.hasSuffix(".env")) ? "env" : ext + + var matches = DirectoryScanner.scanFileContent( + content: content, ext: parsedExt, + relativePath: relativePath, config: config + ) + matches = Allowlist.filterInlineAllow(matches: matches, content: content) + + // Apply severity filter + if let threshold = severity { + matches = matches.filter { $0.effectiveSeverity >= threshold } + } + + guard !matches.isEmpty else { return } + + let timestamp = ISO8601DateFormatter().string(from: Date()) + + if jsonOutput { + outputJSON(relativePath: relativePath, matches: matches, timestamp: timestamp) + } else { + outputText(relativePath: relativePath, matches: matches, timestamp: timestamp) + } + } + + private func outputText(relativePath: String, matches: [DetectedMatch], timestamp: String) { + for match in matches { + let severity = match.effectiveSeverity.rawValue.uppercased() + let line = "[\(timestamp)] \(severity) \(relativePath):\(match.line) \(match.displayName): \(match.value)" + FileHandle.standardError.write(Data((line + "\n").utf8)) + } + } + + private func outputJSON(relativePath: String, matches: [DetectedMatch], timestamp: String) { + for match in matches { + let obj: [String: Any] = [ + "timestamp": timestamp, + "file": relativePath, + "line": match.line, + "type": match.displayName, + "value": match.value, + "severity": match.effectiveSeverity.rawValue + ] + if let data = try? JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys]), + let str = String(data: data, encoding: .utf8) { + print(str) + } + } + } +} diff --git a/Sources/PastewatchCore/FormatParser.swift b/Sources/PastewatchCore/FormatParser.swift new file mode 100644 index 0000000..723dcbc --- /dev/null +++ b/Sources/PastewatchCore/FormatParser.swift @@ -0,0 +1,42 @@ +import Foundation + +/// A parsed value from a structured file. +public struct ParsedValue { + public let value: String + public let line: Int + public let key: String? + + public init(value: String, line: Int, key: String? = nil) { + self.value = value + self.line = line + self.key = key + } +} + +/// Protocol for format-specific file parsers. +public protocol FormatParser { + func parseValues(from content: String) -> [ParsedValue] +} + +/// Select appropriate parser for a file extension. +public func parserForExtension(_ ext: String, config: PastewatchConfig? = nil) -> FormatParser? { + switch ext.lowercased() { + case "env": + return EnvParser() + case "json": + return JSONValueParser() + case "yml", "yaml": + return YAMLValueParser() + case "properties", "cfg", "ini": + return PropertiesParser() + case "xml": + let customTags = config?.xmlSensitiveTags ?? [] + if customTags.isEmpty { + return XMLValueParser() + } + let merged = XMLValueParser.defaultSensitiveTags.union(Set(customTags)) + return XMLValueParser(sensitiveTags: merged) + default: + return nil + } +} diff --git a/Sources/PastewatchCore/GitDiffScanner.swift b/Sources/PastewatchCore/GitDiffScanner.swift new file mode 100644 index 0000000..c9e8d21 --- /dev/null +++ b/Sources/PastewatchCore/GitDiffScanner.swift @@ -0,0 +1,226 @@ +import Foundation + +/// Scans git diff output for sensitive data, reporting only findings on added lines. +public struct GitDiffScanner { + + /// Parsed representation of one file in a unified diff. + struct DiffFile { + let path: String + let addedLines: Set + } + + /// Mutable state used during diff parsing. + private struct DiffParserState { + var files: [DiffFile] = [] + var currentPath: String? + var currentAdded = Set() + var newLineNumber = 0 + + mutating func flushCurrentFile() { + if let path = currentPath, !currentAdded.isEmpty { + files.append(DiffFile(path: path, addedLines: currentAdded)) + } + } + } + + /// Scan staged and/or unstaged git changes for secrets. + public static func scan( + staged: Bool = true, + unstaged: Bool = false, + config: PastewatchConfig, + bail: Bool = false + ) throws -> [FileScanResult] { + var diffFiles: [DiffFile] = [] + + if staged { + let diff = try runGit(["diff", "--cached", "--no-color", "--diff-filter=d"]) + diffFiles.append(contentsOf: parseDiff(diff)) + } + + if unstaged { + let diff = try runGit(["diff", "--no-color", "--diff-filter=d"]) + let unstagedFiles = parseDiff(diff) + // Merge unstaged into existing: union addedLines for same path + for uf in unstagedFiles { + if let idx = diffFiles.firstIndex(where: { $0.path == uf.path }) { + let merged = DiffFile( + path: uf.path, + addedLines: diffFiles[idx].addedLines.union(uf.addedLines) + ) + diffFiles[idx] = merged + } else { + diffFiles.append(uf) + } + } + } + + guard !diffFiles.isEmpty else { return [] } + + var results: [FileScanResult] = [] + + for df in diffFiles { + // Check extension filter (same as DirectoryScanner) + let url = URL(fileURLWithPath: df.path) + let fileName = url.lastPathComponent + let ext = url.pathExtension.lowercased() + let isEnvFile = fileName == ".env" || fileName.hasSuffix(".env") + + guard isEnvFile || DirectoryScanner.allowedExtensions.contains(ext) else { + continue + } + + // Get file content + let content: String + if staged && !unstaged { + // Staged only: get from git index + guard let staged = try? runGit(["show", ":\(df.path)"]) else { continue } + content = staged + } else { + // Unstaged or both: read from disk + guard let disk = try? String(contentsOfFile: df.path, encoding: .utf8) else { + continue + } + content = disk + } + + guard !content.isEmpty else { continue } + + let parsedExt = isEnvFile ? "env" : ext + var fileMatches = DirectoryScanner.scanFileContent( + content: content, ext: parsedExt, + relativePath: df.path, config: config + ) + + fileMatches = Allowlist.filterInlineAllow(matches: fileMatches, content: content) + + // Filter to only added lines + fileMatches = fileMatches.filter { df.addedLines.contains($0.line) } + + if !fileMatches.isEmpty { + results.append(FileScanResult( + filePath: df.path, + matches: fileMatches, + content: content + )) + if bail { return results } + } + } + + return results.sorted { $0.filePath < $1.filePath } + } + + // MARK: - Diff parsing + + /// Parse unified diff output into per-file entries with added line numbers. + static func parseDiff(_ diff: String) -> [DiffFile] { + guard !diff.isEmpty else { return [] } + + var state = DiffParserState() + let lines = diff.components(separatedBy: "\n") + + for line in lines { + parseDiffLine(line, state: &state) + } + + // Save last file + state.flushCurrentFile() + return state.files + } + + private static func parseDiffLine(_ line: String, state: inout DiffParserState) { + if line.hasPrefix("diff --git ") { + state.flushCurrentFile() + state.currentPath = nil + state.currentAdded = Set() + state.newLineNumber = 0 + return + } + + if line.hasPrefix("Binary files ") { + state.currentPath = nil + return + } + + if line.hasPrefix("+++ ") { + state.currentPath = extractPath(from: line) + return + } + + if line.hasPrefix("--- ") { return } + + if line.hasPrefix("@@ ") { + if let newStart = parseHunkHeader(line) { + state.newLineNumber = newStart + } + return + } + + guard state.currentPath != nil else { return } + + if line.hasPrefix("+") { + state.currentAdded.insert(state.newLineNumber) + state.newLineNumber += 1 + } else if line.hasPrefix("-") { + // Removed line: don't increment new-file counter + } else if line.hasPrefix(" ") || line.isEmpty { + state.newLineNumber += 1 + } + } + + private static func extractPath(from line: String) -> String? { + let pathPart = String(line.dropFirst(4)) + if pathPart == "/dev/null" { return nil } + if pathPart.hasPrefix("b/") { return String(pathPart.dropFirst(2)) } + return pathPart + } + + /// Extract the new-file start line from a hunk header like `@@ -1,3 +4,5 @@`. + private static func parseHunkHeader(_ line: String) -> Int? { + // Match +start or +start,count + guard let plusRange = line.range(of: "+", range: line.index(line.startIndex, offsetBy: 3).. String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = arguments + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + throw GitDiffError.gitCommandFailed(arguments.joined(separator: " ")) + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) ?? "" + } +} + +/// Errors from git diff scanning. +public enum GitDiffError: Error, CustomStringConvertible { + case gitCommandFailed(String) + case notAGitRepository + + public var description: String { + switch self { + case .gitCommandFailed(let cmd): + return "git command failed: git \(cmd)" + case .notAGitRepository: + return "not a git repository" + } + } +} diff --git a/Sources/PastewatchCore/GitHistoryScanner.swift b/Sources/PastewatchCore/GitHistoryScanner.swift new file mode 100644 index 0000000..b50eb66 --- /dev/null +++ b/Sources/PastewatchCore/GitHistoryScanner.swift @@ -0,0 +1,237 @@ +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif +import Foundation + +/// Result of scanning a single commit. +public struct CommitFinding { + public let commitHash: String + public let author: String + public let date: String + public let filePath: String + public let matches: [DetectedMatch] + + public init(commitHash: String, author: String, date: String, + filePath: String, matches: [DetectedMatch]) { + self.commitHash = commitHash + self.author = author + self.date = date + self.filePath = filePath + self.matches = matches + } +} + +/// Aggregate result from git history scanning. +public struct GitLogScanResult { + public let findings: [CommitFinding] + public let commitsScanned: Int + public let filesScanned: Int + + public init(findings: [CommitFinding], commitsScanned: Int, filesScanned: Int) { + self.findings = findings + self.commitsScanned = commitsScanned + self.filesScanned = filesScanned + } +} + +/// Parsed metadata for a single commit chunk. +struct CommitChunk { + let hash: String + let author: String + let date: String + let diffContent: String +} + +/// Scans git commit history for secrets, reporting only the first introduction of each finding. +public struct GitHistoryScanner { + + /// Marker prefix used in git log --format to delimit commits. + static let commitMarker = "PWCOMMIT " + + /// Scan git history for secrets. + /// + /// - Parameters: + /// - range: Git revision range (e.g., "HEAD~50..HEAD"). Nil = all history. + /// - since: Only commits after this date (ISO format). + /// - branch: Specific branch to scan. Nil with nil range = --all. + /// - config: Pastewatch configuration. + /// - bail: Stop at first finding. + /// - Returns: Scan result with findings, commit count, and file count. + public static func scan( + range: String? = nil, + since: String? = nil, + branch: String? = nil, + config: PastewatchConfig, + bail: Bool = false + ) throws -> GitLogScanResult { + let output = try runGitLog(range: range, since: since, branch: branch) + let chunks = parseCommitChunks(output) + + var findings: [CommitFinding] = [] + var seenFingerprints = Set() + var filesScanned = 0 + + for chunk in chunks { + let diffFiles = GitDiffScanner.parseDiff(chunk.diffContent) + + for df in diffFiles { + guard shouldScanFile(df.path) else { continue } + filesScanned += 1 + + guard let content = try? GitDiffScanner.runGit( + ["show", "\(chunk.hash):\(df.path)"] + ), !content.isEmpty else { continue } + + let ext = scanExtension(for: df.path) + var fileMatches = DirectoryScanner.scanFileContent( + content: content, ext: ext, + relativePath: df.path, config: config + ) + fileMatches = Allowlist.filterInlineAllow( + matches: fileMatches, content: content + ) + + // Filter to only added lines + fileMatches = fileMatches.filter { df.addedLines.contains($0.line) } + + // Dedup: skip findings already seen in earlier commits + var newMatches: [DetectedMatch] = [] + for match in fileMatches { + let fp = fingerprint(match) + if !seenFingerprints.contains(fp) { + seenFingerprints.insert(fp) + newMatches.append(match) + } + } + + if !newMatches.isEmpty { + findings.append(CommitFinding( + commitHash: chunk.hash, + author: chunk.author, + date: chunk.date, + filePath: df.path, + matches: newMatches + )) + if bail { return GitLogScanResult( + findings: findings, + commitsScanned: chunks.count, + filesScanned: filesScanned + )} + } + } + } + + return GitLogScanResult( + findings: findings, + commitsScanned: chunks.count, + filesScanned: filesScanned + ) + } + + // MARK: - Git log command + + static func runGitLog( + range: String?, + since: String?, + branch: String? + ) throws -> String { + var args = [ + "log", "--reverse", "-p", "--no-color", + "--diff-filter=d", + "--format=\(commitMarker)%H %ae %aI", + ] + if let since = since { + args.append("--since=\(since)") + } + if let range = range { + args.append(range) + } else if let branch = branch { + args.append(branch) + } else { + args.append("--all") + } + return try GitDiffScanner.runGit(args) + } + + // MARK: - Parsing + + /// Split git log output into per-commit chunks. + static func parseCommitChunks(_ output: String) -> [CommitChunk] { + guard !output.isEmpty else { return [] } + + var chunks: [CommitChunk] = [] + let lines = output.components(separatedBy: "\n") + var currentChunk: CommitChunk? + var currentDiffLines: [String] = [] + + for line in lines { + if line.hasPrefix(commitMarker) { + // Flush previous chunk + if let chunk = currentChunk { + chunks.append(CommitChunk( + hash: chunk.hash, author: chunk.author, + date: chunk.date, + diffContent: currentDiffLines.joined(separator: "\n") + )) + } + // Parse new commit metadata: "PWCOMMIT " + let parts = String(line.dropFirst(commitMarker.count)) + .split(separator: " ", maxSplits: 2) + .map { String($0) } + if parts.count >= 3 { + currentChunk = CommitChunk( + hash: parts[0], author: parts[1], + date: parts[2], diffContent: "" + ) + } else { + currentChunk = nil + } + currentDiffLines = [] + } else { + currentDiffLines.append(line) + } + } + + // Flush last chunk + if let chunk = currentChunk { + chunks.append(CommitChunk( + hash: chunk.hash, author: chunk.author, + date: chunk.date, + diffContent: currentDiffLines.joined(separator: "\n") + )) + } + + return chunks + } + + // MARK: - File filtering + + /// Check if a file path should be scanned (by extension). + private static func shouldScanFile(_ path: String) -> Bool { + let url = URL(fileURLWithPath: path) + let fileName = url.lastPathComponent + if fileName == ".env" || fileName.hasSuffix(".env") { return true } + return DirectoryScanner.allowedExtensions.contains( + url.pathExtension.lowercased() + ) + } + + /// Get the effective extension for scanning (handles .env files). + private static func scanExtension(for path: String) -> String { + let url = URL(fileURLWithPath: path) + let fileName = url.lastPathComponent + if fileName == ".env" || fileName.hasSuffix(".env") { return "env" } + return url.pathExtension.lowercased() + } + + // MARK: - Dedup + + /// Compute a fingerprint for deduplication: SHA256(type + ":" + value). + private static func fingerprint(_ match: DetectedMatch) -> String { + let input = match.type.rawValue + ":" + match.value + let digest = SHA256.hash(data: Data(input.utf8)) + return digest.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Sources/PastewatchCore/IgnoreFile.swift b/Sources/PastewatchCore/IgnoreFile.swift new file mode 100644 index 0000000..105d054 --- /dev/null +++ b/Sources/PastewatchCore/IgnoreFile.swift @@ -0,0 +1,75 @@ +import Foundation + +public struct IgnoreFile { + public let patterns: [String] + + public init(patterns: [String]) { + self.patterns = patterns + } + + public static func load(from directory: String) -> IgnoreFile? { + let path = (directory as NSString).appendingPathComponent(".pastewatchignore") + guard FileManager.default.fileExists(atPath: path), + let content = try? String(contentsOfFile: path, encoding: .utf8) else { + return nil + } + let patterns = content + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty && !$0.hasPrefix("#") } + return IgnoreFile(patterns: patterns) + } + + public func shouldIgnore(_ relativePath: String) -> Bool { + patterns.contains { matchesPattern(relativePath, pattern: $0) } + } + + private func matchesPattern(_ path: String, pattern: String) -> Bool { + // Directory pattern (ends with /) + if pattern.hasSuffix("/") { + let dirName = String(pattern.dropLast()) + let components = path.split(separator: "/").map(String.init) + return components.contains(dirName) + } + + // Pattern with path separator — match against full relative path + if pattern.contains("/") { + return globMatch(path, pattern: pattern) + } + + // Simple filename pattern — match against last component and full path + let filename = URL(fileURLWithPath: path).lastPathComponent + return globMatch(filename, pattern: pattern) || globMatch(path, pattern: pattern) + } + + /// Simple glob matching: * matches any sequence, ? matches single character. + private func globMatch(_ string: String, pattern: String) -> Bool { + var si = string.startIndex + var pi = pattern.startIndex + var starSi = string.endIndex + var starPi = pattern.endIndex + + while si < string.endIndex { + if pi < pattern.endIndex && (pattern[pi] == "?" || pattern[pi] == string[si]) { + si = string.index(after: si) + pi = pattern.index(after: pi) + } else if pi < pattern.endIndex && pattern[pi] == "*" { + starPi = pi + starSi = si + pi = pattern.index(after: pi) + } else if starPi < pattern.endIndex { + pi = pattern.index(after: starPi) + starSi = string.index(after: starSi) + si = starSi + } else { + return false + } + } + + while pi < pattern.endIndex && pattern[pi] == "*" { + pi = pattern.index(after: pi) + } + + return pi == pattern.endIndex + } +} diff --git a/Sources/PastewatchCore/InventoryReport.swift b/Sources/PastewatchCore/InventoryReport.swift new file mode 100644 index 0000000..cbf3b4d --- /dev/null +++ b/Sources/PastewatchCore/InventoryReport.swift @@ -0,0 +1,404 @@ +import Foundation + +// MARK: - Data structures + +public struct InventoryEntry: Codable, Equatable { + public let filePath: String + public let type: String + public let severity: String + public let count: Int + public let lines: [Int] + + public init(filePath: String, type: String, severity: String, count: Int, lines: [Int]) { + self.filePath = filePath + self.type = type + self.severity = severity + self.count = count + self.lines = lines + } +} + +public struct SeverityBreakdown: Codable { + public let critical: Int + public let high: Int + public let medium: Int + public let low: Int + + public init(critical: Int, high: Int, medium: Int, low: Int) { + self.critical = critical + self.high = high + self.medium = medium + self.low = low + } +} + +public struct HotSpot: Codable { + public let filePath: String + public let findingCount: Int + public let types: [String] + + public init(filePath: String, findingCount: Int, types: [String]) { + self.filePath = filePath + self.findingCount = findingCount + self.types = types + } +} + +public struct TypeGroup: Codable { + public let type: String + public let severity: String + public let count: Int + public let files: [String] + + public init(type: String, severity: String, count: Int, files: [String]) { + self.type = type + self.severity = severity + self.count = count + self.files = files + } +} + +public struct InventoryDelta: Codable { + public let added: [InventoryEntry] + public let removed: [InventoryEntry] + public let totalBefore: Int + public let totalAfter: Int + public let summary: String + + public init(added: [InventoryEntry], removed: [InventoryEntry], + totalBefore: Int, totalAfter: Int, summary: String) { + self.added = added + self.removed = removed + self.totalBefore = totalBefore + self.totalAfter = totalAfter + self.summary = summary + } +} + +public struct InventoryReport: Codable { + public let version: String + public let generatedAt: String + public let directory: String + public let totalFindings: Int + public let filesAffected: Int + public let severityBreakdown: SeverityBreakdown + public let entries: [InventoryEntry] + public let hotSpots: [HotSpot] + public let typeGroups: [TypeGroup] + + public init(version: String, generatedAt: String, directory: String, + totalFindings: Int, filesAffected: Int, + severityBreakdown: SeverityBreakdown, + entries: [InventoryEntry], hotSpots: [HotSpot], + typeGroups: [TypeGroup]) { + self.version = version + self.generatedAt = generatedAt + self.directory = directory + self.totalFindings = totalFindings + self.filesAffected = filesAffected + self.severityBreakdown = severityBreakdown + self.entries = entries + self.hotSpots = hotSpots + self.typeGroups = typeGroups + } +} + +private struct EntryAccumulator { + let type: String + let severity: String + var lines: [Int] +} + +// MARK: - Build + +public extension InventoryReport { + + static func build(from results: [FileScanResult], directory: String) -> InventoryReport { + let allMatches = results.flatMap { fr in + fr.matches.map { (fr.filePath, $0) } + } + + // Entries: group by (filePath, type) + var entryMap: [String: EntryAccumulator] = [:] + for (path, match) in allMatches { + let key = "\(path)|\(match.displayName)" + if var existing = entryMap[key] { + existing.lines.append(match.line) + entryMap[key] = existing + } else { + entryMap[key] = EntryAccumulator( + type: match.displayName, + severity: match.effectiveSeverity.rawValue, + lines: [match.line] + ) + } + } + + var entries: [InventoryEntry] = [] + for (key, value) in entryMap { + let path = String(key.prefix(while: { $0 != "|" })) + entries.append(InventoryEntry( + filePath: path, type: value.type, + severity: value.severity, count: value.lines.count, + lines: value.lines.sorted() + )) + } + entries.sort { $0.filePath < $1.filePath || ($0.filePath == $1.filePath && $0.type < $1.type) } + + // Severity breakdown + var crit = 0, high = 0, med = 0, low = 0 + for (_, match) in allMatches { + switch match.effectiveSeverity { + case .critical: crit += 1 + case .high: high += 1 + case .medium: med += 1 + case .low: low += 1 + } + } + + // Hot spots: files sorted by match count + let byFile = Dictionary(grouping: allMatches, by: { $0.0 }) + var hotSpots = byFile.map { (path, matches) in + HotSpot( + filePath: path, + findingCount: matches.count, + types: Array(Set(matches.map { $0.1.displayName })).sorted() + ) + } + hotSpots.sort { $0.findingCount > $1.findingCount } + if hotSpots.count > 10 { hotSpots = Array(hotSpots.prefix(10)) } + + // Type groups + let byType = Dictionary(grouping: allMatches, by: { $0.1.displayName }) + var typeGroups = byType.map { (type, matches) in + TypeGroup( + type: type, + severity: matches.first?.1.effectiveSeverity.rawValue ?? "low", + count: matches.count, + files: Array(Set(matches.map { $0.0 })).sorted() + ) + } + typeGroups.sort { $0.count > $1.count } + + let formatter = ISO8601DateFormatter() + let timestamp = formatter.string(from: Date()) + + return InventoryReport( + version: "1", + generatedAt: timestamp, + directory: directory, + totalFindings: allMatches.count, + filesAffected: byFile.count, + severityBreakdown: SeverityBreakdown(critical: crit, high: high, medium: med, low: low), + entries: entries, + hotSpots: hotSpots, + typeGroups: typeGroups + ) + } + + static func load(from path: String) throws -> InventoryReport { + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + return try JSONDecoder().decode(InventoryReport.self, from: data) + } +} + +// MARK: - Compare + +public extension InventoryReport { + + static func compare(current: InventoryReport, previous: InventoryReport) -> InventoryDelta { + let currentKeys = Set(current.entries.map { "\($0.filePath)|\($0.type)" }) + let previousKeys = Set(previous.entries.map { "\($0.filePath)|\($0.type)" }) + + let addedKeys = currentKeys.subtracting(previousKeys) + let removedKeys = previousKeys.subtracting(currentKeys) + + let added = current.entries.filter { addedKeys.contains("\($0.filePath)|\($0.type)") } + let removed = previous.entries.filter { removedKeys.contains("\($0.filePath)|\($0.type)") } + + let delta = current.totalFindings - previous.totalFindings + let sign = delta >= 0 ? "+" : "" + let summary = "\(sign)\(addedKeys.count) added, -\(removedKeys.count) removed (\(current.totalFindings) total, was \(previous.totalFindings))" + + return InventoryDelta( + added: added.sorted { $0.filePath < $1.filePath }, + removed: removed.sorted { $0.filePath < $1.filePath }, + totalBefore: previous.totalFindings, + totalAfter: current.totalFindings, + summary: summary + ) + } +} + +// MARK: - Formatters + +public enum InventoryFormatter { + + public static func formatText(_ report: InventoryReport) -> String { + var lines: [String] = [] + lines.append("Secret Inventory Report") + lines.append("=======================") + lines.append("Directory: \(report.directory)") + lines.append("Generated: \(report.generatedAt)") + lines.append("") + lines.append("Total findings: \(report.totalFindings)") + lines.append("Files affected: \(report.filesAffected)") + lines.append("") + lines.append("Severity breakdown:") + lines.append(" critical: \(report.severityBreakdown.critical)") + lines.append(" high: \(report.severityBreakdown.high)") + lines.append(" medium: \(report.severityBreakdown.medium)") + lines.append(" low: \(report.severityBreakdown.low)") + + if !report.hotSpots.isEmpty { + lines.append("") + lines.append("Hot spots:") + for hs in report.hotSpots { + let types = hs.types.joined(separator: ", ") + lines.append(" \(hs.filePath) \(hs.findingCount) findings (\(types))") + } + } + + if !report.typeGroups.isEmpty { + lines.append("") + lines.append("Findings by type:") + for tg in report.typeGroups { + lines.append(" \(tg.type) (\(tg.severity)) \(tg.count) in \(tg.files.count) file(s)") + } + } + + return lines.joined(separator: "\n") + "\n" + } + + public static func formatJSON(_ report: InventoryReport) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + guard let data = try? encoder.encode(report), + let str = String(data: data, encoding: .utf8) else { + return "{}" + } + return str + } + + public static func formatMarkdown(_ report: InventoryReport) -> String { + var lines: [String] = [] + lines.append("## Secret Inventory Report") + lines.append("") + lines.append("**Directory:** `\(report.directory)`") + lines.append("**Generated:** \(report.generatedAt)") + lines.append("**Total findings:** \(report.totalFindings) | **Files affected:** \(report.filesAffected)") + lines.append("") + lines.append("### Severity Breakdown") + lines.append("") + lines.append("| Severity | Count |") + lines.append("|----------|-------|") + lines.append("| critical | \(report.severityBreakdown.critical) |") + lines.append("| high | \(report.severityBreakdown.high) |") + lines.append("| medium | \(report.severityBreakdown.medium) |") + lines.append("| low | \(report.severityBreakdown.low) |") + + if !report.hotSpots.isEmpty { + lines.append("") + lines.append("### Hot Spots") + lines.append("") + lines.append("| File | Findings | Types |") + lines.append("|------|----------|-------|") + for hs in report.hotSpots { + lines.append("| \(hs.filePath) | \(hs.findingCount) | \(hs.types.joined(separator: ", ")) |") + } + } + + if !report.typeGroups.isEmpty { + lines.append("") + lines.append("### Findings by Type") + lines.append("") + lines.append("| Type | Severity | Count | Files |") + lines.append("|------|----------|-------|-------|") + for tg in report.typeGroups { + lines.append("| \(tg.type) | \(tg.severity) | \(tg.count) | \(tg.files.joined(separator: ", ")) |") + } + } + + if !report.entries.isEmpty { + lines.append("") + lines.append("### All Findings") + lines.append("") + lines.append("| File | Type | Severity | Count | Lines |") + lines.append("|------|------|----------|-------|-------|") + for entry in report.entries { + let lineStr = entry.lines.map(String.init).joined(separator: ", ") + lines.append("| \(entry.filePath) | \(entry.type) | \(entry.severity) | \(entry.count) | \(lineStr) |") + } + } + + return lines.joined(separator: "\n") + "\n" + } + + public static func formatCSV(_ report: InventoryReport) -> String { + var lines: [String] = [] + lines.append("file,type,severity,count,lines") + for entry in report.entries { + let lineStr = entry.lines.map(String.init).joined(separator: ";") + lines.append("\(entry.filePath),\(entry.type),\(entry.severity),\(entry.count),\"\(lineStr)\"") + } + return lines.joined(separator: "\n") + "\n" + } + + public static func formatDeltaText(_ delta: InventoryDelta) -> String { + var lines: [String] = [] + lines.append("") + lines.append("Changes") + lines.append("-------") + lines.append(delta.summary) + + if !delta.added.isEmpty { + lines.append("") + lines.append("New findings:") + for entry in delta.added { + lines.append(" + \(entry.filePath): \(entry.type) (\(entry.count))") + } + } + + if !delta.removed.isEmpty { + lines.append("") + lines.append("Resolved findings:") + for entry in delta.removed { + lines.append(" - \(entry.filePath): \(entry.type) (\(entry.count))") + } + } + + return lines.joined(separator: "\n") + "\n" + } + + public static func formatDeltaMarkdown(_ delta: InventoryDelta) -> String { + var lines: [String] = [] + lines.append("") + lines.append("### Changes") + lines.append("") + lines.append(delta.summary) + + if !delta.added.isEmpty { + lines.append("") + lines.append("**New findings:**") + lines.append("") + lines.append("| File | Type | Count |") + lines.append("|------|------|-------|") + for entry in delta.added { + lines.append("| \(entry.filePath) | \(entry.type) | \(entry.count) |") + } + } + + if !delta.removed.isEmpty { + lines.append("") + lines.append("**Resolved findings:**") + lines.append("") + lines.append("| File | Type | Count |") + lines.append("|------|------|-------|") + for entry in delta.removed { + lines.append("| \(entry.filePath) | \(entry.type) | \(entry.count) |") + } + } + + return lines.joined(separator: "\n") + "\n" + } +} diff --git a/Sources/PastewatchCore/JSONParser.swift b/Sources/PastewatchCore/JSONParser.swift new file mode 100644 index 0000000..63f8859 --- /dev/null +++ b/Sources/PastewatchCore/JSONParser.swift @@ -0,0 +1,48 @@ +import Foundation + +/// Parser for JSON files — extracts all string values recursively. +public struct JSONValueParser: FormatParser { + public init() {} + + public func parseValues(from content: String) -> [ParsedValue] { + guard let data = content.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) else { + return [] + } + + var results: [ParsedValue] = [] + extractStrings(from: json, key: nil, results: &results, content: content) + return results + } + + private func extractStrings(from object: Any, key: String?, results: inout [ParsedValue], content: String) { + switch object { + case let str as String: + let line = findLine(of: str, in: content) + results.append(ParsedValue(value: str, line: line, key: key)) + case let dict as [String: Any]: + for (k, v) in dict { + extractStrings(from: v, key: k, results: &results, content: content) + } + case let array as [Any]: + for item in array { + extractStrings(from: item, key: key, results: &results, content: content) + } + default: + break + } + } + + /// Approximate line number by searching for the string value in the content. + private func findLine(of value: String, in content: String) -> Int { + // Search for the value in the content to find its line + guard let range = content.range(of: value) else { return 1 } + var line = 1 + var current = content.startIndex + while current < range.lowerBound { + if content[current] == "\n" { line += 1 } + current = content.index(after: current) + } + return line + } +} diff --git a/Sources/PastewatchCore/MCPAuditLogger.swift b/Sources/PastewatchCore/MCPAuditLogger.swift new file mode 100644 index 0000000..907e0a2 --- /dev/null +++ b/Sources/PastewatchCore/MCPAuditLogger.swift @@ -0,0 +1,35 @@ +import Foundation + +/// Audit logger for MCP tool calls — writes to file and stderr. +/// Never logs actual secret values — only metadata (counts, types, paths). +public final class MCPAuditLogger { + private let fileHandle: FileHandle? + private let dateFormatter: ISO8601DateFormatter + + public init(path: String) { + dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = [.withInternetDateTime] + + if !FileManager.default.fileExists(atPath: path) { + FileManager.default.createFile(atPath: path, contents: nil) + } + fileHandle = FileHandle(forWritingAtPath: path) + fileHandle?.seekToEndOfFile() + + log("MCP audit log started") + } + + deinit { + fileHandle?.closeFile() + } + + public func log(_ message: String) { + let timestamp = dateFormatter.string(from: Date()) + let line = "\(timestamp) \(message)\n" + if let data = line.data(using: .utf8) { + fileHandle?.write(data) + fileHandle?.synchronizeFile() + FileHandle.standardError.write(data) + } + } +} diff --git a/Sources/PastewatchCore/MCPProtocol.swift b/Sources/PastewatchCore/MCPProtocol.swift new file mode 100644 index 0000000..bfa1e2d --- /dev/null +++ b/Sources/PastewatchCore/MCPProtocol.swift @@ -0,0 +1,167 @@ +import Foundation + +/// JSON-RPC 2.0 flexible ID type (integer or string). +public enum JSONRPCId: Codable, Equatable { + case int(Int) + case string(String) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let intValue = try? container.decode(Int.self) { + self = .int(intValue) + return + } + if let stringValue = try? container.decode(String.self) { + self = .string(stringValue) + return + } + throw DecodingError.typeMismatch( + JSONRPCId.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Expected Int or String for JSON-RPC id" + ) + ) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .int(let value): + try container.encode(value) + case .string(let value): + try container.encode(value) + } + } +} + +/// Arbitrary JSON value for MCP protocol messages. +public enum JSONValue: Codable, Equatable { + case string(String) + case number(Double) + case bool(Bool) + case null + case array([JSONValue]) + case object([String: JSONValue]) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + // Bool must be checked before number because JSON booleans + // can be decoded as numbers in some implementations. + if let boolValue = try? container.decode(Bool.self) { + self = .bool(boolValue) + return + } + if let numberValue = try? container.decode(Double.self) { + self = .number(numberValue) + return + } + if let stringValue = try? container.decode(String.self) { + self = .string(stringValue) + return + } + if container.decodeNil() { + self = .null + return + } + if let arrayValue = try? container.decode([JSONValue].self) { + self = .array(arrayValue) + return + } + if let objectValue = try? container.decode([String: JSONValue].self) { + self = .object(objectValue) + return + } + throw DecodingError.typeMismatch( + JSONValue.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Cannot decode JSONValue" + ) + ) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): + try container.encode(value) + case .number(let value): + try container.encode(value) + case .bool(let value): + try container.encode(value) + case .null: + try container.encodeNil() + case .array(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + } + } +} + +/// JSON-RPC 2.0 request message. +public struct JSONRPCRequest: Codable { + public let jsonrpc: String + public let id: JSONRPCId? + public let method: String + public let params: JSONValue? + + public init(jsonrpc: String, id: JSONRPCId?, method: String, params: JSONValue?) { + self.jsonrpc = jsonrpc + self.id = id + self.method = method + self.params = params + } +} + +/// JSON-RPC 2.0 response message. +public struct JSONRPCResponse: Codable { + public let jsonrpc: String + public let id: JSONRPCId? + public let result: JSONValue? + public let error: JSONRPCError? + + public init(jsonrpc: String, id: JSONRPCId?, result: JSONValue?, error: JSONRPCError?) { + self.jsonrpc = jsonrpc + self.id = id + self.result = result + self.error = error + } +} + +/// JSON-RPC 2.0 error object. +public struct JSONRPCError: Codable { + public let code: Int + public let message: String + + public init(code: Int, message: String) { + self.code = code + self.message = message + } +} + +/// MCP tool definition for tools/list responses. +public struct MCPToolDefinition { + public let name: String + public let description: String + public let inputSchema: JSONValue + + public init(name: String, description: String, inputSchema: JSONValue) { + self.name = name + self.description = description + self.inputSchema = inputSchema + } +} + +/// MCP content block for tool call results. +public struct MCPContent: Codable { + public let type: String + public let text: String + + public init(type: String, text: String) { + self.type = type + self.text = text + } +} diff --git a/Sources/PastewatchCore/MarkdownOutput.swift b/Sources/PastewatchCore/MarkdownOutput.swift new file mode 100644 index 0000000..fe1b1bd --- /dev/null +++ b/Sources/PastewatchCore/MarkdownOutput.swift @@ -0,0 +1,48 @@ +import Foundation + +public enum MarkdownFormatter { + /// Format findings for a single file/stdin scan. + public static func formatSingle(matches: [DetectedMatch], filePath: String?, obfuscated: String?) -> String { + var lines: [String] = [] + lines.append("## Pastewatch Scan Results") + lines.append("") + lines.append("\(matches.count) finding(s) detected") + lines.append("") + lines.append("| Severity | Type | Line | Value |") + lines.append("|----------|------|------|-------|") + for match in matches { + let sev = match.effectiveSeverity.rawValue + let name = match.displayName + let line = match.line + let val = "`\(match.value)`" + lines.append("| \(sev) | \(name) | \(line) | \(val) |") + } + lines.append("") + return lines.joined(separator: "\n") + } + + /// Format findings for a directory scan. + public static func formatDirectory(results: [FileScanResult]) -> String { + let totalFindings = results.reduce(0) { $0 + $1.matches.count } + var lines: [String] = [] + lines.append("## Pastewatch Scan Results") + lines.append("") + lines.append("\(totalFindings) finding(s) in \(results.count) file(s)") + lines.append("") + for fr in results { + lines.append("### \(fr.filePath)") + lines.append("") + lines.append("| Severity | Type | Line | Value |") + lines.append("|----------|------|------|-------|") + for match in fr.matches { + let sev = match.effectiveSeverity.rawValue + let name = match.displayName + let line = match.line + let val = "`\(match.value)`" + lines.append("| \(sev) | \(name) | \(line) | \(val) |") + } + lines.append("") + } + return lines.joined(separator: "\n") + } +} diff --git a/Sources/Pastewatch/Obfuscator.swift b/Sources/PastewatchCore/Obfuscator.swift similarity index 56% rename from Sources/Pastewatch/Obfuscator.swift rename to Sources/PastewatchCore/Obfuscator.swift index 0bf7f0b..c61bda7 100644 --- a/Sources/Pastewatch/Obfuscator.swift +++ b/Sources/PastewatchCore/Obfuscator.swift @@ -7,11 +7,11 @@ import Foundation /// - Mapping exists only in memory /// - No persistence, no recovery mechanism /// - After paste, the system returns to rest -struct Obfuscator { +public struct Obfuscator { /// Obfuscate all matches in the content. /// Returns the obfuscated content with matches replaced by placeholders. - static func obfuscate(_ content: String, matches: [DetectedMatch]) -> String { + public static func obfuscate(_ content: String, matches: [DetectedMatch]) -> String { guard !matches.isEmpty else { return content } // Sort matches by range start position (descending) to replace from end @@ -41,8 +41,31 @@ struct Obfuscator { } /// Create a placeholder string for a given type and occurrence number. - private static func makePlaceholder(type: SensitiveDataType, number: Int) -> String { + /// Used by GUI clipboard obfuscation and CLI output. + public static func makePlaceholder(type: SensitiveDataType, number: Int) -> String { let typeName = type.rawValue.uppercased().replacingOccurrences(of: " ", with: "_") return "<\(typeName)_\(number)>" } + + /// Create an MCP-safe placeholder that never collides with real content. + /// Format: __PW_TYPE_N__ — ASCII-safe, grep-friendly, proxy-compatible. + /// Used by MCP redacted read/write tools. + public static func makeMCPPlaceholder(type: SensitiveDataType, number: Int) -> String { + let typeName = type.rawValue.uppercased().replacingOccurrences(of: " ", with: "_") + return "__PW_\(typeName)_\(number)__" + } + + /// Create a custom-prefix placeholder. + /// Format: {prefix}{zero-padded number} — no braces, no special chars. + public static func makeCustomPlaceholder(prefix: String, number: Int) -> String { + return "\(prefix)\(String(format: "%03d", number))" + } + + /// Regex pattern matching MCP placeholders for resolution. + public static let mcpPlaceholderPattern = "__PW_[A-Z][A-Z0-9_]*_\\d+__" + + /// Build a regex pattern matching custom-prefix placeholders. + public static func customPlaceholderPattern(prefix: String) -> String { + return NSRegularExpression.escapedPattern(for: prefix) + "\\d{3,}" + } } diff --git a/Sources/PastewatchCore/PostureScanner.swift b/Sources/PastewatchCore/PostureScanner.swift new file mode 100644 index 0000000..2badc8e --- /dev/null +++ b/Sources/PastewatchCore/PostureScanner.swift @@ -0,0 +1,337 @@ +import Foundation + +// MARK: - Data structures + +public struct RepositorySummary: Codable { + public let name: String + public let totalFindings: Int + public let filesAffected: Int + public let severityBreakdown: SeverityBreakdown + public let typeGroups: [TypeGroup] + public let hotSpots: [HotSpot] + + public init(name: String, totalFindings: Int, filesAffected: Int, + severityBreakdown: SeverityBreakdown, + typeGroups: [TypeGroup], hotSpots: [HotSpot]) { + self.name = name + self.totalFindings = totalFindings + self.filesAffected = filesAffected + self.severityBreakdown = severityBreakdown + self.typeGroups = typeGroups + self.hotSpots = hotSpots + } +} + +public struct PostureReport: Codable { + public let version: String + public let generatedAt: String + public let organization: String + public let totalRepos: Int + public let reposScanned: Int + public let totalFindings: Int + public let severityBreakdown: SeverityBreakdown + public let repositories: [RepositorySummary] + + public init(version: String, generatedAt: String, organization: String, + totalRepos: Int, reposScanned: Int, totalFindings: Int, + severityBreakdown: SeverityBreakdown, + repositories: [RepositorySummary]) { + self.version = version + self.generatedAt = generatedAt + self.organization = organization + self.totalRepos = totalRepos + self.reposScanned = reposScanned + self.totalFindings = totalFindings + self.severityBreakdown = severityBreakdown + self.repositories = repositories + } +} + +public struct PostureDelta: Codable { + public let newFindings: [String] + public let resolvedFindings: [String] + public let totalBefore: Int + public let totalAfter: Int + public let summary: String + + public init(newFindings: [String], resolvedFindings: [String], + totalBefore: Int, totalAfter: Int, summary: String) { + self.newFindings = newFindings + self.resolvedFindings = resolvedFindings + self.totalBefore = totalBefore + self.totalAfter = totalAfter + self.summary = summary + } +} + +// MARK: - Scanner + +public enum PostureScanner { + + public static func runCommand(_ executable: String, _ arguments: [String]) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [executable] + arguments + let pipe = Pipe() + let errPipe = Pipe() + process.standardOutput = pipe + process.standardError = errPipe + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + let errData = errPipe.fileHandleForReading.readDataToEndOfFile() + let errMsg = String(data: errData, encoding: .utf8) ?? "unknown error" + throw PostureError.commandFailed(executable, errMsg.trimmingCharacters(in: .whitespacesAndNewlines)) + } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) ?? "" + } + + public static func enumerateRepos(org: String) throws -> [String] { + let output = try runCommand("gh", ["repo", "list", org, + "--no-archived", "--source", + "--limit", "500", + "--json", "name", "-q", ".[].name"]) + return output.split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } + + public static func cloneRepo(org: String, name: String, into baseDir: String) throws -> String { + let dest = (baseDir as NSString).appendingPathComponent(name) + _ = try runCommand("gh", ["repo", "clone", "\(org)/\(name)", dest, "--", "--depth", "1", "--quiet"]) + return dest + } + + public static func scanRepo(at path: String, name: String, config: PastewatchConfig) throws -> RepositorySummary { + let ignoreFile = IgnoreFile.load(from: path) + let results = try DirectoryScanner.scan( + directory: path, config: config, + ignoreFile: ignoreFile, extraIgnorePatterns: [] + ) + let report = InventoryReport.build(from: results, directory: path) + return RepositorySummary( + name: name, + totalFindings: report.totalFindings, + filesAffected: report.filesAffected, + severityBreakdown: report.severityBreakdown, + typeGroups: report.typeGroups, + hotSpots: report.hotSpots + ) + } + + public static func aggregate( + org: String, summaries: [RepositorySummary], totalRepos: Int + ) -> PostureReport { + var crit = 0, high = 0, med = 0, low = 0 + var totalFindings = 0 + for s in summaries { + crit += s.severityBreakdown.critical + high += s.severityBreakdown.high + med += s.severityBreakdown.medium + low += s.severityBreakdown.low + totalFindings += s.totalFindings + } + + let sorted = summaries.sorted { $0.totalFindings > $1.totalFindings } + + let formatter = ISO8601DateFormatter() + let timestamp = formatter.string(from: Date()) + + return PostureReport( + version: "1", + generatedAt: timestamp, + organization: org, + totalRepos: totalRepos, + reposScanned: summaries.count, + totalFindings: totalFindings, + severityBreakdown: SeverityBreakdown(critical: crit, high: high, medium: med, low: low), + repositories: sorted + ) + } + + public static func compare(current: PostureReport, previous: PostureReport) -> PostureDelta { + let currentRepos = Set(current.repositories.filter { $0.totalFindings > 0 }.map { $0.name }) + let previousRepos = Set(previous.repositories.filter { $0.totalFindings > 0 }.map { $0.name }) + + let newFindings = Array(currentRepos.subtracting(previousRepos)).sorted() + let resolved = Array(previousRepos.subtracting(currentRepos)).sorted() + + let delta = current.totalFindings - previous.totalFindings + let sign = delta >= 0 ? "+" : "" + let summary = "\(sign)\(delta) findings (\(current.totalFindings) total, was \(previous.totalFindings))" + + return PostureDelta( + newFindings: newFindings, + resolvedFindings: resolved, + totalBefore: previous.totalFindings, + totalAfter: current.totalFindings, + summary: summary + ) + } + + public static func load(from path: String) throws -> PostureReport { + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + return try JSONDecoder().decode(PostureReport.self, from: data) + } +} + +public enum PostureError: Error, CustomStringConvertible { + case commandFailed(String, String) + case noReposFound(String) + + public var description: String { + switch self { + case .commandFailed(let cmd, let msg): return "\(cmd) failed: \(msg)" + case .noReposFound(let org): return "no repositories found for \(org)" + } + } +} + +// MARK: - Formatters + +public enum PostureFormatter { + + public static func formatText(_ report: PostureReport, findingsOnly: Bool = false) -> String { + var lines: [String] = [] + lines.append("Posture Report: \(report.organization)") + lines.append(String(repeating: "=", count: 40)) + lines.append("Generated: \(report.generatedAt)") + lines.append("Repos scanned: \(report.reposScanned)/\(report.totalRepos)") + lines.append("Total findings: \(report.totalFindings)") + lines.append("") + lines.append("Severity breakdown:") + lines.append(" critical: \(report.severityBreakdown.critical)") + lines.append(" high: \(report.severityBreakdown.high)") + lines.append(" medium: \(report.severityBreakdown.medium)") + lines.append(" low: \(report.severityBreakdown.low)") + + let withFindings = report.repositories.filter { $0.totalFindings > 0 } + let clean = report.repositories.filter { $0.totalFindings == 0 } + + if !withFindings.isEmpty { + lines.append("") + lines.append("Repositories with findings:") + for repo in withFindings { + let sev = repo.severityBreakdown + lines.append(" \(repo.name) \(repo.totalFindings) findings (C:\(sev.critical) H:\(sev.high) M:\(sev.medium) L:\(sev.low))") + } + } + + if !findingsOnly && !clean.isEmpty { + lines.append("") + lines.append("Clean repositories: \(clean.count)") + for repo in clean { + lines.append(" \(repo.name)") + } + } + + return lines.joined(separator: "\n") + "\n" + } + + public static func formatJSON(_ report: PostureReport) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + guard let data = try? encoder.encode(report), + let str = String(data: data, encoding: .utf8) else { + return "{}" + } + return str + } + + public static func formatMarkdown(_ report: PostureReport, findingsOnly: Bool = false) -> String { + var lines: [String] = [] + lines.append("## Posture Report: \(report.organization)") + lines.append("") + lines.append("**Generated:** \(report.generatedAt)") + lines.append("**Repos scanned:** \(report.reposScanned)/\(report.totalRepos)") + lines.append("**Total findings:** \(report.totalFindings)") + lines.append("") + lines.append("### Severity Breakdown") + lines.append("") + lines.append("| Severity | Count |") + lines.append("|----------|-------|") + lines.append("| critical | \(report.severityBreakdown.critical) |") + lines.append("| high | \(report.severityBreakdown.high) |") + lines.append("| medium | \(report.severityBreakdown.medium) |") + lines.append("| low | \(report.severityBreakdown.low) |") + + let withFindings = report.repositories.filter { $0.totalFindings > 0 } + let clean = report.repositories.filter { $0.totalFindings == 0 } + + if !withFindings.isEmpty { + lines.append("") + lines.append("### Repositories with Findings") + lines.append("") + lines.append("| Repository | Findings | Critical | High | Medium | Low |") + lines.append("|------------|----------|----------|------|--------|-----|") + for repo in withFindings { + let sev = repo.severityBreakdown + lines.append("| \(repo.name) | \(repo.totalFindings) | \(sev.critical) | \(sev.high) | \(sev.medium) | \(sev.low) |") + } + } + + if !findingsOnly && !clean.isEmpty { + lines.append("") + lines.append("### Clean Repositories (\(clean.count))") + lines.append("") + for repo in clean { + lines.append("- \(repo.name)") + } + } + + return lines.joined(separator: "\n") + "\n" + } + + public static func formatDeltaText(_ delta: PostureDelta) -> String { + var lines: [String] = [] + lines.append("") + lines.append("Changes") + lines.append("-------") + lines.append(delta.summary) + + if !delta.newFindings.isEmpty { + lines.append("") + lines.append("New repos with findings:") + for name in delta.newFindings { + lines.append(" + \(name)") + } + } + + if !delta.resolvedFindings.isEmpty { + lines.append("") + lines.append("Repos now clean:") + for name in delta.resolvedFindings { + lines.append(" - \(name)") + } + } + + return lines.joined(separator: "\n") + "\n" + } + + public static func formatDeltaMarkdown(_ delta: PostureDelta) -> String { + var lines: [String] = [] + lines.append("") + lines.append("### Changes") + lines.append("") + lines.append(delta.summary) + + if !delta.newFindings.isEmpty { + lines.append("") + lines.append("**New repos with findings:**") + for name in delta.newFindings { + lines.append("- \(name)") + } + } + + if !delta.resolvedFindings.isEmpty { + lines.append("") + lines.append("**Repos now clean:**") + for name in delta.resolvedFindings { + lines.append("- \(name)") + } + } + + return lines.joined(separator: "\n") + "\n" + } +} diff --git a/Sources/PastewatchCore/PropertiesParser.swift b/Sources/PastewatchCore/PropertiesParser.swift new file mode 100644 index 0000000..9e51148 --- /dev/null +++ b/Sources/PastewatchCore/PropertiesParser.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Parser for .properties / .cfg / .ini files. +public struct PropertiesParser: FormatParser { + public init() {} + + public func parseValues(from content: String) -> [ParsedValue] { + var results: [ParsedValue] = [] + let lines = content.components(separatedBy: .newlines) + + for (index, line) in lines.enumerated() { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + // Skip empty lines, comments (# and !), section headers [section] + if trimmed.isEmpty || trimmed.hasPrefix("#") || trimmed.hasPrefix("!") || trimmed.hasPrefix("[") { + continue + } + + // Find separator (= or :) + var separatorIndex: String.Index? + for char in ["=", ":"] { + if let idx = trimmed.firstIndex(of: Character(char)) { + if separatorIndex == nil || idx < separatorIndex! { + separatorIndex = idx + } + } + } + + guard let sepIdx = separatorIndex else { continue } + + let key = String(trimmed[trimmed.startIndex.. URLSession { + let sessionConfig = URLSessionConfiguration.default + #if canImport(Darwin) + if let proxy = forwardProxy { + let proxyHost = proxy.host ?? "127.0.0.1" + let proxyPort = proxy.port ?? 8080 + sessionConfig.connectionProxyDictionary = [ + kCFNetworkProxiesHTTPEnable: true, + kCFNetworkProxiesHTTPProxy: proxyHost, + kCFNetworkProxiesHTTPPort: proxyPort, + "HTTPSEnable": true, + "HTTPSProxy": proxyHost, + "HTTPSPort": proxyPort + ] + } + #else + if let proxy = forwardProxy { + let proxyHost = proxy.host ?? "127.0.0.1" + let proxyPort = proxy.port ?? 8080 + sessionConfig.connectionProxyDictionary = [ + "HTTPEnable": true, + "HTTPProxy": proxyHost, + "HTTPPort": proxyPort, + "HTTPSEnable": true, + "HTTPSProxy": proxyHost, + "HTTPSPort": proxyPort + ] + } + #endif + return URLSession(configuration: sessionConfig) + } + + /// Start the proxy server. Blocks until stop() is called. + public func start() throws { + #if canImport(Darwin) + serverSocket = socket(AF_INET, SOCK_STREAM, 0) + #else + serverSocket = socket(AF_INET, Int32(SOCK_STREAM.rawValue), 0) + #endif + guard serverSocket >= 0 else { + throw ProxyError.socketCreationFailed + } + + var reuse: Int32 = 1 + setsockopt(serverSocket, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout.size)) + + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = port.bigEndian + addr.sin_addr.s_addr = inet_addr("127.0.0.1") + + let bindResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + bind(serverSocket, sockPtr, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { + close(serverSocket) + throw ProxyError.bindFailed(port: port) + } + + guard listen(serverSocket, 128) == 0 else { + close(serverSocket) + throw ProxyError.listenFailed + } + + running = true + + while running { + var clientAddr = sockaddr_in() + var clientLen = socklen_t(MemoryLayout.size) + let clientSocket = withUnsafeMutablePointer(to: &clientAddr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + accept(serverSocket, sockPtr, &clientLen) + } + } + + guard clientSocket >= 0 else { continue } + + queue.async { [weak self] in + self?.handleConnection(clientSocket) + } + } + } + + /// Stop the proxy server. + public func stop() { + running = false + if serverSocket >= 0 { + close(serverSocket) + serverSocket = -1 + } + } + + // MARK: - Connection handling + + private func handleConnection(_ clientSocket: Int32) { + defer { close(clientSocket) } + + guard let request = readHTTPRequest(from: clientSocket) else { return } + + // Parse the request + guard let parsed = parseHTTPRequest(request) else { + sendError(to: clientSocket, status: 400, message: "Bad Request") + return + } + + // Only scan POST /v1/messages (the endpoint that carries tool results) + var processedBody = parsed.body + var redactionCount = 0 + var redactedTypes: [String] = [] + if parsed.method == "POST" && parsed.path.contains("/v1/messages") { + let result = scanAndRedactBody(parsed.body) + processedBody = result.body + redactionCount = result.redacted + redactedTypes = result.redactedTypes + } + + stats.requestsProcessed += 1 + if redactionCount > 0 { + stats.requestsRedacted += 1 + stats.secretsRedacted += redactionCount + logRedaction(path: parsed.path, count: redactionCount) + } + + // Forward to upstream + let upstreamURL = URL(string: parsed.path, relativeTo: upstream) ?? upstream.appendingPathComponent(parsed.path) + var upstreamRequest = URLRequest(url: upstreamURL) + upstreamRequest.httpMethod = parsed.method + upstreamRequest.httpBody = processedBody.data(using: .utf8) + + // Copy headers (except Host, which we set to upstream) + for (key, value) in parsed.headers where key.lowercased() != "host" && key.lowercased() != "content-length" { + upstreamRequest.setValue(value, forHTTPHeaderField: key) + } + upstreamRequest.setValue(upstream.host, forHTTPHeaderField: "Host") + if let bodyData = processedBody.data(using: .utf8) { + upstreamRequest.setValue(String(bodyData.count), forHTTPHeaderField: "Content-Length") + } + + // Synchronous request to upstream + let semaphore = DispatchSemaphore(value: 0) + var responseData: Data? + var httpResponse: HTTPURLResponse? + + let task = urlSession.dataTask(with: upstreamRequest) { data, response, _ in + responseData = data + httpResponse = response as? HTTPURLResponse + semaphore.signal() + } + task.resume() + semaphore.wait() + + // Send response back to client + guard let resp = httpResponse, let data = responseData else { + sendError(to: clientSocket, status: 502, message: "Bad Gateway") + return + } + + var finalBody = data + if redactionCount > 0 && injectAlert { + finalBody = injectAlertIntoResponse(data, redactionCount: redactionCount, types: redactedTypes) + } + + sendResponse(to: clientSocket, status: resp.statusCode, headers: resp.allHeaderFields, body: finalBody) + } + + // MARK: - Request scanning + + private struct ScanResult { + let body: String + let redacted: Int + let redactedTypes: [String] + } + + private func scanAndRedactBody(_ body: String) -> ScanResult { + guard let data = body.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return ScanResult(body: body, redacted: 0, redactedTypes: []) + } + + var redacted = 0 + var types: [String] = [] + let processed = redactContentArray(json, redacted: &redacted, types: &types) + + guard redacted > 0, + let resultData = try? JSONSerialization.data(withJSONObject: processed, options: []), + let resultString = String(data: resultData, encoding: .utf8) else { + return ScanResult(body: body, redacted: 0, redactedTypes: []) + } + + return ScanResult(body: resultString, redacted: redacted, redactedTypes: types) + } + + /// Walk the messages array looking for tool_result content to scan. + private func redactContentArray(_ json: [String: Any], redacted: inout Int, types: inout [String]) -> [String: Any] { + var result = json + + guard var messages = json["messages"] as? [[String: Any]] else { + return result + } + + for i in 0..= severity } + if !filtered.isEmpty { + let obfuscated = Obfuscator.obfuscate(blockContent, matches: filtered) + var newBlock = block + newBlock["content"] = obfuscated + newContent.append(newBlock) + redacted += filtered.count + types.append(contentsOf: filtered.map { $0.displayName }) + } else { + newContent.append(block) + } + } else if let type = block["type"] as? String, type == "tool_result", + let nestedContent = block["content"] as? [[String: Any]] { + // Content can be array of {type: "text", text: "..."} blocks + var newNested: [[String: Any]] = [] + for nested in nestedContent { + if let nType = nested["type"] as? String, nType == "text", + let text = nested["text"] as? String { + let matches = DetectionRules.scan(text, config: config) + let filtered = matches.filter { $0.effectiveSeverity >= severity } + if !filtered.isEmpty { + let obfuscated = Obfuscator.obfuscate(text, matches: filtered) + var newNest = nested + newNest["text"] = obfuscated + newNested.append(newNest) + redacted += filtered.count + types.append(contentsOf: filtered.map { $0.displayName }) + } else { + newNested.append(nested) + } + } else { + newNested.append(nested) + } + } + var newBlock = block + newBlock["content"] = newNested + newContent.append(newBlock) + } else { + newContent.append(block) + } + } + messages[i]["content"] = newContent + } + } + + result["messages"] = messages + return result + } + + // MARK: - Raw socket I/O + + private func readHTTPRequest(from socket: Int32) -> String? { + var buffer = [UInt8](repeating: 0, count: 1_048_576) // 1MB max + var accumulated = Data() + var contentLength = 0 + var headerEnd = false + var headerEndIndex = 0 + + while true { + let bytesRead = recv(socket, &buffer, buffer.count, 0) + guard bytesRead > 0 else { break } + accumulated.append(contentsOf: buffer[0..= contentLength { break } + } + } + + return String(data: accumulated, encoding: .utf8) + } + + private func parseHTTPRequest(_ raw: String) -> HTTPRequest? { + guard let headerEnd = raw.range(of: "\r\n\r\n") else { return nil } + let headerSection = String(raw[..= 2 else { return nil } + + let method = parts[0] + let path = parts[1] + + var headers: [(String, String)] = [] + for line in lines.dropFirst() { + if let colonIndex = line.firstIndex(of: ":") { + let key = String(line[.. [String: Any] { + let uniqueTypes = Array(Set(types)).sorted() + let typeList = uniqueTypes.joined(separator: ", ") + let text = "[PASTEWATCH] \(redactionCount) secret(s) redacted from your last tool call. " + + "Types: \(typeList). " + + "The original values were replaced with placeholders before reaching the API. " + + "Review your tool outputs for leaked credentials and recommend rotation." + return ["type": "text", "text": text] + } + + func injectAlertIntoResponse(_ responseBody: Data, redactionCount: Int, types: [String]) -> Data { + guard let json = try? JSONSerialization.jsonObject(with: responseBody) as? [String: Any], + var content = json["content"] as? [[String: Any]] else { + return responseBody + } + + let alert = buildAlertBlock(redactionCount: redactionCount, types: types) + content.insert(alert, at: 0) + + var modified = json + modified["content"] = content + + guard let resultData = try? JSONSerialization.data(withJSONObject: modified, options: []) else { + return responseBody + } + + return resultData + } + + // MARK: - Audit log + + private func logRedaction(path: String, count: Int) { + let timestamp = ISO8601DateFormatter().string(from: Date()) + let line = "[\(timestamp)] PROXY REDACTED \(count) secret(s) in \(path)\n" + FileHandle.standardError.write(Data(line.utf8)) + + if let logPath = auditLogPath { + if let handle = FileHandle(forWritingAtPath: logPath) { + handle.seekToEndOfFile() + handle.write(Data(line.utf8)) + handle.closeFile() + } else { + FileManager.default.createFile(atPath: logPath, contents: Data(line.utf8)) + } + } + } +} + +// MARK: - Errors + +public enum ProxyError: Error, CustomStringConvertible { + case socketCreationFailed + case bindFailed(port: UInt16) + case listenFailed + + public var description: String { + switch self { + case .socketCreationFailed: return "Failed to create socket" + case .bindFailed(let port): return "Failed to bind to port \(port) (already in use?)" + case .listenFailed: return "Failed to listen on socket" + } + } +} diff --git a/Sources/PastewatchCore/RedactionStore.swift b/Sources/PastewatchCore/RedactionStore.swift new file mode 100644 index 0000000..d4aea85 --- /dev/null +++ b/Sources/PastewatchCore/RedactionStore.swift @@ -0,0 +1,176 @@ +import Foundation + +/// In-memory store for placeholder↔original mappings used by MCP redacted read/write. +/// +/// Design: +/// - Mapping lives only in server process memory — dies on exit, never persisted +/// - Same value always maps to same placeholder across all files in a session +/// - Deobfuscation happens locally on-device — secrets never leave the machine +/// - Default format: __PW_TYPE_N__ — never collides with real content +/// - Custom prefix format: {prefix}{NNN} — LLM-proxy compatible, no braces +public final class RedactionStore { + // swiftlint:disable:next force_try + private static let structuredRegex = try! NSRegularExpression(pattern: Obfuscator.mcpPlaceholderPattern) + + /// Optional custom prefix for LLM-proxy compatibility. + private let customPrefix: String? + + /// Compiled regex for custom-prefix placeholders (nil when using structured format). + private let customRegex: NSRegularExpression? + + /// Global sequential counter for custom-prefix placeholders. + private var globalCounter: Int = 0 + + /// Forward mapping: placeholder → original value, per file. + private var mappings: [String: [String: String]] = [:] + + /// Global reverse mapping: original value → placeholder (cross-file consistency). + private var globalReverse: [String: String] = [:] + + /// Global type counters for placeholder numbering (structured format only). + private var globalTypeCounters: [SensitiveDataType: Int] = [:] + + public init(placeholderPrefix: String? = nil) { + self.customPrefix = placeholderPrefix + if let prefix = placeholderPrefix { + // swiftlint:disable:next force_try + self.customRegex = try! NSRegularExpression(pattern: Obfuscator.customPlaceholderPattern(prefix: prefix)) + } else { + self.customRegex = nil + } + } + + /// Redact sensitive values in content, storing the mapping for later resolution. + /// Returns the redacted content and a manifest of redactions. + public func redact(content: String, matches: [DetectedMatch], filePath: String) -> (String, [RedactionEntry]) { + guard !matches.isEmpty else { + return (content, []) + } + + // Sort by position (ascending) for consistent placeholder assignment + let sorted = matches.sorted { $0.range.lowerBound < $1.range.lowerBound } + + var entries: [RedactionEntry] = [] + var placeholdersByMatch: [(DetectedMatch, String)] = [] + + for match in sorted { + let original = match.value + + let placeholder: String + if let existing = globalReverse[original] { + // Same value seen before in any file — reuse placeholder + placeholder = existing + } else if let prefix = customPrefix { + globalCounter += 1 + placeholder = Obfuscator.makeCustomPlaceholder(prefix: prefix, number: globalCounter) + globalReverse[original] = placeholder + } else { + let count = (globalTypeCounters[match.type] ?? 0) + 1 + globalTypeCounters[match.type] = count + placeholder = Obfuscator.makeMCPPlaceholder(type: match.type, number: count) + globalReverse[original] = placeholder + } + + // Always store in per-file forward mapping for resolution + var forward = mappings[filePath] ?? [:] + forward[placeholder] = original + mappings[filePath] = forward + + placeholdersByMatch.append((match, placeholder)) + + entries.append(RedactionEntry( + type: match.displayName, + severity: match.effectiveSeverity.rawValue, + line: match.line, + placeholder: placeholder + )) + } + + // Replace from end to preserve indices + var result = content + for (match, placeholder) in placeholdersByMatch.reversed() { + result.replaceSubrange(match.range, with: placeholder) + } + + return (result, entries) + } + + /// Resolve placeholders in content using mappings for a specific file. + public func resolve(content: String, filePath: String) -> ResolveResult { + return resolveWithMappings(content: content, filePaths: [filePath]) + } + + /// Resolve placeholders in content using mappings across all files. + public func resolveAll(content: String) -> ResolveResult { + return resolveWithMappings(content: content, filePaths: Array(mappings.keys)) + } + + /// Clear all mappings. + public func clear() { + mappings.removeAll() + globalReverse.removeAll() + globalTypeCounters.removeAll() + globalCounter = 0 + } + + /// Check if any mappings exist for a file. + public func hasMappings(for filePath: String) -> Bool { + mappings[filePath] != nil && !(mappings[filePath]?.isEmpty ?? true) + } + + private func resolveWithMappings(content: String, filePaths: [String]) -> ResolveResult { + // Build combined mapping from specified files + var combined: [String: String] = [:] + for path in filePaths { + if let fileMap = mappings[path] { + for (placeholder, original) in fileMap { + combined[placeholder] = original + } + } + } + + var result = content + var resolvedCount = 0 + var unresolvedPlaceholders: [String] = [] + + let regex = customRegex ?? Self.structuredRegex + let nsContent = result as NSString + let allMatches = regex.matches(in: result, range: NSRange(location: 0, length: nsContent.length)) + + // Process in reverse order to preserve indices + for match in allMatches.reversed() { + let placeholder = nsContent.substring(with: match.range) + if let original = combined[placeholder] { + let startIndex = result.index(result.startIndex, offsetBy: match.range.location) + let endIndex = result.index(startIndex, offsetBy: match.range.length) + result.replaceSubrange(startIndex..() + var entries: [(key: String, value: String)] = [] + for action in actions { + guard !seen.contains(action.envVarName) else { continue } + seen.insert(action.envVarName) + entries.append((key: action.envVarName, value: action.secretValue)) + } + return entries + } +} + +/// Builds and applies fix plans for externalizing secrets to environment variables. +public enum Remediation { + + // MARK: - Plan building + + /// Build a fix plan from directory scan results. + public static func buildPlan( + results: [FileScanResult], + minSeverity: Severity = .high + ) -> FixPlan { + var actions: [FixAction] = [] + var usedNames: [String: Int] = [:] + + for fr in results { + let fileName = URL(fileURLWithPath: fr.filePath).lastPathComponent + let isEnvFile = fileName == ".env" || fileName.hasSuffix(".env") + let ext = isEnvFile ? "env" : URL(fileURLWithPath: fr.filePath).pathExtension.lowercased() + for match in fr.matches { + guard match.effectiveSeverity >= minSeverity else { continue } + + var name = suggestEnvVarName(match: match, fileContent: fr.content) + name = deduplicateName(name, usedNames: &usedNames) + + let replacement = envVarReference(name: name, ext: ext) + actions.append(FixAction( + filePath: fr.filePath, + line: match.line, + secretValue: match.value, + envVarName: name, + replacement: replacement, + type: match.type, + severity: match.effectiveSeverity + )) + } + } + + return FixPlan(actions: actions) + } + + // MARK: - Env var name suggestion + + /// Suggest an environment variable name for a detected match. + public static func suggestEnvVarName(match: DetectedMatch, fileContent: String) -> String { + // Priority 1: Extract key from the source line + if let keyFromLine = extractKeyFromLine(match: match, content: fileContent) { + return normalizeToEnvVar(keyFromLine) + } + + // Priority 2: Type-based defaults + return defaultEnvVarName(for: match.type) + } + + /// Generate a language-aware environment variable reference. + public static func envVarReference(name: String, ext: String) -> String { + switch ext.lowercased() { + case "py": + return "os.environ[\"\(name)\"]" + case "js", "ts", "mjs", "cjs": + return "process.env.\(name)" + case "go": + return "os.Getenv(\"\(name)\")" + case "rb": + return "ENV[\"\(name)\"]" + case "swift": + return "ProcessInfo.processInfo.environment[\"\(name)\"] ?? \"\"" + case "sh", "bash", "zsh": + return "${\(name)}" + case "env": + return "" + default: + return "${\(name)}" + } + } + + // MARK: - Plan application + + /// Apply a fix plan: patch source files and generate .env file. + public static func apply(plan: FixPlan, dirPath: String, envFilePath: String) throws { + try patchFiles(plan: plan, dirPath: dirPath) + + // Generate .env file + try writeEnvFile(plan: plan, dirPath: dirPath, envFilePath: envFilePath) + } + + /// Patch source files only (no .env generation). Used by --encrypt vault path. + public static func patchFiles(plan: FixPlan, dirPath: String) throws { + let grouped = Dictionary(grouping: plan.actions, by: { $0.filePath }) + for (relPath, actions) in grouped { + let fullPath = (dirPath as NSString).appendingPathComponent(relPath) + try patchFile(at: fullPath, actions: actions) + } + } + + /// Check if .gitignore contains a .env entry. + public static func gitignoreContainsEnv(dirPath: String) -> Bool { + let gitignorePath = (dirPath as NSString).appendingPathComponent(".gitignore") + guard let content = try? String(contentsOfFile: gitignorePath, encoding: .utf8) else { + return false + } + let lines = content.components(separatedBy: .newlines) + return lines.contains { line in + let trimmed = line.trimmingCharacters(in: .whitespaces) + return trimmed == ".env" || trimmed == ".env*" || trimmed == "*.env" + } + } + + // MARK: - Private helpers + + /// Extract the key name from the line containing the match. + private static func extractKeyFromLine(match: DetectedMatch, content: String) -> String? { + let lines = content.components(separatedBy: .newlines) + let lineIndex = match.line - 1 + guard lineIndex >= 0, lineIndex < lines.count else { return nil } + let line = lines[lineIndex] + + // Try common assignment patterns: key = "value", key: value, key=value + let patterns = [ + "([a-zA-Z_][a-zA-Z0-9_]*)\\s*=", // key = or key= + "([a-zA-Z_][a-zA-Z0-9_]*)\\s*:", // key: (YAML style) + "\"([a-zA-Z_][a-zA-Z0-9_]*)\"\\s*:" // "key": (JSON style) + ] + + for pattern in patterns { + guard let regex = try? NSRegularExpression(pattern: pattern), + let result = regex.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)), + let keyRange = Range(result.range(at: 1), in: line) else { continue } + let key = String(line[keyRange]) + // Verify the match value appears after the key on this line + if let keyEnd = line.range(of: key)?.upperBound, + line[keyEnd...].contains(match.value) { + return key + } + } + + return nil + } + + /// Normalize a key name to SCREAMING_SNAKE_CASE. + static func normalizeToEnvVar(_ key: String) -> String { + var result = "" + for (i, char) in key.enumerated() { + if char.isUppercase && i > 0 { + let prev = key[key.index(key.startIndex, offsetBy: i - 1)] + if prev.isLowercase || prev.isNumber { + result += "_" + } + } + result += String(char) + } + return result + .replacingOccurrences(of: "-", with: "_") + .replacingOccurrences(of: ".", with: "_") + .uppercased() + } + + /// Default env var name based on detection type. + private static let defaultEnvVarNames: [SensitiveDataType: String] = [ + .awsKey: "AWS_ACCESS_KEY_ID", + .dbConnectionString: "DATABASE_URL", + .openaiKey: "OPENAI_API_KEY", + .anthropicKey: "ANTHROPIC_API_KEY", + .huggingfaceToken: "HF_TOKEN", + .groqKey: "GROQ_API_KEY", + .npmToken: "NPM_TOKEN", + .pypiToken: "PYPI_TOKEN", + .rubygemsToken: "GEM_HOST_API_KEY", + .gitlabToken: "GITLAB_TOKEN", + .telegramBotToken: "TELEGRAM_BOT_TOKEN", + .sendgridKey: "SENDGRID_API_KEY", + .shopifyToken: "SHOPIFY_ACCESS_TOKEN", + .digitaloceanToken: "DIGITALOCEAN_TOKEN", + .genericApiKey: "API_KEY", + .jwtToken: "JWT_SECRET", + .slackWebhook: "SLACK_WEBHOOK_URL", + .discordWebhook: "DISCORD_WEBHOOK_URL", + .azureConnectionString: "AZURE_CONNECTION_STRING", + .gcpServiceAccount: "GCP_SERVICE_ACCOUNT", + .credential: "SECRET", + .sshPrivateKey: "SSH_PRIVATE_KEY", + .creditCard: "CARD_NUMBER", + .email: "EMAIL", + .phone: "PHONE", + .ipAddress: "IP_ADDRESS", + .hostname: "HOSTNAME", + .filePath: "FILE_PATH", + .uuid: "UUID", + .highEntropyString: "SECRET" + ] + + static func defaultEnvVarName(for type: SensitiveDataType) -> String { + defaultEnvVarNames[type] ?? "SECRET" + } + + /// Add numeric suffix to deduplicate env var names. + private static func deduplicateName(_ name: String, usedNames: inout [String: Int]) -> String { + let count = (usedNames[name] ?? 0) + 1 + usedNames[name] = count + if count == 1 { return name } + return "\(name)_\(count)" + } + + /// Patch a single file by replacing secret values with env var references. + private static func patchFile(at path: String, actions: [FixAction]) throws { + guard var content = try? String(contentsOfFile: path, encoding: .utf8) else { return } + var lines = content.components(separatedBy: "\n") + + // Process from bottom to top to preserve line indices + let sortedActions = actions.sorted { $0.line > $1.line } + for action in sortedActions { + let lineIndex = action.line - 1 + guard lineIndex >= 0, lineIndex < lines.count else { continue } + + if action.replacement.isEmpty { + // .env file: clear the value after the = sign + if let eqIndex = lines[lineIndex].firstIndex(of: "=") { + let key = String(lines[lineIndex][...eqIndex]) + lines[lineIndex] = key + } + } else { + // Try replacing quoted value first (strip surrounding quotes) + let doubleQuoted = "\"\(action.secretValue)\"" + let singleQuoted = "'\(action.secretValue)'" + if lines[lineIndex].contains(doubleQuoted) { + lines[lineIndex] = lines[lineIndex].replacingOccurrences( + of: doubleQuoted, with: action.replacement + ) + } else if lines[lineIndex].contains(singleQuoted) { + lines[lineIndex] = lines[lineIndex].replacingOccurrences( + of: singleQuoted, with: action.replacement + ) + } else { + lines[lineIndex] = lines[lineIndex].replacingOccurrences( + of: action.secretValue, with: action.replacement + ) + } + } + } + + content = lines.joined(separator: "\n") + try content.write(toFile: path, atomically: true, encoding: .utf8) + } + + /// Write or append entries to a .env file. + private static func writeEnvFile( + plan: FixPlan, dirPath: String, envFilePath: String + ) throws { + let envPath = (dirPath as NSString).appendingPathComponent(envFilePath) + var existingKeys = Set() + + // Read existing .env if present + if let existing = try? String(contentsOfFile: envPath, encoding: .utf8) { + for line in existing.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } + if let eqIndex = trimmed.firstIndex(of: "=") { + existingKeys.insert(String(trimmed[.. Data { + let rules = buildRuleDefinitions() + let results = buildResults(matches: matches, filePath: filePath) + return encode(rules: rules, results: results, version: version) + } + + /// Format matches from multiple files into a single SARIF run. + public static func formatMultiFile( + fileResults: [(filePath: String, matches: [DetectedMatch])], + version: String + ) -> Data { + let rules = buildRuleDefinitions() + var results: [SarifResult] = [] + for (filePath, matches) in fileResults { + results.append(contentsOf: buildResults(matches: matches, filePath: filePath)) + } + return encode(rules: rules, results: results, version: version) + } + + // MARK: - Private + + private static func ruleId(for type: SensitiveDataType) -> String { + "pastewatch/" + type.rawValue.uppercased().replacingOccurrences(of: " ", with: "_") + } + + private static func customRuleId(name: String) -> String { + "pastewatch/CUSTOM_" + name.uppercased().replacingOccurrences(of: " ", with: "_") + } + + private static func buildRuleDefinitions() -> [SarifRule] { + SensitiveDataType.allCases.map { type in + SarifRule( + id: ruleId(for: type), + shortDescription: SarifMessage(text: "\(type.rawValue) detected"), + defaultConfiguration: SarifRuleConfig(level: type.severity.sarifLevel), + properties: SarifRuleProps(tags: ["security", "sensitive-data"]) + ) + } + } + + private static func buildResults( + matches: [DetectedMatch], + filePath: String? + ) -> [SarifResult] { + matches.map { match in + let id: String + if let customName = match.customRuleName { + id = customRuleId(name: customName) + } else { + id = ruleId(for: match.type) + } + + let uri = filePath ?? match.filePath ?? "stdin" + + return SarifResult( + ruleId: id, + level: match.effectiveSeverity.sarifLevel, + message: SarifMessage(text: "\(match.displayName) detected"), + locations: [ + SarifLocation( + physicalLocation: SarifPhysicalLocation( + artifactLocation: SarifArtifactLocation(uri: uri), + region: SarifRegion(startLine: match.line) + ) + ) + ] + ) + } + } + + private static func encode( + rules: [SarifRule], + results: [SarifResult], + version: String + ) -> Data { + let log = SarifLog( + schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json", + version: "2.1.0", + runs: [ + SarifRun( + tool: SarifTool( + driver: SarifDriver( + name: "pastewatch-cli", + version: version, + informationUri: "https://github.com/ppiankov/pastewatch", + rules: rules + ) + ), + results: results + ) + ] + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + // swiftlint:disable:next force_try + return try! encoder.encode(log) + } +} diff --git a/Sources/PastewatchCore/SessionReport.swift b/Sources/PastewatchCore/SessionReport.swift new file mode 100644 index 0000000..7cac851 --- /dev/null +++ b/Sources/PastewatchCore/SessionReport.swift @@ -0,0 +1,376 @@ +import Foundation + +// MARK: - Report Types + +/// Aggregated report from an MCP audit log session. +public struct SessionReport: Codable { + public let generatedAt: String + public let auditLogPath: String + public let periodStart: String? + public let periodEnd: String? + public let summary: SessionSummary + public let secretsByType: [TypeCount] + public let filesAccessed: [FileAccess] + public let verdict: String +} + +/// Summary counters for a session. +public struct SessionSummary: Codable { + public let filesRead: Int + public let filesWritten: Int + public let secretsRedacted: Int + public let placeholdersResolved: Int + public let unresolvedPlaceholders: Int + public let outputChecks: Int + public let outputChecksDirty: Int + public let scans: Int + public let scanFindings: Int +} + +/// Count of a detection type found during the session. +public struct TypeCount: Codable { + public let type: String + public let count: Int + public let severity: String +} + +/// Per-file access summary. +public struct FileAccess: Codable { + public let file: String + public let reads: Int + public let writes: Int + public let secretsRedacted: Int +} + +// MARK: - Internal Aggregation Types + +struct WriteFileStats { + var writes: Int = 0 + var resolved: Int = 0 + var unresolved: Int = 0 +} + +// MARK: - Builder + +/// Parses MCP audit log content and builds a SessionReport. +public enum SessionReportBuilder { + + /// Build a session report from audit log content. + public static func build( + content: String, + logPath: String, + since: Date? = nil + ) -> SessionReport { + let df = ISO8601DateFormatter() + df.formatOptions = [.withInternetDateTime] + let now = df.string(from: Date()) + + let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty } + + // Parse lines into entries + var timestamps: [String] = [] + var readFiles: [String: (reads: Int, secrets: Int)] = [:] + var writeFiles: [String: WriteFileStats] = [:] + var typeCounts: [String: Int] = [:] + var totalRedacted = 0 + var totalResolved = 0 + var totalUnresolved = 0 + var outputChecks = 0 + var outputChecksDirty = 0 + var scans = 0 + var scanFindings = 0 + + for line in lines { + // Extract timestamp (ISO8601 = 20 chars min, up to first space after) + guard let spaceIdx = line.firstIndex(of: " "), + spaceIdx > line.startIndex else { continue } + + let tsStr = String(line[line.startIndex..() + for key in readFiles.keys { allFiles.insert(key) } + for key in writeFiles.keys { allFiles.insert(key) } + + let filesAccessed = allFiles.sorted().map { file -> FileAccess in + let r = readFiles[file] + let w = writeFiles[file] + return FileAccess( + file: file, + reads: r?.reads ?? 0, + writes: w?.writes ?? 0, + secretsRedacted: r?.secrets ?? 0 + ) + } + + // Build type counts with severity + let secretsByType = typeCounts.keys.sorted().map { type -> TypeCount in + let sev = SensitiveDataType(rawValue: type)?.severity.rawValue ?? "unknown" + return TypeCount(type: type, count: typeCounts[type] ?? 0, severity: sev) + } + + // Verdict + let verdict: String + if totalUnresolved > 0 { + verdict = "WARNING: \(totalUnresolved) unresolved placeholder(s) — secrets may have leaked." + } else if outputChecksDirty > 0 { + verdict = "WARNING: \(outputChecksDirty) output check(s) found secrets in agent output." + } else { + verdict = "Zero secrets leaked to cloud API during this session." + } + + let summary = SessionSummary( + filesRead: readFiles.count, + filesWritten: writeFiles.count, + secretsRedacted: totalRedacted, + placeholdersResolved: totalResolved, + unresolvedPlaceholders: totalUnresolved, + outputChecks: outputChecks, + outputChecksDirty: outputChecksDirty, + scans: scans, + scanFindings: scanFindings + ) + + return SessionReport( + generatedAt: now, + auditLogPath: logPath, + periodStart: timestamps.first, + periodEnd: timestamps.last, + summary: summary, + secretsByType: secretsByType, + filesAccessed: filesAccessed, + verdict: verdict + ) + } + + // MARK: - Line Parsers + + private static func parseReadLine( + _ line: String, + readFiles: inout [String: (reads: Int, secrets: Int)], + typeCounts: inout [String: Int], + totalRedacted: inout Int + ) { + // Format: "READ redacted=N [Type1, Type2]" or "READ clean" + let parts = line.replacingOccurrences(of: "READ", with: "") + .trimmingCharacters(in: .whitespaces) + + // Extract path (up to first double-space or "redacted=" or "clean") + let path = extractPath(from: parts) + guard !path.isEmpty else { return } + + var existing = readFiles[path] ?? (reads: 0, secrets: 0) + existing.reads += 1 + + if let n = extractInt(from: parts, key: "redacted") { + existing.secrets += n + totalRedacted += n + + // Extract types from [Type1, Type2] + if let bracketStart = parts.firstIndex(of: "["), + let bracketEnd = parts.firstIndex(of: "]") { + let typeStr = parts[parts.index(after: bracketStart).. resolved=N unresolved=M" + let parts = line.replacingOccurrences(of: "WRITE", with: "") + .trimmingCharacters(in: .whitespaces) + + let path = extractPath(from: parts) + guard !path.isEmpty else { return } + + var existing = writeFiles[path] ?? WriteFileStats() + existing.writes += 1 + + if let n = extractInt(from: parts, key: "resolved") { + existing.resolved += n + totalResolved += n + } + if let n = extractInt(from: parts, key: "unresolved") { + existing.unresolved += n + totalUnresolved += n + } + + writeFiles[path] = existing + } + + // MARK: - Helpers + + /// Extract path from log detail (everything before key=value pairs). + private static func extractPath(from detail: String) -> String { + // Path ends at first key=value pattern or bracket + let tokens = detail.components(separatedBy: " ").filter { !$0.isEmpty } + guard let first = tokens.first else { return "" } + // Skip inline markers + if first == "(inline)" { return "(inline)" } + return first + } + + /// Extract integer value for a key=N pattern. + static func extractInt(from text: String, key: String) -> Int? { + let pattern = key + "=" + guard let range = text.range(of: pattern) else { return nil } + let after = text[range.upperBound...] + let numStr = after.prefix(while: { $0.isNumber }) + return Int(numStr) + } + + // MARK: - Formatters + + /// Format report as human-readable text. + public static func formatText(_ report: SessionReport) -> String { + var lines: [String] = [] + lines.append("Agent Session Report") + lines.append("Generated: \(report.generatedAt)") + lines.append("Audit log: \(report.auditLogPath)") + if let start = report.periodStart, let end = report.periodEnd { + lines.append("Period: \(start) — \(end)") + } + lines.append("") + + let s = report.summary + lines.append("Summary") + lines.append(" Files read via MCP: \(s.filesRead)") + lines.append(" Files written via MCP: \(s.filesWritten)") + lines.append(" Secrets redacted (read): \(s.secretsRedacted)") + lines.append(" Placeholders resolved: \(s.placeholdersResolved)") + lines.append(" Unresolved placeholders: \(s.unresolvedPlaceholders)") + lines.append(" Output checks: \(s.outputChecks)") + lines.append(" Output checks (dirty): \(s.outputChecksDirty)") + lines.append(" Scans: \(s.scans)") + lines.append(" Scan findings: \(s.scanFindings)") + lines.append("") + + if !report.secretsByType.isEmpty { + lines.append("Secrets by type") + for tc in report.secretsByType { + let padType = tc.type.padding(toLength: 22, withPad: " ", startingAt: 0) + lines.append(" \(padType) \(tc.count) (\(tc.severity))") + } + lines.append("") + } + + if !report.filesAccessed.isEmpty { + lines.append("Files accessed") + for fa in report.filesAccessed { + lines.append(" \(fa.file) reads=\(fa.reads) writes=\(fa.writes) redacted=\(fa.secretsRedacted)") + } + lines.append("") + } + + lines.append("Verdict: \(report.verdict)") + lines.append("") + return lines.joined(separator: "\n") + } + + /// Format report as JSON. + public static func formatJSON(_ report: SessionReport) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + guard let data = try? encoder.encode(report), + let str = String(data: data, encoding: .utf8) else { + return "{}" + } + return str + "\n" + } + + /// Format report as markdown. + public static func formatMarkdown(_ report: SessionReport) -> String { + var lines: [String] = [] + lines.append("# Agent Session Report") + lines.append("") + lines.append("Generated: \(report.generatedAt)") + lines.append("Audit log: `\(report.auditLogPath)`") + if let start = report.periodStart, let end = report.periodEnd { + lines.append("Period: \(start) — \(end)") + } + lines.append("") + + let s = report.summary + lines.append("## Summary") + lines.append("") + lines.append("| Metric | Count |") + lines.append("|--------|-------|") + lines.append("| Files read via MCP | \(s.filesRead) |") + lines.append("| Files written via MCP | \(s.filesWritten) |") + lines.append("| Secrets redacted (read) | \(s.secretsRedacted) |") + lines.append("| Placeholders resolved (write) | \(s.placeholdersResolved) |") + lines.append("| Unresolved placeholders | \(s.unresolvedPlaceholders) |") + lines.append("| Output checks | \(s.outputChecks) |") + lines.append("| Output checks (dirty) | \(s.outputChecksDirty) |") + lines.append("| Scans | \(s.scans) |") + lines.append("| Scan findings | \(s.scanFindings) |") + lines.append("") + + if !report.secretsByType.isEmpty { + lines.append("## Secrets by Type") + lines.append("") + lines.append("| Type | Count | Severity |") + lines.append("|------|-------|----------|") + for tc in report.secretsByType { + lines.append("| \(tc.type) | \(tc.count) | \(tc.severity) |") + } + lines.append("") + } + + if !report.filesAccessed.isEmpty { + lines.append("## Files Accessed") + lines.append("") + lines.append("| File | Reads | Writes | Secrets Redacted |") + lines.append("|------|-------|--------|-----------------|") + for fa in report.filesAccessed { + lines.append("| \(fa.file) | \(fa.reads) | \(fa.writes) | \(fa.secretsRedacted) |") + } + lines.append("") + } + + lines.append("## Verdict") + lines.append("") + lines.append(report.verdict) + lines.append("") + return lines.joined(separator: "\n") + } +} diff --git a/Sources/PastewatchCore/Types.swift b/Sources/PastewatchCore/Types.swift new file mode 100644 index 0000000..60c4423 --- /dev/null +++ b/Sources/PastewatchCore/Types.swift @@ -0,0 +1,428 @@ +import Foundation + +/// Severity level for detected findings. +public enum Severity: String, Codable, CaseIterable, Comparable { + case critical + case high + case medium + case low + + private var rank: Int { + switch self { + case .critical: return 4 + case .high: return 3 + case .medium: return 2 + case .low: return 1 + } + } + + public static func < (lhs: Severity, rhs: Severity) -> Bool { + lhs.rank < rhs.rank + } + + /// Map to SARIF result level. + public var sarifLevel: String { + switch self { + case .critical, .high: return "error" + case .medium: return "warning" + case .low: return "note" + } + } +} + +/// Detected sensitive data types. +/// Each type has deterministic detection rules — no ML, no guessing. +public enum SensitiveDataType: String, CaseIterable, Codable { + case email = "Email" + case phone = "Phone" + case ipAddress = "IP" + case awsKey = "AWS Key" + case genericApiKey = "API Key" + case uuid = "UUID" + case dbConnectionString = "DB Connection" + case sshPrivateKey = "SSH Key" + case jwtToken = "JWT" + case creditCard = "Card" + case filePath = "File Path" + case hostname = "Hostname" + case credential = "Credential" + case slackWebhook = "Slack Webhook" + case discordWebhook = "Discord Webhook" + case azureConnectionString = "Azure Connection" + case gcpServiceAccount = "GCP Service Account" + case openaiKey = "OpenAI Key" + case anthropicKey = "Anthropic Key" + case huggingfaceToken = "Hugging Face Token" + case groqKey = "Groq Key" + case npmToken = "npm Token" + case pypiToken = "PyPI Token" + case rubygemsToken = "RubyGems Token" + case gitlabToken = "GitLab Token" + case telegramBotToken = "Telegram Bot Token" + case sendgridKey = "SendGrid Key" + case shopifyToken = "Shopify Token" + case digitaloceanToken = "DigitalOcean Token" + case perplexityKey = "Perplexity Key" + case workledgerKey = "Workledger Key" + case oraculKey = "Oracul Key" + case jdbcUrl = "JDBC URL" + case xmlCredential = "XML Credential" + case xmlUsername = "XML Username" + case xmlHostname = "XML Hostname" + case highEntropyString = "High Entropy" + + /// Severity of this detection type. + public var severity: Severity { + switch self { + case .awsKey, .genericApiKey, .sshPrivateKey, .dbConnectionString, + .jwtToken, .creditCard, .credential, + .slackWebhook, .discordWebhook, .azureConnectionString, .gcpServiceAccount, + .openaiKey, .anthropicKey, .huggingfaceToken, .groqKey, + .npmToken, .pypiToken, .rubygemsToken, + .gitlabToken, .telegramBotToken, .sendgridKey, .shopifyToken, .digitaloceanToken, + .perplexityKey, .workledgerKey, .oraculKey, .jdbcUrl, .xmlCredential: + return .critical + case .email, .phone, .xmlUsername: + return .high + case .ipAddress, .filePath, .hostname, .xmlHostname: + return .medium + case .uuid, .highEntropyString: + return .low + } + } + + /// Human-readable explanation of what this type detects. + public var explanation: String { + switch self { + case .email: return "Email addresses (user@domain.tld)" + case .phone: return "Phone numbers in international or US format" + case .ipAddress: return "Private and public IPv4 addresses (excludes localhost)" + case .awsKey: return "AWS access key IDs starting with AKIA" + case .genericApiKey: return "API keys and tokens (GitHub, Stripe, generic secret_ prefixes)" + case .uuid: return "UUIDs (version 1-5 format)" + case .dbConnectionString: return "Database connection strings (postgres://, mysql://, mongodb://)" + case .sshPrivateKey: return "SSH/PGP private key headers (BEGIN RSA/DSA/EC/OPENSSH PRIVATE KEY)" + case .jwtToken: return "JSON Web Tokens (three base64url-encoded segments)" + case .creditCard: return "Credit card numbers (Visa, Mastercard, Amex) with Luhn validation" + case .filePath: return "Sensitive file paths (/etc/*, /home/*/.ssh/*, etc.)" + case .hostname: return "Internal hostnames and non-public domains" + case .credential: return "Key-value credential patterns (password=, secret:, auth=)" + case .slackWebhook: return "Slack incoming webhook URLs" + case .discordWebhook: return "Discord webhook URLs" + case .azureConnectionString: return "Azure Storage connection strings with AccountKey" + case .gcpServiceAccount: return "GCP service account JSON key files" + case .openaiKey: return "OpenAI API keys (sk-proj-, sk-svcacct- prefixes)" + case .anthropicKey: return "Anthropic API keys (sk-ant-api03-, sk-ant-admin01-, sk-ant-oat01- prefixes)" + case .huggingfaceToken: return "Hugging Face access tokens (hf_ prefix)" + case .groqKey: return "Groq API keys (gsk_ prefix)" + case .npmToken: return "npm access tokens (npm_ prefix)" + case .pypiToken: return "PyPI API tokens (pypi- prefix)" + case .rubygemsToken: return "RubyGems API keys (rubygems_ prefix)" + case .gitlabToken: return "GitLab personal access tokens (glpat- prefix)" + case .telegramBotToken: return "Telegram bot tokens (numeric ID + AA hash)" + case .sendgridKey: return "SendGrid API keys (SG. prefix with base64 segments)" + case .shopifyToken: return "Shopify access tokens (shpat_, shpca_, shppa_ prefixes)" + case .digitaloceanToken: return "DigitalOcean tokens (dop_v1_, doo_v1_ prefixes)" + case .perplexityKey: return "Perplexity AI API keys (pplx- prefix)" + case .workledgerKey: return "Workledger API keys (wl_sk_ prefix)" + case .oraculKey: return "Oracul API keys (vc__ prefix)" + case .jdbcUrl: return "JDBC connection URLs (jdbc:oracle, jdbc:db2, jdbc:mysql, jdbc:postgresql, jdbc:sqlserver)" + case .xmlCredential: return "Credentials in XML tags (password, secret, access_key)" + case .xmlUsername: return "Usernames in XML tags (user, name within users context)" + case .xmlHostname: return "Hostnames in XML tags (host, hostname, replica)" + case .highEntropyString: return "High-entropy strings that may be secrets (Shannon entropy > 4.0, mixed character classes)" + } + } + + /// Example strings that would be detected by this type. + public var examples: [String] { + switch self { + case .email: return ["user@company.com", "admin@internal.corp.net"] + case .phone: return ["+14155551234", "(555) 123-4567"] + case .ipAddress: return ["192.168.1.100", "10.0.0.50"] + case .awsKey: return ["AKIA<20-character key ID>"] + case .genericApiKey: return ["ghp_<36-character token>", "sk_live_"] + case .uuid: return ["550e8400-e29b-41d4-a716-446655440000"] + case .dbConnectionString: return ["postgres://... (connection URI)", "mongodb://... (connection URI)"] + case .sshPrivateKey: return ["-----BEGIN PRIVATE KEY-----"] + case .jwtToken: return ["
.. (base64url)"] + case .creditCard: return ["4111 1111 1111 1111", "5500 0000 0000 0004"] + case .filePath: return ["/etc/nginx/nginx.conf", "/home/deploy/.ssh/id_rsa"] + case .hostname: return ["db-primary.internal.corp.net", "api.staging.company.io"] + case .credential: return ["password=", "secret: "] + case .slackWebhook: return ["https://hooks.slack.com/services/T.../B.../xxx"] + case .discordWebhook: return ["https://discord.com/api/webhooks//"] + case .azureConnectionString: return ["DefaultEndpointsProtocol=https;AccountName=;AccountKey="] + case .gcpServiceAccount: return ["{\"type\": \"service_account\", \"project_id\": \"\"}"] + case .openaiKey: return ["sk-proj-", "sk-svcacct-"] + case .anthropicKey: return ["sk-ant-api03-", "sk-ant-admin01-"] + case .huggingfaceToken: return ["hf_"] + case .groqKey: return ["gsk_"] + case .npmToken: return ["npm_"] + case .pypiToken: return ["pypi-"] + case .rubygemsToken: return ["rubygems_"] + case .gitlabToken: return ["glpat-"] + case .telegramBotToken: return ["123456789:AA<33-character hash>"] + case .sendgridKey: return ["SG.."] + case .shopifyToken: return ["shpat_", "shpca_", "shppa_"] + case .digitaloceanToken: return ["dop_v1_<64-hex-chars>", "doo_v1_<64-hex-chars>"] + case .perplexityKey: return ["pplx-<48-alphanumeric-chars>"] + case .workledgerKey: return ["wl_sk_<32+-base64url-chars>"] + case .oraculKey: return ["vc_admin_<32-hex-chars>", "vc_pro_<32-hex-chars>"] + case .jdbcUrl: return ["jdbc:oracle:thin:@host:1521:SID", "jdbc:postgresql://host:5432/db"] + case .xmlCredential: return ["secret123", "KEY"] + case .xmlUsername: return ["admin", "deploy"] + case .xmlHostname: return ["db-primary.internal.corp.net"] + case .highEntropyString: return ["xK9mP2qL8nR5vT1wY6hJ3dF0s (20+ chars, mixed case/digits)"] + } + } +} + +/// A single detected match in the clipboard content. +public struct DetectedMatch: Identifiable, Equatable { + public let id = UUID() + public let type: SensitiveDataType + public let value: String + public let range: Range + public let line: Int + public let filePath: String? + public let customRuleName: String? + public let customSeverity: Severity? + + public init( + type: SensitiveDataType, + value: String, + range: Range, + line: Int = 1, + filePath: String? = nil, + customRuleName: String? = nil, + customSeverity: Severity? = nil + ) { + self.type = type + self.value = value + self.range = range + self.line = line + self.filePath = filePath + self.customRuleName = customRuleName + self.customSeverity = customSeverity + } + + /// Effective severity: custom override if set, otherwise type default. + public var effectiveSeverity: Severity { + customSeverity ?? type.severity + } + + /// Display name for output (custom rule name or type rawValue). + public var displayName: String { + customRuleName ?? type.rawValue + } + + public static func == (lhs: DetectedMatch, rhs: DetectedMatch) -> Bool { + lhs.id == rhs.id + } +} + +/// Result of scanning clipboard content. +public struct ScanResult { + public let originalContent: String + public let matches: [DetectedMatch] + public let obfuscatedContent: String + public let timestamp: Date + + public init(originalContent: String, matches: [DetectedMatch], obfuscatedContent: String, timestamp: Date) { + self.originalContent = originalContent + self.matches = matches + self.obfuscatedContent = obfuscatedContent + self.timestamp = timestamp + } + + public var hasMatches: Bool { !matches.isEmpty } + + /// Summary for notification display. + public var summary: String { + guard hasMatches else { return "" } + + let grouped = Dictionary(grouping: matches, by: { $0.type }) + let parts = grouped.map { type, items in + "\(type.rawValue) (\(items.count))" + } + return parts.joined(separator: ", ") + } +} + +/// Application state. +public enum AppState: Equatable { + case idle + case monitoring + case paused +} + +/// Custom rule definition for user-defined patterns. +public struct CustomRuleConfig: Codable { + public let name: String + public let pattern: String + public let severity: String? + + public init(name: String, pattern: String, severity: String? = nil) { + self.name = name + self.pattern = pattern + self.severity = severity + } +} + +/// Configuration for Pastewatch. +/// Loaded from ~/.config/pastewatch/config.json if present. +public struct PastewatchConfig: Codable { + public var enabled: Bool + public var enabledTypes: [String] + public var showNotifications: Bool + public var soundEnabled: Bool + public var allowedValues: [String] + public var customRules: [CustomRuleConfig] + public var safeHosts: [String] + public var sensitiveHosts: [String] + public var allowedPatterns: [String] + public var sensitiveIPPrefixes: [String] + public var mcpMinSeverity: String + public var xmlSensitiveTags: [String] + public var placeholderPrefix: String? + public var protectedPaths: [String] + + public init( + enabled: Bool, + enabledTypes: [String], + showNotifications: Bool, + soundEnabled: Bool, + allowedValues: [String] = [], + customRules: [CustomRuleConfig] = [], + safeHosts: [String] = [], + sensitiveHosts: [String] = [], + allowedPatterns: [String] = [], + sensitiveIPPrefixes: [String] = [], + mcpMinSeverity: String = "high", + xmlSensitiveTags: [String] = [], + placeholderPrefix: String? = nil, + protectedPaths: [String] = ["~/.openclaw"] + ) { + self.enabled = enabled + self.enabledTypes = enabledTypes + self.showNotifications = showNotifications + self.soundEnabled = soundEnabled + self.allowedValues = allowedValues + self.customRules = customRules + self.safeHosts = safeHosts + self.sensitiveHosts = sensitiveHosts + self.allowedPatterns = allowedPatterns + self.sensitiveIPPrefixes = sensitiveIPPrefixes + self.mcpMinSeverity = mcpMinSeverity + self.xmlSensitiveTags = xmlSensitiveTags + self.placeholderPrefix = placeholderPrefix + self.protectedPaths = protectedPaths + } + + // Backward-compatible decoding: missing fields get defaults + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + enabled = try container.decode(Bool.self, forKey: .enabled) + var loaded = try container.decode([String].self, forKey: .enabledTypes) + // Auto-enable new detection types that weren't in the saved config + let allDefaults = SensitiveDataType.allCases + .filter { $0 != .highEntropyString } + .map { $0.rawValue } + for typeName in allDefaults where !loaded.contains(typeName) { + loaded.append(typeName) + } + enabledTypes = loaded + showNotifications = try container.decode(Bool.self, forKey: .showNotifications) + soundEnabled = try container.decode(Bool.self, forKey: .soundEnabled) + allowedValues = try container.decodeIfPresent([String].self, forKey: .allowedValues) ?? [] + customRules = try container.decodeIfPresent([CustomRuleConfig].self, forKey: .customRules) ?? [] + safeHosts = try container.decodeIfPresent([String].self, forKey: .safeHosts) ?? [] + sensitiveHosts = try container.decodeIfPresent([String].self, forKey: .sensitiveHosts) ?? [] + allowedPatterns = try container.decodeIfPresent([String].self, forKey: .allowedPatterns) ?? [] + sensitiveIPPrefixes = try container.decodeIfPresent([String].self, forKey: .sensitiveIPPrefixes) ?? [] + mcpMinSeverity = try container.decodeIfPresent(String.self, forKey: .mcpMinSeverity) ?? "high" + xmlSensitiveTags = try container.decodeIfPresent([String].self, forKey: .xmlSensitiveTags) ?? [] + placeholderPrefix = try container.decodeIfPresent(String.self, forKey: .placeholderPrefix) + protectedPaths = try container.decodeIfPresent([String].self, forKey: .protectedPaths) ?? ["~/.openclaw"] + } + + public static let defaultConfig = PastewatchConfig( + enabled: true, + enabledTypes: SensitiveDataType.allCases.filter { $0 != .highEntropyString }.map { $0.rawValue }, + showNotifications: true, + soundEnabled: false + ) + + public static let configPath: URL = { + let home = FileManager.default.homeDirectoryForCurrentUser + return home.appendingPathComponent(".config/pastewatch/config.json") + }() + + public static func load() -> PastewatchConfig { + guard FileManager.default.fileExists(atPath: configPath.path) else { + return defaultConfig + } + + do { + let data = try Data(contentsOf: configPath) + return try JSONDecoder().decode(PastewatchConfig.self, from: data) + } catch { + return defaultConfig + } + } + + /// System-wide admin config path. If present, takes highest priority (cannot be overridden). + public static let systemConfigPath = "/etc/pastewatch/config.json" + + /// Resolve config with cascade: /etc/pastewatch -> CWD .pastewatch.json -> ~/.config/pastewatch -> defaults. + public static func resolve() -> PastewatchConfig { + // 1. Admin-enforced config (highest priority) + if FileManager.default.fileExists(atPath: systemConfigPath), + let data = try? Data(contentsOf: URL(fileURLWithPath: systemConfigPath)), + let config = try? JSONDecoder().decode(PastewatchConfig.self, from: data) { + return config + } + + // 2. Project config + let cwd = FileManager.default.currentDirectoryPath + let projectPath = cwd + "/.pastewatch.json" + if FileManager.default.fileExists(atPath: projectPath), + let data = try? Data(contentsOf: URL(fileURLWithPath: projectPath)), + let config = try? JSONDecoder().decode(PastewatchConfig.self, from: data) { + return config + } + + // 3. User config / defaults + return load() + } + + public func save() throws { + let directory = PastewatchConfig.configPath.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let data = try JSONEncoder().encode(self) + try data.write(to: PastewatchConfig.configPath) + } + + public func isTypeEnabled(_ type: SensitiveDataType) -> Bool { + enabledTypes.contains(type.rawValue) + } + + /// Returns true if the given file path is inside a protected directory. + public func isPathProtected(_ filePath: String) -> Bool { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let normalized = filePath.hasPrefix("~") + ? home + filePath.dropFirst() + : filePath + for protectedPath in protectedPaths { + let expanded = protectedPath.hasPrefix("~") + ? home + protectedPath.dropFirst() + : protectedPath + let dir = expanded.hasSuffix("/") ? expanded : expanded + "/" + if normalized.hasPrefix(dir) || normalized == expanded { + return true + } + } + return false + } +} diff --git a/Sources/PastewatchCore/Vault.swift b/Sources/PastewatchCore/Vault.swift new file mode 100644 index 0000000..e983be5 --- /dev/null +++ b/Sources/PastewatchCore/Vault.swift @@ -0,0 +1,246 @@ +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif +import Foundation + +// MARK: - Types + +public struct VaultEntry: Codable { + public let varName: String + public let type: String + public let sourceFile: String + public let sourceLine: Int + public let nonce: String + public let ciphertext: String + + public init( + varName: String, type: String, sourceFile: String, + sourceLine: Int, nonce: String, ciphertext: String + ) { + self.varName = varName + self.type = type + self.sourceFile = sourceFile + self.sourceLine = sourceLine + self.nonce = nonce + self.ciphertext = ciphertext + } +} + +public struct VaultFile: Codable { + public let version: Int + public let keyFingerprint: String + public var entries: [VaultEntry] + + public init(version: Int, keyFingerprint: String, entries: [VaultEntry]) { + self.version = version + self.keyFingerprint = keyFingerprint + self.entries = entries + } +} + +// MARK: - Vault Operations + +public enum VaultError: Error, CustomStringConvertible { + case invalidKeyFormat + case decryptionFailed + case keyFingerprintMismatch(expected: String, got: String) + case keyFileNotFound(String) + case vaultFileNotFound(String) + + public var description: String { + switch self { + case .invalidKeyFormat: + return "invalid key format: expected 64 hex characters" + case .decryptionFailed: + return "decryption failed: wrong key or corrupted data" + case .keyFingerprintMismatch(let expected, let got): + return "key fingerprint mismatch: vault expects \(expected), got \(got)" + case .keyFileNotFound(let path): + return "key file not found: \(path) (use --init-key to generate)" + case .vaultFileNotFound(let path): + return "vault file not found: \(path)" + } + } +} + +public enum Vault { + + // MARK: - Key Management + + public static func generateKey() -> String { + let key = SymmetricKey(size: .bits256) + return key.withUnsafeBytes { bytes in + bytes.map { String(format: "%02x", $0) }.joined() + } + } + + public static func keyFingerprint(_ keyHex: String) -> String { + let digest = SHA256.hash(data: Data(keyHex.utf8)) + return digest.prefix(8).map { String(format: "%02x", $0) }.joined() + } + + public static func readKey(from path: String) throws -> String { + guard FileManager.default.fileExists(atPath: path) else { + throw VaultError.keyFileNotFound(path) + } + let content = try String(contentsOfFile: path, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard content.count == 64, content.allSatisfy({ $0.isHexDigit }) else { + throw VaultError.invalidKeyFormat + } + return content + } + + public static func writeKey(_ keyHex: String, to path: String) throws { + let dir = (path as NSString).deletingLastPathComponent + if !dir.isEmpty { + try FileManager.default.createDirectory( + atPath: dir, withIntermediateDirectories: true + ) + } + try keyHex.write(toFile: path, atomically: true, encoding: .utf8) + // Set file permissions to owner-only (0600) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], ofItemAtPath: path + ) + } + + // MARK: - Encrypt / Decrypt + + public static func encrypt( + value: String, keyHex: String + ) throws -> (nonce: String, ciphertext: String) { + let symmetricKey = try parseKey(keyHex) + let plaintext = Data(value.utf8) + let sealedBox = try ChaChaPoly.seal(plaintext, using: symmetricKey) + let nonceData = Data(sealedBox.nonce) + let combined = sealedBox.ciphertext + sealedBox.tag + return ( + nonce: nonceData.base64EncodedString(), + ciphertext: combined.base64EncodedString() + ) + } + + public static func decrypt( + nonce nonceB64: String, ciphertext ciphertextB64: String, keyHex: String + ) throws -> String { + let symmetricKey = try parseKey(keyHex) + guard let nonceData = Data(base64Encoded: nonceB64), + let combined = Data(base64Encoded: ciphertextB64) else { + throw VaultError.decryptionFailed + } + do { + let nonce = try ChaChaPoly.Nonce(data: nonceData) + let tagSize = 16 + guard combined.count >= tagSize else { throw VaultError.decryptionFailed } + let ciphertext = combined.prefix(combined.count - tagSize) + let tag = combined.suffix(tagSize) + let sealedBox = try ChaChaPoly.SealedBox( + nonce: nonce, ciphertext: ciphertext, tag: tag + ) + let plaintext = try ChaChaPoly.open(sealedBox, using: symmetricKey) + guard let str = String(data: plaintext, encoding: .utf8) else { + throw VaultError.decryptionFailed + } + return str + } catch is VaultError { + throw VaultError.decryptionFailed + } catch { + throw VaultError.decryptionFailed + } + } + + // MARK: - Vault File I/O + + public static func load(from path: String) throws -> VaultFile { + guard FileManager.default.fileExists(atPath: path) else { + throw VaultError.vaultFileNotFound(path) + } + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + return try JSONDecoder().decode(VaultFile.self, from: data) + } + + public static func save(_ vault: VaultFile, to path: String) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(vault) + try data.write(to: URL(fileURLWithPath: path)) + } + + // MARK: - Build & Decrypt All + + public static func buildVault( + plan: FixPlan, keyHex: String + ) throws -> VaultFile { + let fp = keyFingerprint(keyHex) + var entries: [VaultEntry] = [] + + var seen = Set() + for action in plan.actions { + guard !seen.contains(action.envVarName) else { continue } + seen.insert(action.envVarName) + + let encrypted = try encrypt(value: action.secretValue, keyHex: keyHex) + entries.append(VaultEntry( + varName: action.envVarName, + type: action.type.rawValue, + sourceFile: action.filePath, + sourceLine: action.line, + nonce: encrypted.nonce, + ciphertext: encrypted.ciphertext + )) + } + + return VaultFile(version: 1, keyFingerprint: fp, entries: entries) + } + + public static func decryptAll( + vault: VaultFile, keyHex: String + ) throws -> [(String, String)] { + let fp = keyFingerprint(keyHex) + if vault.keyFingerprint != fp { + throw VaultError.keyFingerprintMismatch( + expected: vault.keyFingerprint, got: fp + ) + } + + return try vault.entries.map { entry in + let value = try decrypt( + nonce: entry.nonce, + ciphertext: entry.ciphertext, + keyHex: keyHex + ) + return (entry.varName, value) + } + } + + /// Merge new entries into existing vault, skipping duplicates by varName. + public static func merge( + existing: VaultFile, new: VaultFile + ) -> VaultFile { + let existingNames = Set(existing.entries.map { $0.varName }) + let newEntries = new.entries.filter { !existingNames.contains($0.varName) } + var merged = existing + merged.entries.append(contentsOf: newEntries) + return merged + } + + // MARK: - Private + + private static func parseKey(_ keyHex: String) throws -> SymmetricKey { + guard keyHex.count == 64 else { throw VaultError.invalidKeyFormat } + var bytes: [UInt8] = [] + var index = keyHex.startIndex + for _ in 0..<32 { + let nextIndex = keyHex.index(index, offsetBy: 2) + guard let byte = UInt8(keyHex[index.. = [ + // Credentials + "password", "password_sha256_hex", "password_double_sha1_hex", + "access_key_id", "secret_access_key", + // Usernames + "user", "name", "quota_key", + // Hostnames + "host", "hostname", "interserver_http_host", + // Connection strings + "connection_string", "url", + ] + + private let sensitiveTags: Set + + public init(sensitiveTags: Set? = nil) { + self.sensitiveTags = sensitiveTags ?? Self.defaultSensitiveTags + } + + public func parseValues(from content: String) -> [ParsedValue] { + var results: [ParsedValue] = [] + let lines = content.components(separatedBy: .newlines) + + for (index, line) in lines.enumerated() { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + // Skip XML comments, processing instructions, declarations + if trimmed.hasPrefix(" + real_value + """ + let values = parser.parseValues(from: xml) + XCTAssertEqual(values.count, 1) + XCTAssertEqual(values.first?.value, "real_value") + } + + func testSkipsProcessingInstructions() { + let xml = """ + + value + """ + let values = parser.parseValues(from: xml) + XCTAssertEqual(values.count, 1) + } + + func testSkipsEmptyTags() { + let xml = "" + let values = parser.parseValues(from: xml) + XCTAssertEqual(values.count, 0) + } + + // MARK: - Line Numbers + + func testCorrectLineNumbers() { + let xml = """ + + db.corp.net + 9000 + secret + + """ + let values = parser.parseValues(from: xml) + let hostValue = values.first { $0.key == "host" } + let passValue = values.first { $0.key == "password" } + XCTAssertEqual(hostValue?.line, 2) + XCTAssertEqual(passValue?.line, 4) + } + + // MARK: - Custom Tags + + func testCustomSensitiveTags() { + let customParser = XMLValueParser(sensitiveTags: ["custom_secret", "api_key"]) + let xml = """ + my_value + ignored + key123 + """ + let values = customParser.parseValues(from: xml) + XCTAssertEqual(values.count, 2) + XCTAssertTrue(values.contains { $0.key == "custom_secret" }) + XCTAssertTrue(values.contains { $0.key == "api_key" }) + XCTAssertFalse(values.contains { $0.key == "password" }) + } + + // MARK: - Connection Strings + + func testExtractsConnectionStringTag() { + let connStr = ["postgres://admin:pass", "@db:5432/prod"].joined() + let xml = "\(connStr)" + let values = parser.parseValues(from: xml) + XCTAssertEqual(values.count, 1) + XCTAssertEqual(values.first?.key, "connection_string") + } + + func testExtractsURLTag() { + let xml = "https://s3.amazonaws.com/bucket/key" + let values = parser.parseValues(from: xml) + XCTAssertEqual(values.count, 1) + XCTAssertEqual(values.first?.key, "url") + } + + // MARK: - Hostname Tag + + func testExtractsHostnameTag() { + let xml = "replica-02.dc1.internal" + let values = parser.parseValues(from: xml) + XCTAssertEqual(values.count, 1) + XCTAssertEqual(values.first?.key, "hostname") + } + + func testExtractsInterserverHttpHostTag() { + let xml = "ch-node3.internal.corp.net" + let values = parser.parseValues(from: xml) + XCTAssertEqual(values.count, 1) + XCTAssertEqual(values.first?.key, "interserver_http_host") + } + + // MARK: - Parser Registration + + func testXMLParserRegistered() { + XCTAssertNotNil(parserForExtension("xml")) + } + + func testXMLParserWithCustomConfig() { + var config = PastewatchConfig.defaultConfig + config.xmlSensitiveTags = ["custom_tag"] + let parser = parserForExtension("xml", config: config) + XCTAssertNotNil(parser) + + // Should parse both default and custom tags + let xml = """ + secret + custom_value + """ + let values = parser?.parseValues(from: xml) ?? [] + XCTAssertEqual(values.count, 2) + } +} diff --git a/Tests/PastewatchTests/YAMLParserTests.swift b/Tests/PastewatchTests/YAMLParserTests.swift new file mode 100644 index 0000000..d5081b6 --- /dev/null +++ b/Tests/PastewatchTests/YAMLParserTests.swift @@ -0,0 +1,49 @@ +import XCTest +@testable import PastewatchCore + +final class YAMLParserTests: XCTestCase { + let parser = YAMLValueParser() + + func testParsesKeyValuePairs() { + let content = "host: db.internal.net\nport: 5432\n" + let values = parser.parseValues(from: content) + XCTAssertEqual(values.count, 2) + XCTAssertEqual(values[0].value, "db.internal.net") + XCTAssertEqual(values[0].key, "host") + XCTAssertEqual(values[0].line, 1) + } + + func testSkipsComments() { + let content = "# comment\nkey: value\n" + let values = parser.parseValues(from: content) + XCTAssertEqual(values.count, 1) + } + + func testHandlesQuotedValues() { + let content = "name: \"quoted value\"\nother: 'single'\n" + let values = parser.parseValues(from: content) + XCTAssertEqual(values.count, 2) + XCTAssertEqual(values[0].value, "quoted value") + XCTAssertEqual(values[1].value, "single") + } + + func testSkipsDocumentMarkers() { + let content = "---\nkey: value\n...\n" + let values = parser.parseValues(from: content) + XCTAssertEqual(values.count, 1) + } + + func testHandlesListItems() { + let content = "hosts:\n - name: host1.corp.net\n - name: host2.corp.net\n" + let values = parser.parseValues(from: content) + let hostValues = values.filter { $0.key == "name" } + XCTAssertEqual(hostValues.count, 2) + } + + func testSkipsBlockMappingKeys() { + let content = "database:\n host: db.corp.net\n" + let values = parser.parseValues(from: content) + XCTAssertEqual(values.count, 1) + XCTAssertEqual(values[0].value, "db.corp.net") + } +} diff --git a/docs/CLAUDE-SNIPPET.md b/docs/CLAUDE-SNIPPET.md new file mode 100644 index 0000000..ab2ad13 --- /dev/null +++ b/docs/CLAUDE-SNIPPET.md @@ -0,0 +1,46 @@ +# Pastewatch — Claude Code Integration Snippet + +Copy the block below into your project's `CLAUDE.md` (or `~/.claude/CLAUDE.md` for global). + +--- + +```markdown +## Pastewatch — Secret Redaction + +This project uses [pastewatch](https://github.com/ppiankov/pastewatch) to prevent secrets from reaching AI APIs. + +### When hooks block you + +If a pastewatch hook blocks `Read`, `Write`, or `Edit`: +- Use `pastewatch_read_file` MCP tool to read the file (secrets are replaced with `__PW_TYPE_N__` placeholders) +- Use `pastewatch_write_file` MCP tool to write back (placeholders are resolved to real values locally) +- NEVER use `cat`, `head`, `python3`, or any Bash workaround to read blocked files +- NEVER delete or rename files to bypass the hook + +### Round-trip workflow + +1. Read: `pastewatch_read_file` → you see `password: __PW_CREDENTIAL_1__` +2. Edit: work with placeholders in your code changes +3. Write: `pastewatch_write_file` → real values restored on disk + +### Rules + +- Same secret always maps to the same placeholder within a session +- Placeholders are in-memory only — they die when the MCP server stops +- If you see `__PW_` prefixed values, those are redacted secrets — do not treat them as real values +- When writing files that contain `__PW_` placeholders, always use `pastewatch_write_file` — native Write will be blocked +``` + +--- + +## Setup + +If pastewatch is not yet configured for this project: + +```bash +brew install ppiankov/tap/pastewatch +pastewatch-cli setup claude-code # auto-configures MCP + hooks +pastewatch-cli init # creates .pastewatch.json +# or for banking/enterprise: +pastewatch-cli init --profile banking # JDBC, medium severity, internal host detection +``` diff --git a/docs/SKILL.md b/docs/SKILL.md new file mode 100644 index 0000000..81e3f66 --- /dev/null +++ b/docs/SKILL.md @@ -0,0 +1,543 @@ +--- +name: pastewatch +description: "Sensitive data scanner — deterministic detection and obfuscation for text content" +user-invocable: false +metadata: {"requires":{"bins":["pastewatch-cli"]}} +--- + +# pastewatch-cli + +Sensitive data scanner. Deterministic regex-based detection and obfuscation for text content. No ML, no network calls. + +**For AI agent setup with secret redaction**, see [agent-integration.md](agent-integration.md). + +## Install + +```bash +brew install ppiankov/tap/pastewatch +``` + +## Configuration + +### Config files + +| File | Location | Purpose | Created By | +|------|----------|---------|------------| +| `.pastewatch.json` | Project root (`$CWD`) | Project-level config | `pastewatch-cli init` | +| `~/.config/pastewatch/config.json` | Home | User-level defaults | Manual / GUI app | +| `.pastewatch-allow` | Project root | Value allowlist (one per line, `#` comments) | `pastewatch-cli init` | +| `.pastewatchignore` | Project root | Path exclusion patterns (glob, like `.gitignore`) | Manual | +| `.pastewatch-baseline.json` | Project root | Known findings baseline (SHA256 fingerprints) | `pastewatch-cli baseline create` | + +### Resolution cascade + +CWD `.pastewatch.json` > `~/.config/pastewatch/config.json` > built-in defaults. + +### `.pastewatch.json` schema + +```json +{ + "enabled": true, + "enabledTypes": ["Email", "AWS Key", "API Key", "Credential", "High Entropy"], + "showNotifications": true, + "soundEnabled": false, + "allowedValues": ["test@example.com", "192.168.1.1"], + "allowedPatterns": ["sk_test_.*", "EXAMPLE_.*"], + "customRules": [ + {"name": "Internal ID", "pattern": "MYCO-[0-9]{6}", "severity": "medium"} + ], + "safeHosts": [".internal.company.com", "safe.dev.local"], + "sensitiveHosts": [".local", "secrets.vault.internal.net"], + "sensitiveIPPrefixes": ["172.16.", "10."], + "mcpMinSeverity": "high", + "placeholderPrefix": "REDACTED_PLACEHOLDER_" +} +``` + +`sensitiveHosts` supports 2-segment hostnames (e.g., `.local` catches `nas.local`) as well as 3+ segment FQDNs. + +### Field reference + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `enabled` | bool | `true` | Enable/disable scanning globally | +| `enabledTypes` | string[] | All except High Entropy | Which detection types to activate (see Detection Types) | +| `showNotifications` | bool | `true` | System notifications on GUI obfuscation | +| `soundEnabled` | bool | `false` | Sound on GUI obfuscation | +| `allowedValues` | string[] | `[]` | Exact values to suppress (merged with `.pastewatch-allow` file) | +| `allowedPatterns` | string[] | `[]` | Regex patterns for value suppression (wrapped in `^(...)$`) | +| `customRules` | object[] | `[]` | Additional regex detection patterns with name, pattern, optional severity | +| `safeHosts` | string[] | `[]` | Hostnames excluded from detection. Leading dot = suffix match (`.co.com` matches `x.co.com`) | +| `sensitiveHosts` | string[] | `[]` | Hostnames always detected — overrides built-in and user safe hosts. Also catches 2-segment hosts (e.g., `.local` → `nas.local`) | +| `sensitiveIPPrefixes` | string[] | `[]` | IP prefixes always detected — overrides built-in IP exclude list (e.g., `172.16.`, `10.`) | +| `mcpMinSeverity` | string | `"high"` | Default minimum severity for MCP `pastewatch_read_file` redaction (critical, high, medium, low) | +| `placeholderPrefix` | string? | `null` | Custom prefix for MCP placeholders. When set, produces `{prefix}001` instead of `__PW_TYPE_N__` | + +## Commands + +### pastewatch-cli scan + +Scan text for sensitive data patterns. Reports findings or outputs obfuscated text. + +**Flags:** +- `--format json` — output as JSON (default: text). Also supports `sarif`, `markdown` +- `--file path` — file to scan (reads from stdin if omitted) +- `--dir path` — directory to scan recursively (mutually exclusive with --file) +- `--check` — check mode: exit code only, no output modification +- `--allowlist path` — path to allowlist file (one value per line, # comments) +- `--rules path` — path to custom rules JSON file +- `--baseline path` — path to baseline file (only report new findings) +- `--stdin-filename name` — filename hint for format-aware stdin parsing (e.g., `.env`, `config.yml`) +- `--fail-on-severity level` — minimum severity for non-zero exit (critical, high, medium, low) +- `--output path` — write report to file instead of stdout +- `--ignore pattern` — glob pattern to ignore (can be repeated) +- `--bail` — stop at first finding and exit immediately (fast gate check) +- `--git-diff` — scan git diff changes (staged by default) +- `--unstaged` — include unstaged changes (requires --git-diff) + +**Flag constraints:** +- `--file` and `--dir` are mutually exclusive +- `--git-diff` is mutually exclusive with `--file` and `--dir` +- `--unstaged` requires `--git-diff` +- `--bail` is only valid with `--dir` or `--git-diff` + +**JSON output:** +```json +{ + "count": 2, + "findings": [ + {"type": "Email", "value": "admin@internal.corp.net", "severity": "high"}, + {"type": "AWS Key", "value": "AKIA****************", "severity": "critical"} + ], + "obfuscated": "contact ****@**** about key ****" +} +``` + +In check mode (`--check`), the `obfuscated` field is null. + +**Exit codes:** +- 0: clean — no sensitive data found (or below fail-on-severity threshold) +- 2: error (file/directory not found, invalid arguments) +- 6: findings detected at or above severity threshold + +### pastewatch-cli fix + +Externalize secrets to environment variables. Scans for secrets, generates `.env` file entries, and replaces hardcoded values with language-aware env var references. + +**Flags:** +- `--dir path` — directory to fix (required) +- `--dry-run` — show fix plan without applying changes +- `--min-severity level` — minimum severity to fix (default: high) +- `--env-file path` — path for generated .env file (default: `.env`) +- `--ignore pattern` — glob pattern to ignore (can be repeated) + +**Language-aware replacement:** + +| Language | Replacement | +|----------|-------------| +| Python (.py) | `os.environ["KEY"]` | +| JavaScript/TypeScript (.js/.ts) | `process.env.KEY` | +| Go (.go) | `os.Getenv("KEY")` | +| Ruby (.rb) | `ENV["KEY"]` | +| Swift (.swift) | `ProcessInfo.processInfo.environment["KEY"]` | +| Shell (.sh) | `${KEY}` | + +**Exit codes:** +- 0: success +- 2: directory not found + +### pastewatch-cli inventory + +Generate a structured inventory of all detected secrets in a directory. + +**Flags:** +- `--dir path` — directory to scan (required) +- `--format text|json|markdown|csv` — output format (default: text) +- `--output path` — write report to file instead of stdout +- `--compare path` — compare with previous inventory JSON file (show added/removed) +- `--allowlist path` — path to allowlist file +- `--rules path` — path to custom rules JSON file +- `--ignore pattern` — glob pattern to ignore (can be repeated) + +**Exit codes:** +- 0: success +- 2: directory not found, compare file not found, invalid inventory file, or write error + +### pastewatch-cli guard + +Check if a shell command would access files containing secrets. Used as a PreToolUse hook for Bash tool. + +**Arguments:** +- `command` — shell command to check (required) + +**Flags:** +- `--fail-on-severity level` — minimum severity to block (default: high) +- `--json` — machine-readable JSON output +- `--quiet` — exit code only, no output + +**Exit codes:** +- 0: command allowed (no secrets in referenced files) +- 1: command blocked (file contains secrets) + +### pastewatch-cli guard-read + +Check if a file contains secrets before allowing Read tool access. Used as a PreToolUse hook for Read tool. + +**Arguments:** +- `file-path` — file path to check (required) + +**Flags:** +- `--fail-on-severity level` — minimum severity to block (default: high) + +**Exit codes:** +- 0: file allowed (clean or below threshold) +- 2: file blocked (contains secrets at or above threshold) + +### pastewatch-cli guard-write + +Check if a file contains secrets before allowing Write tool access. Used as a PreToolUse hook for Write/Edit tools. + +**Arguments:** +- `file-path` — file path to check (required) + +**Flags:** +- `--fail-on-severity level` — minimum severity to block (default: high) + +**Exit codes:** +- 0: file allowed (clean or below threshold) +- 2: file blocked (contains secrets at or above threshold) + +### pastewatch-cli init + +Generate project configuration files (`.pastewatch.json` and `.pastewatch-allow`). + +**Flags:** +- `--force` — overwrite existing files +- `--profile ` — configuration profile. Available: `banking` + +**Profiles:** +- `banking` — JDBC URL detection, `mcpMinSeverity: medium`, RFC 1918 IP prefixes, example service account and internal URI rules. Replace `YOURBANK` in `sensitiveHosts` with your domain. + +**Exit codes:** +- 0: success +- 2: files already exist (without --force), or unknown profile + +### pastewatch-cli baseline create + +Create a baseline of known findings from a directory scan. + +**Flags:** +- `--dir path` — directory to scan (required) +- `--output path` / `-o path` — output file path (default: `.pastewatch-baseline.json`) + +**Exit codes:** +- 0: success +- 2: directory not found + +### pastewatch-cli hook install + +Install a pre-commit hook that scans staged changes. + +**Flags:** +- `--append` — append to existing hook instead of failing + +**Exit codes:** +- 0: success +- 2: hook already exists, or not a git repository + +### pastewatch-cli hook uninstall + +Remove pastewatch section from pre-commit hook. + +**Exit codes:** +- 0: success +- 2: no hook found, or hook has no pastewatch section + +### pastewatch-cli explain + +Show detection type details with severity and examples. + +**Arguments:** +- `[type-name]` — type name to explain (omit to list all). Case-insensitive. + +**Exit codes:** +- 0: success +- 2: unknown type name + +### pastewatch-cli config check + +Validate configuration files (JSON syntax, type names, regex patterns, severity strings). + +**Flags:** +- `--file path` — path to config file (uses resolved config if omitted) + +**Exit codes:** +- 0: valid +- 2: validation errors + +### pastewatch-cli doctor + +Check installation health and show active configuration. Reports CLI version, PATH status, config resolution, hook installation, MCP server processes, and Homebrew formula version. + +**Flags:** +- `--json` — output results as JSON + +**Checks performed:** + +| Check | What it reports | +|-------|----------------| +| cli | Version and binary path | +| path | Whether pastewatch-cli is on PATH | +| config | Which config file is active (project > user > defaults), validation warnings | +| hook | Pre-commit hook installation status | +| allowlist | `.pastewatch-allow` file presence | +| ignore | `.pastewatchignore` file presence | +| baseline | `.pastewatch-baseline.json` file presence | +| mcp | Running MCP server processes and PIDs | +| homebrew | Formula version vs installed version vs current CLI version | + +**Exit codes:** +- 0: success + +### pastewatch-cli version + +Print version information. + +**Exit codes:** +- 0: success + +### pastewatch-cli mcp + +Run as MCP server (JSON-RPC 2.0 over stdio). + +**Flags:** +- `--audit-log path` — write audit log of all tool calls to file (append mode). Logs timestamps, tool names, file paths, redaction counts — never logs secret values. +- `--min-severity level` — default minimum severity for redacted reads (critical, high, medium, low). Overrides config `mcpMinSeverity`. Per-request `min_severity` parameter still takes highest precedence. + +**MCP config (Claude Desktop, Cursor, etc.):** +```json +{ + "mcpServers": { + "pastewatch": { + "command": "pastewatch-cli", + "args": ["mcp", "--audit-log", "/tmp/pastewatch-audit.log"] + } + } +} +``` + +**Per-agent severity — use `--min-severity` to set different thresholds per agent:** +```json +{ + "mcpServers": { + "pastewatch": { + "command": "pastewatch-cli", + "args": ["mcp", "--audit-log", "/tmp/pastewatch-audit.log", "--min-severity", "medium"] + } + } +} +``` + +**Precedence:** per-request `min_severity` > `--min-severity` flag > config `mcpMinSeverity` > default (`high`). + +**Tools provided:** + +#### pastewatch_scan +Scan a text string for sensitive data. + +Input: +```json +{"text": "string (required) — text content to scan"} +``` + +Response: content array with summary text and JSON findings array. Each finding has `type`, `value`, `line`. + +#### pastewatch_scan_file +Scan a single file. Supports format-aware parsing for .env, .json, .yml, .yaml, .properties, .cfg, .ini. + +Input: +```json +{"path": "string (required) — absolute file path to scan"} +``` + +Response: same as pastewatch_scan, with `file` field on each finding. + +#### pastewatch_scan_dir +Scan a directory recursively. Skips .git, node_modules, vendor, build directories. + +Input: +```json +{"path": "string (required) — absolute directory path to scan"} +``` + +Response: summary of files scanned and findings count, plus JSON findings array with `type`, `value`, `file`, `line`. + +#### pastewatch_scan_diff +Scan git diff for secrets in changed lines only. Staged changes by default. + +Input: +```json +{ + "path": "string (required) — git repository path", + "unstaged": "boolean (optional, default: false) — include unstaged changes" +} +``` + +Response: findings in added lines only, with accurate file paths and line numbers. + +#### pastewatch_read_file +Read a file with sensitive values replaced by `__PW_TYPE_N__` placeholders. Secrets stay local — only placeholders reach the AI. Same value always maps to same placeholder across all files in a session. + +Input: +```json +{ + "path": "string (required) — absolute file path to read", + "min_severity": "string (optional, default: high) — minimum severity to redact" +} +``` + +Response: JSON object with `content` (redacted text), `redactions` (manifest of type/severity/line/placeholder), `clean` (boolean). + +**Severity thresholds:** `high` (default) redacts credentials, API keys, DB connections, emails, phones. IPs, hostnames, and file paths are `medium` — pass through unless `min_severity: "medium"` is set. UUIDs and high entropy are `low`. + +#### pastewatch_write_file +Write file contents, resolving `__PW_TYPE_N__` placeholders back to original values locally. Pair with pastewatch_read_file for safe round-trip editing. + +Input: +```json +{"path": "string (required) — file path to write", "content": "string (required) — file content with placeholders"} +``` + +Response: JSON object with `written`, `path`, `resolved` (count), `unresolved` (count), and `unresolvedPlaceholders` (if any). + +#### pastewatch_check_output +Check if text contains raw sensitive data. Use before writing or returning code to verify no secrets leak. + +Input: +```json +{"text": "string (required) — text to check"} +``` + +Response: JSON object with `clean` (boolean) and `findings` array (type/severity/line). + +#### pastewatch_inventory +Generate a secret posture report for a directory. + +Input: +```json +{ + "path": "string (required) — directory path to scan", + "format": "string (optional, default: json) — output format: text, json, markdown, csv", + "compare": "string (optional) — path to previous inventory JSON for delta comparison" +} +``` + +Response: inventory report with severity breakdown, hot spots, type groups, and entries. + +**Redacted read/write workflow:** + +1. Agent calls `pastewatch_read_file` → gets content with `__PW_EMAIL_1__` style placeholders +2. Agent processes code with placeholders (secrets never reach the API) +3. Agent calls `pastewatch_write_file` → MCP server resolves placeholders locally, writes real values to disk + +## Detection types + +| Type | What it matches | Severity | +|------|----------------|----------| +| Email | Email addresses | high | +| Phone | International and local phone numbers (10+ digits) | high | +| IP | IPv4 addresses (excludes localhost, broadcast) | medium | +| AWS Key | AKIA/ABIA/ACCA/ASIA key IDs and 40-char secret keys | critical | +| API Key | Generic keys (sk-, pk-, api_, token_), GitHub tokens, Stripe keys | critical | +| UUID | Standard UUID v4 format | low | +| DB Connection | PostgreSQL, MySQL, MongoDB, Redis, ClickHouse connection strings | critical | +| SSH Key | RSA, DSA, EC, OPENSSH private key headers | critical | +| JWT | Three-segment base64url tokens (eyJ...) | critical | +| Card | Visa, Mastercard, Amex, Discover with Luhn validation | critical | +| File Path | Infrastructure paths (/home, /var, /etc, /root, /usr, /tmp, /opt) | medium | +| Hostname | Fully qualified domain names (excludes safe public hosts) | medium | +| Credential | Key-value pairs with password, secret, token, api_key keywords | critical | +| Slack Webhook | Slack incoming webhook URLs | critical | +| Discord Webhook | Discord webhook URLs | critical | +| Azure Connection | Azure Storage connection strings with AccountKey | critical | +| GCP Service Account | GCP service account JSON key files | critical | +| OpenAI Key | OpenAI API keys (sk-proj-, sk-svcacct-) | critical | +| Anthropic Key | Anthropic API keys (sk-ant-api03-, sk-ant-admin01-, sk-ant-oat01-) | critical | +| Hugging Face Token | Hugging Face access tokens (hf_) | critical | +| Groq Key | Groq API keys (gsk_) | critical | +| npm Token | npm access tokens (npm_) | critical | +| PyPI Token | PyPI API tokens (pypi-) | critical | +| RubyGems Token | RubyGems API keys (rubygems_) | critical | +| GitLab Token | GitLab personal access tokens (glpat-) | critical | +| Telegram Bot Token | Telegram bot tokens (numeric ID + AA hash) | critical | +| SendGrid Key | SendGrid API keys (SG. prefix) | critical | +| Shopify Token | Shopify access tokens (shpat_, shpca_, shppa_) | critical | +| DigitalOcean Token | DigitalOcean tokens (dop_v1_, doo_v1_) | critical | +| Perplexity Key | Perplexity AI API keys (pplx- prefix) | critical | +| JDBC URL | JDBC connection URLs (Oracle, DB2, MySQL, PostgreSQL, SQL Server, AS/400) | critical | +| XML Credential | Credentials in XML tags (password, secret, access_key) | critical | +| XML Username | Usernames in XML tags (user, quota_key) | high | +| XML Hostname | Hostnames in XML tags (host, hostname) | medium | +| High Entropy | High-entropy strings (Shannon > 4.0, 20+ chars, mixed classes) — opt-in only | low | + +SARIF maps: critical/high → `error`, medium → `warning`, low → `note`. + +## Inline allowlist + +Add `pastewatch:allow` anywhere on a line to suppress findings on that line: + +``` +API_KEY=test_12345 # pastewatch:allow +password = "dev" // pastewatch:allow +``` + +## What this does NOT do + +- Does not use ML or probabilistic scoring — deterministic regex matching only +- Does not make network calls — all detection is local, offline +- Does not rotate or revoke secrets — only detects and externalizes them +- Does not modify the clipboard in CLI mode — reads input, writes output +- Does not maintain persistent state — every invocation is stateless (MCP placeholder mapping is session-scoped, in-memory only) +- Does not block or intercept — reports findings, does not prevent actions (guard subcommands are for agent hooks) +- Does not execute or evaluate scanned content + +## Parsing examples + +```bash +# Check if text is clean +echo "hello world" | pastewatch-cli scan --check && echo "clean" || echo "found sensitive data" + +# Get finding count +pastewatch-cli scan --file config.yml --format json | jq '.count' + +# List finding types +pastewatch-cli scan --file .env --format json | jq -r '.findings[].type' + +# Get obfuscated output +cat debug.log | pastewatch-cli scan --format json | jq -r '.obfuscated' + +# Scan directory, check mode +pastewatch-cli scan --dir . --check --format json | jq '.count' + +# Fast gate check (bail at first finding) +pastewatch-cli scan --dir . --check --bail --fail-on-severity high + +# Scan staged git changes +pastewatch-cli scan --git-diff --check + +# Generate secret inventory +pastewatch-cli inventory --dir . --format json --output inventory.json + +# Compare inventories +pastewatch-cli inventory --dir . --compare inventory.json + +# Check installation health +pastewatch-cli doctor + +# Get doctor output as JSON +pastewatch-cli doctor --json +``` + +--- + +This tool follows the [Agent-Native CLI Convention](https://ancc.dev). Validate with: `ancc validate .` diff --git a/docs/agent-integration.md b/docs/agent-integration.md new file mode 100644 index 0000000..9065630 --- /dev/null +++ b/docs/agent-integration.md @@ -0,0 +1,375 @@ +# Agent Integration Reference + +Consolidated reference for integrating pastewatch with AI coding agents. Covers enforcement levels, hook configuration, MCP setup, and anti-workaround measures. + +**Install first:** +```bash +brew install ppiankov/tap/pastewatch +``` + +--- + +## 1. Enforcement Matrix + +| Agent | Read/Write/Edit | Bash Commands | Enforcement | Hook Format | +|-------|----------------|---------------|-------------|-------------| +| Claude Code | Structural | Structural | PreToolUse hooks | exit 0 = allow, exit 2 = block; stdout → agent | +| Cline | Structural | Structural | PreToolUse hooks | JSON `{"cancel": true}` response | +| Cursor | Advisory | Advisory | Instructions only | No hook support yet | +| OpenCode | Advisory | Advisory | Instructions only | No hook support yet | +| Codex CLI | Advisory | Advisory | Instructions only | No hook support yet | +| Qwen Code | Advisory | Advisory | Instructions only | No hook support yet | + +**Structural** means the agent cannot bypass the check - hooks run outside the agent's control. **Advisory** means the agent is told to use pastewatch tools but is not forced. + +--- + +## 2. MCP Setup Per Agent + +All agents use the same MCP server command. Only the config file location and format differ. + +### Claude Code + +Register via CLI: +```bash +claude mcp add pastewatch -- pastewatch-cli mcp --audit-log /tmp/pastewatch-audit.log +``` + +Or add to `~/.claude/settings.json` (global) or `.claude/settings.json` (per-project): +```json +{ + "mcpServers": { + "pastewatch": { + "command": "pastewatch-cli", + "args": ["mcp", "--audit-log", "/tmp/pastewatch-audit.log"] + } + } +} +``` + +### Claude Desktop + +Config: `~/Library/Application Support/Claude/claude_desktop_config.json` +```json +{ + "mcpServers": { + "pastewatch": { + "command": "pastewatch-cli", + "args": ["mcp", "--audit-log", "/tmp/pastewatch-audit.log"] + } + } +} +``` + +### Cline (VS Code) + +Config: `~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json` +```json +{ + "mcpServers": { + "pastewatch": { + "command": "pastewatch-cli", + "args": ["mcp", "--audit-log", "/tmp/pastewatch-audit.log"], + "disabled": false + } + } +} +``` + +Requires pastewatch >= 0.7.1 (fixes JSON-RPC notification response). + +### Cursor + +Config: `~/.cursor/mcp.json` +```json +{ + "mcpServers": { + "pastewatch": { + "command": "pastewatch-cli", + "args": ["mcp", "--audit-log", "/tmp/pastewatch-audit.log"] + } + } +} +``` + +### OpenCode + +Config: `~/.config/opencode/opencode.json` +```json +{ + "mcp": { + "pastewatch": { + "type": "local", + "command": ["pastewatch-cli", "mcp", "--audit-log", "/tmp/pastewatch-audit.log"], + "enabled": true + } + } +} +``` + +### Codex CLI + +Config: `~/.codex/config.toml` +```toml +[mcp_servers.pastewatch] +command = "pastewatch-cli" +args = ["mcp", "--audit-log", "/tmp/pastewatch-audit.log"] +enabled = true +``` + +### Qwen Code + +Config: `~/.qwen/settings.json` +```json +{ + "mcpServers": { + "pastewatch": { + "command": "pastewatch-cli", + "args": ["mcp", "--audit-log", "/tmp/pastewatch-audit.log"] + } + } +} +``` + +--- + +## 3. Hook Configuration + +Hooks make enforcement structural. Without hooks, MCP tools are opt-in and agents can bypass redaction using native Read/Write or Bash commands. + +### Claude Code + +Add to `~/.claude/settings.json`: + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Read", + "hooks": [ + { "type": "command", "command": "pastewatch-cli guard-read \"$FILE_PATH\"" } + ] + }, + { + "matcher": "Write|Edit", + "hooks": [ + { "type": "command", "command": "pastewatch-cli guard-write \"$FILE_PATH\"" } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { "type": "command", "command": "pastewatch-cli guard \"$COMMAND\"" } + ] + } + ] + } +} +``` + +**Hook protocol:** +- `guard-read` / `guard-write`: exit 0 = allow, exit 2 = block. Stdout is shown to the agent ("You MUST use pastewatch_read_file instead"). Stderr is shown to the human. +- `guard`: exit 0 = allow, exit 1 = block. Parses shell commands to extract file paths, scans those files. + +### Cline + +Cline uses a JSON cancel protocol instead of exit codes. The hook script must output `{"cancel": true, "message": "..."}` to block. + +Add guard logic to your `hooks/PreToolUse` script that wraps pastewatch-cli and translates exit codes to JSON responses. + +### Cursor + +Cursor supports `preToolUse` hooks with the same exit code protocol as Claude Code (exit 0 = allow, exit 2 = block). Configure in `.cursor/hooks/pretooluse.sh`. + +--- + +## 4. Anti-Workaround Enforcement + +Agents are creative about bypassing restrictions. These measures close known bypass paths. + +### Bash command guard + +The `guard` subcommand parses shell commands and scans referenced files: + +```bash +pastewatch-cli guard "cat .env" +# BLOCKED: .env contains 3 secret(s) (2 critical, 1 high) + +pastewatch-cli guard "python3 -c 'open(\".env\").read()'" +# BLOCKED: detects python3/ruby/node scripting workarounds +``` + +Commands detected: +- **File readers:** `cat`, `head`, `tail`, `less`, `more`, `bat`, `tac`, `nl` +- **File writers:** `sed`, `awk` +- **File searchers:** `grep`, `egrep`, `fgrep`, `rg`, `ag` +- **Source commands:** `source`, `.` +- **Scripting interpreters:** `python3`, `python`, `ruby`, `node`, `perl`, `php`, `lua` +- **File transfer tools:** `scp`, `rsync`, `ssh`, `ssh-keygen` +- **Infrastructure tools:** `ansible-playbook`, `ansible`, `ansible-vault`, `terraform`, `docker-compose`, `docker`, `kubectl`, `helm` +- **Database CLIs:** `psql`, `mysql`, `mongosh`, `mongo`, `redis-cli`, `sqlite3` - extracts file flags and scans inline connection strings/passwords +- **Pipe chains:** `|`, `&&`, `||`, `;` - each segment is parsed independently +- **Redirect operators:** `>`, `>>`, `2>`, `&>`, `<` - stripped from commands; input redirects (`<`) scanned as file access +- **Subshells:** `$(...)` and backticks - inner commands extracted and scanned + +### Read/Write/Edit guard + +The `guard-read` and `guard-write` subcommands use format-aware scanning (`.env`, `.json`, `.yml` parsers) for accurate detection: + +```bash +pastewatch-cli guard-read /path/to/.env +# Exit 2 + message: "You MUST use pastewatch_read_file instead of Read" + +pastewatch-cli guard-write /path/to/config.yml +# Exit 2 + message: "You MUST use pastewatch_write_file instead of Write" +``` + +### Directive language in hook messages + +Hook stdout messages use imperative language that agents follow: +- "You **MUST** use pastewatch_read_file instead of Read for files containing secrets." +- "You **MUST** use pastewatch_write_file instead of Write for files containing secrets." + +### Agent instruction files + +For advisory-only agents (no hooks), add explicit rules to agent config files: + +```markdown +## Pastewatch - Secret Redaction - CRITICAL + +When the pastewatch-guard hook blocks Read/Write/Edit, you MUST use the pastewatch MCP tool: +- Read blocked → use `pastewatch_read_file` +- Write blocked → use `pastewatch_write_file` +- Edit blocked → use `pastewatch_read_file` then `pastewatch_write_file` + +NEVER work around a pastewatch block: +- NEVER use python3/ruby/perl/node to read or write files that pastewatch blocked +- NEVER use cat/head/tail/sed/awk via Bash to read files that pastewatch blocked +- NEVER delete a file to bypass the guard, then recreate it +- NEVER copy file contents through environment variables or temp files to avoid scanning +``` + +Add to `CLAUDE.md`, `AGENTS.md`, `.clinerules`, or equivalent per-agent instruction file. + +--- + +## 5. PW_GUARD Escape Hatch + +`PW_GUARD=0` disables all guard subcommands. When set, `guard`, `guard-read`, `guard-write`, and `scan --check` exit 0 immediately. + +```bash +export PW_GUARD=0 # disable for current shell session +unset PW_GUARD # re-enable (or restart shell) +``` + +**Agent-proof by design:** The guard runs in the hook's process, not the agent's shell. The agent cannot set `PW_GUARD=0` - only the human can, before starting the agent session. + +**When to use:** +- Editing detection rule source files (DetectionRules.swift) +- Working with test fixtures that contain intentional secret-like patterns +- Debugging hook behavior + +--- + +## 6. Upstream Hook Support Requests + +Agents without hook support can only use advisory enforcement (instruction files). When these agents add hook support, they upgrade to structural enforcement. + +| Agent | Issue | Status | Assignee | +|-------|-------|--------|----------| +| OpenCode | [anomalyco/opencode#12472](https://github.com/anomalyco/opencode/issues/12472) | Open | thdxr | +| Qwen Code | [QwenLM/qwen-code#268](https://github.com/QwenLM/qwen-code/issues/268) | P2 | Mingholy | +| Codex CLI | No issue filed | - | - | +| Cursor | Supported | Available | - | + +When hooks land for OpenCode and Qwen Code, add `guard-read`/`guard-write`/`guard` hooks following the Claude Code pattern. + +--- + +## 7. Configuration Files + +Pastewatch config resolves from the agent's working directory. When an agent runs `pastewatch-cli scan` or uses MCP tools, it picks up the project's `.pastewatch.json` automatically. + +### Config files + +| File | Location | Purpose | Created By | +|------|----------|---------|------------| +| `.pastewatch.json` | Project root (`$CWD`) | Project-level config | `pastewatch-cli init` | +| `~/.config/pastewatch/config.json` | Home | User-level defaults | Manual / GUI app | +| `.pastewatch-allow` | Project root | Value allowlist (one per line) | `pastewatch-cli init` | +| `.pastewatchignore` | Project root | Path exclusion patterns (glob) | Manual | +| `.pastewatch-baseline.json` | Project root | Known findings baseline | `pastewatch-cli baseline create` | + +Resolution cascade: CWD `.pastewatch.json` > `~/.config/pastewatch/config.json` > built-in defaults. + +### `.pastewatch.json` schema + +```json +{ + "enabled": true, + "enabledTypes": ["Email", "AWS Key", "API Key", "Credential", "High Entropy"], + "showNotifications": true, + "soundEnabled": false, + "allowedValues": ["test@example.com"], + "allowedPatterns": ["sk_test_.*", "EXAMPLE_.*"], + "customRules": [ + {"name": "Internal ID", "pattern": "MYCO-[0-9]{6}", "severity": "medium"} + ], + "safeHosts": [".internal.company.com"], + "sensitiveHosts": [".local", "secrets.vault.internal.net"], + "sensitiveIPPrefixes": ["172.16.", "10."], + "mcpMinSeverity": "high" +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `enabled` | bool | Enable/disable scanning globally | +| `enabledTypes` | string[] | Detection types to activate (default: all except High Entropy) | +| `allowedValues` | string[] | Exact values to suppress (merged with `.pastewatch-allow`) | +| `allowedPatterns` | string[] | Regex patterns for value suppression (wrapped in `^(...)$`) | +| `customRules` | object[] | Additional regex patterns with name, pattern, optional severity | +| `safeHosts` | string[] | Hostnames excluded from detection (leading dot = suffix match) | +| `sensitiveHosts` | string[] | Hostnames always detected (overrides safe hosts, catches 2-segment hosts like `.local`) | +| `sensitiveIPPrefixes` | string[] | IP prefixes always detected (overrides built-in exclude list) | +| `mcpMinSeverity` | string | Default severity for MCP redacted reads (default: `high`) | + +For the full command reference, see [SKILL.md](SKILL.md). + +--- + +## Verification + +After configuring MCP and hooks for any agent: + +1. Start the agent - pastewatch should appear in the MCP/tools panel with 6 tools +2. Create a test file with a fake secret (e.g., `password=hunter2`) +3. Ask the agent to read the test file with native Read - hook should block and redirect to `pastewatch_read_file` +4. Ask the agent to use `pastewatch_read_file` - verify the secret is replaced with a `__PW_...__` placeholder +5. Check `/tmp/pastewatch-audit.log` for the read entry + +### Troubleshooting + +- **"command not found"**: ensure `pastewatch-cli` is on PATH (`brew install ppiankov/tap/pastewatch`) +- **JSON validation errors in Cline**: upgrade to pastewatch >= 0.7.1 +- **No tools visible**: restart the agent after config change; verify config file JSON syntax +- **Audit log empty**: check the `--audit-log` path is writable +- **Hook not blocking**: verify hook is registered for the correct tool matcher; check `PW_GUARD` is not set to `0` + +--- + +## Available MCP Tools + +Once configured, the agent has access to: + +| Tool | Purpose | +|------|---------| +| `pastewatch_scan` | Scan file or directory for secrets | +| `pastewatch_read_file` | Read file with secrets replaced by `__PW_...__` placeholders | +| `pastewatch_write_file` | Write file, resolving placeholders back to real values locally | +| `pastewatch_check_output` | Verify text contains no raw secrets before returning | +| `pastewatch_scan_diff` | Scan git diff for secrets in changed lines | +| `pastewatch_inventory` | Generate secret posture report for a directory | + +Secrets never leave your machine. Only placeholders reach the AI provider's API. diff --git a/docs/agent-safety.md b/docs/agent-safety.md new file mode 100644 index 0000000..babb3e2 --- /dev/null +++ b/docs/agent-safety.md @@ -0,0 +1,393 @@ +# Agent Safety Guide + +How to use AI coding agents (Claude Code, Cline, Cursor) without leaking secrets to cloud APIs. + +--- + +## The Problem + +When an AI agent reads your files, the contents are sent to the provider's API. If those files contain API keys, connection strings, or credentials, the secrets leave your machine. + +``` +Your machine Cloud API +┌──────────────┐ file contents ┌──────────────┐ +│ source code │ ──────────────────► │ AI provider │ +│ with secrets│ (secrets leak) │ │ +└──────────────┘ └──────────────┘ +``` + +This is not hypothetical. Config files, .env files, and hardcoded credentials are routinely sent to AI APIs during normal agent workflows. + +--- + +## Layer 0: API Proxy (Network Boundary) + +The strongest layer. Every API call from every process — including agent subprocesses you don't control — passes through a local proxy that scans and redacts secrets before they leave your machine. + +```bash +# Start the proxy +pastewatch-cli proxy --audit-log /tmp/pw-proxy.log + +# Start your agent through it +ANTHROPIC_BASE_URL=http://127.0.0.1:8443 claude +``` + +**Why this matters:** Agent subprocesses (subagents, background workers, parallel tasks) bypass tool-level protections like hooks and MCP. They make direct API calls with raw file contents. The proxy is the only layer that catches everything — it operates at the network boundary, not the tool boundary. + +**Corporate environments** with mandatory company proxies: + +```bash +# Pastewatch sits between the agent and the corporate proxy +pastewatch-cli proxy --port 3456 --forward-proxy http://127.0.0.1:3457 +``` + +The agent connects to pastewatch as if it were the company proxy. Pastewatch scans, redacts, and forwards to the real proxy. Transparent to both the agent and the corporate network. + +--- + +## Layer 1: Don't Put Secrets in Code + +The most effective defense. If secrets aren't in files, they can't leak. + +- Use `.env` files (gitignored) for local development +- Use vault references or config templates with placeholders in committed code +- Use environment variables in CI/CD pipelines + +**Verify before starting an agent session:** +```bash +pastewatch-cli scan --dir . --check +``` + +Fix findings first. Move hardcoded secrets to environment variables or vault references. + +--- + +## Layer 2: Pastewatch MCP Redacted Read/Write + +For files that must contain secrets (legacy code, config files being migrated), use pastewatch MCP tools. The MCP server sits between the agent and your files: + +``` +Your machine (local) +┌─────────────────────────────────────────────┐ +│ │ +│ pastewatch MCP server │ +│ ┌───────────────────────────────────────┐ │ +│ │ read_file: │ │ +│ │ file (real secrets) │ │ +│ │ → scan → store mapping in RAM │ │ +│ │ → return content with │ │ +│ │ __PW_EMAIL_1__ placeholders ──┼──┼──► AI API (sees only placeholders) +│ │ │ │ +│ │ write_file: │ │ +│ │ content with placeholders ◄─┼──┼─── AI API returns code +│ │ → resolve from RAM mapping │ │ (contains placeholders) +│ │ → write real values to disk │ │ +│ └───────────────────────────────────────┘ │ +│ │ +│ Secrets never leave this box. │ +└─────────────────────────────────────────────┘ +``` + +### Setup + +Install pastewatch: +```bash +brew install ppiankov/tap/pastewatch +``` + +For per-agent registration instructions (Claude Code, Claude Desktop, Cline, Cursor, OpenCode, Codex CLI, Qwen Code), see [agent-setup.md](agent-setup.md). + +### How the agent uses it + +Once configured, the agent has access to these MCP tools: + +| Tool | Purpose | +|------|---------| +| `pastewatch_read_file` | Read file with secrets replaced by `__PW_EMAIL_1__` placeholders | +| `pastewatch_write_file` | Write file, resolving placeholders back to real values locally | +| `pastewatch_check_output` | Verify text contains no raw secrets before returning | + +**Round-trip workflow:** +1. Agent calls `pastewatch_read_file` for sensitive files +2. Gets back content with `__PW_CREDENTIAL_1__`, `__PW_AWS_KEY_1__` etc. +3. API processes code - only sees placeholders, never real secrets +4. Agent calls `pastewatch_write_file` - MCP server resolves placeholders on-device +5. Written file contains real values - code stays functional + +**What the agent sees (sent to API):** +```yaml +database: + host: db.internal.corp + port: 5432 + password: __PW_CREDENTIAL_1__ + api_key: __PW_AWS_KEY_1__ +``` + +**What gets written to disk:** +```yaml +database: + host: db.internal.corp + port: 5432 + password: (original secret restored) + api_key: (original key restored) +``` + +### Severity threshold (`min_severity`) + +`pastewatch_read_file` accepts an optional `min_severity` parameter (default: `high`). Only findings at or above the threshold are redacted - everything below passes through unchanged. + +**What gets redacted at each threshold:** + +| `min_severity` | Redacted | Passes through | +|---|---|---| +| `critical` | AWS keys, API keys, DB connections, SSH keys, JWTs, cards, webhooks | Credentials, emails, phones, IPs, hostnames, UUIDs | +| `high` (default) | All critical + credentials, emails, phones | IPs, hostnames, file paths, UUIDs | +| `medium` | All high + IPs, hostnames, file paths | UUIDs, high entropy | +| `low` | Everything | Nothing | + +**Example: `.env` file read with default `min_severity: "high"`** + +Original file contains AWS keys, a database URL, an API token, an IP address, and an internal hostname. After redaction with the default `high` threshold: + +```bash +# What the agent sees (sent to API) +AWS_ACCESS_KEY_ID=__PW_AWS_KEY_1__ # critical - redacted +DATABASE_URL=__PW_DB_CONNECTION_1__ # critical - redacted +API_TOKEN=__PW_OPENAI_KEY_1__ # critical - redacted +ANSIBLE_HOST=172.16.161.206 # medium - passes through +INTERNAL_SERVER=keeper2.ipa.local # medium - passes through +``` + +The IP and hostname pass through because they are `medium` severity - below the default `high` threshold. To redact them too, pass `min_severity: "medium"`: + +```bash +# With min_severity: "medium" - IPs and hostnames also redacted +AWS_ACCESS_KEY_ID=__PW_AWS_KEY_1__ +DATABASE_URL=__PW_DB_CONNECTION_1__ +API_TOKEN=__PW_OPENAI_KEY_1__ +ANSIBLE_HOST=__PW_IP_1__ # medium - now redacted +INTERNAL_SERVER=__PW_HOSTNAME_1__ # medium - now redacted +``` + +The default `high` threshold is intentional - it protects credentials (the highest-damage leak vector) while keeping infrastructure identifiers readable so the agent can reason about architecture. + +### Per-agent severity + +Different agents may need different thresholds. Use `--min-severity` on the MCP server command to set each agent's default independently: + +```json +{ + "mcpServers": { + "pastewatch": { + "command": "pastewatch-cli", + "args": ["mcp", "--audit-log", "/tmp/pastewatch-audit.log", "--min-severity", "medium"] + } + } +} +``` + +**Precedence chain:** per-request `min_severity` parameter > `--min-severity` CLI flag > `mcpMinSeverity` config field > default (`high`). + +This means you can run Claude Code at `high` (default) and Cline at `medium` - each agent's MCP registration controls its own threshold, and any agent can still override per-request when needed. + +### Audit logging + +Enable audit logging to get proof of what the MCP server did during a session: + +```json +{ + "mcpServers": { + "pastewatch": { + "command": "pastewatch-cli", + "args": ["mcp", "--audit-log", "/tmp/pastewatch-audit.log"] + } + } +} +``` + +The log records every tool call with timestamps - what files were read, how many secrets were redacted, what types were found, how many placeholders were resolved on write. Secret values are never logged. + +``` +2026-02-25T00:30:12Z READ /app/config.yml redacted=3 [AWS Key, Credential, Email] +2026-02-25T00:30:15Z WRITE /app/config.yml resolved=3 unresolved=0 +2026-02-25T00:30:18Z CHECK (inline) clean=true +``` + +### Important notes + +- The MCP tools are **opt-in** - the agent must choose to use them +- Built-in Read/Write tools still bypass pastewatch unless hooks enforce it (see Layer 2b) +- Mappings live in server process memory only - die when MCP server stops +- Same file re-read returns the same placeholders (idempotent within session) + +--- + +## Layer 2b: Enforce MCP Usage via Hooks + +MCP tools are opt-in - agents can still use native Read/Write and `cat .env` via Bash, bypassing redaction entirely. Hooks make enforcement structural. + +### Bash command guard + +The `guard` subcommand intercepts shell commands before execution: + +```bash +pastewatch-cli guard "cat .env" +# BLOCKED: .env contains 3 secret(s) (2 critical, 1 high) +# Use pastewatch MCP tools for files with secrets. + +pastewatch-cli guard "echo hello" +# exit 0 (safe) +``` + +It parses shell commands (`cat`, `head`, `tail`, `sed`, `awk`, `grep`, `source`), extracts file arguments, and scans those files for secrets. Unknown commands pass through (exit 0). + +Integrate with agent Bash hooks to block commands automatically. See [agent-setup.md](agent-setup.md) for hook configuration per agent. + +### `PW_GUARD=0` - escape hatch + +`PW_GUARD=0` is a native feature of pastewatch-cli. When set, `guard` and `scan --check` exit 0 immediately - every hook that calls pastewatch-cli gets the bypass for free. + +```bash +export PW_GUARD=0 # disable for current shell session +unset PW_GUARD # re-enable +``` + +This is **agent-proof by design**: the guard runs in the hook's process, not the agent's shell. The agent cannot set `PW_GUARD=0` to bypass it - only the human can, before starting the agent session. The bypass requires human action outside the agent's control. + +Use it when editing detection rules, working with test fixtures, or handling files with intentional secret-like patterns. + +--- + +## Layer 3: Restrict Agent File Access + +Limit which files the agent can read. Fewer files exposed = fewer secrets at risk. + +**Claude Code** - `.claude/settings.json`: +```json +{ + "permissions": { + "deny": [ + "Read(path:**/.env*)", + "Read(path:**/credentials*)", + "Read(path:**/secrets/**)" + ] + } +} +``` + +**General principle:** Keep secrets in dedicated directories or files with predictable names. Restrict agent access to those paths. + +--- + +## Layer 4: Pre-commit Safety Net + +Catches secrets before they're committed - including secrets an agent may have written into code. + +```bash +# Install pastewatch pre-commit hook +pastewatch-cli hook install + +# Or use pre-commit.com framework +# .pre-commit-config.yaml +repos: + - repo: https://github.com/ppiankov/pastewatch + rev: v0.24.1 + hooks: + - id: pastewatch +``` + +This catches cases where: +- An agent writes a new secret into code +- An agent copies a secret from one file to another +- Config changes accidentally expose credentials + +--- + +## Layer 5: Pre-session Scanning + +Before starting an agent session on a project: + +```bash +# Full scan +pastewatch-cli scan --dir . --check + +# Only fail on critical (API keys, credentials, connection strings) +pastewatch-cli scan --dir . --check --fail-on-severity critical + +# Detailed report +pastewatch-cli scan --dir . --format markdown --output /tmp/scan-report.md +``` + +Fix findings before the agent reads them. The cheapest secret to protect is the one that's not in a file. + +### Shell alias for automatic pre-flight + +Add to `.zshrc` or `.bashrc` to run a health check before every agent session: + +```bash +alias claude='pastewatch-cli doctor --json >/dev/null 2>&1 && command claude' +``` + +This verifies pastewatch is installed, MCP is configured, and config is valid before the agent starts. If doctor fails, the session won't launch — fail-closed instead of fail-open. + +--- + +## Layer 6: Baseline for Existing Projects + +For projects with known historical secrets that can't be cleaned up immediately: + +```bash +# Create baseline of current findings +pastewatch-cli baseline create --dir . --output .pastewatch-baseline.json + +# Only flag new secrets (ignore baseline) +pastewatch-cli scan --dir . --baseline .pastewatch-baseline.json --check +``` + +This lets you adopt agent safety incrementally without blocking work on legacy codebases. + +--- + +## Summary + +| Layer | What it does | Effort | +|-------|-------------|--------| +| 1. No secrets in code | Eliminate the source | High (best ROI) | +| 2. MCP redacted read/write | Secrets stay local during agent sessions | Low (configure once) | +| 2b. Enforce via hooks | Block native Read/Write/Bash when secrets present | Low (configure once) | +| 3. Restrict file access | Limit agent's blast radius | Low | +| 4. Pre-commit hook | Catch secrets before commit | Low (one-time setup) | +| 5. Pre-session scan | Find secrets before agent reads them | Per-session | +| 6. Baseline | Gradual cleanup of legacy codebases | Per-project | + +Layers are additive. Use as many as your threat model requires. Layer 2 (MCP redacted read/write) is the most impactful for active agent workflows. + +--- + +## What Pastewatch Covers - and What It Doesn't + +Pastewatch protects **credentials** - the highest-damage leak vector. If a key leaks, attackers get immediate access to infrastructure. Pastewatch prevents this structurally. + +**What pastewatch protects (secrets never leave your machine):** + +| Category | Examples | +|----------|----------| +| API keys | AWS, OpenAI, Anthropic, Stripe, GitHub tokens, etc. | +| Database credentials | Connection strings, passwords in config files | +| SSH/TLS keys | Private key headers | +| Identity data | Emails, phone numbers, IPs | +| Session tokens | JWTs, bearer tokens | +| Platform credentials | Slack/Discord webhooks, Azure/GCP keys | + +**What pastewatch does NOT protect:** + +| Category | Why | +|----------|-----| +| Prompt content | Your questions and instructions still reach the API | +| Code structure | Architecture, patterns, business logic - visible to the provider | +| Conversation context | What you're building, for whom, why | +| Non-secret data | Domain names, file paths, comments, variable names | + +Pastewatch protects your **keys**. For protecting your **ideas**, you need a local model (Ollama, llama.cpp). For protecting your **commands**, you need a local proxy (intercepting before they reach the API). + +Think of it as: secrets are the highest-consequence leak - a leaked API key has immediate, measurable damage. Pastewatch eliminates that risk. The other risks (prompt content, business logic) are real but require different tools. diff --git a/docs/agent-setup.md b/docs/agent-setup.md new file mode 100644 index 0000000..c2f4bcc --- /dev/null +++ b/docs/agent-setup.md @@ -0,0 +1,239 @@ +# Agent MCP Setup + +Per-agent instructions for registering pastewatch MCP server. Once configured, the agent has 6 tools for scanning, redacted read/write, and output checking. Secrets stay on your machine - only placeholders reach the AI provider. + +**Install first:** +```bash +brew install ppiankov/tap/pastewatch +``` + +--- + +## Claude Code + +Register via CLI: +```bash +claude mcp add pastewatch -- pastewatch-cli mcp --audit-log /tmp/pastewatch-audit.log +``` + +Or add to `~/.claude/settings.json` (global) or `.claude/settings.json` (per-project): +```json +{ + "mcpServers": { + "pastewatch": { + "command": "pastewatch-cli", + "args": ["mcp", "--audit-log", "/tmp/pastewatch-audit.log"] + } + } +} +``` + +Toggle: `/mcp` in-session or `claude mcp remove pastewatch` + +--- + +## Claude Desktop + +Config: `~/Library/Application Support/Claude/claude_desktop_config.json` + +```json +{ + "mcpServers": { + "pastewatch": { + "command": "pastewatch-cli", + "args": ["mcp", "--audit-log", "/tmp/pastewatch-audit.log"] + } + } +} +``` + +Toggle: remove the `pastewatch` key and restart. + +--- + +## Cline (VS Code) + +Config: `~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json` + +```json +{ + "mcpServers": { + "pastewatch": { + "command": "pastewatch-cli", + "args": ["mcp", "--audit-log", "/tmp/pastewatch-audit.log"], + "disabled": false + } + } +} +``` + +Toggle: set `"disabled": true` or use Cline UI MCP panel. + +**Note:** Requires pastewatch >= 0.7.1. Earlier versions respond to JSON-RPC notifications, which Cline's validator rejects. + +--- + +## Cursor + +Config: `~/.cursor/mcp.json` + +```json +{ + "mcpServers": { + "pastewatch": { + "command": "pastewatch-cli", + "args": ["mcp", "--audit-log", "/tmp/pastewatch-audit.log"] + } + } +} +``` + +--- + +## OpenCode + +Config: `~/.config/opencode/opencode.json` + +```json +{ + "mcp": { + "pastewatch": { + "type": "local", + "command": ["pastewatch-cli", "mcp", "--audit-log", "/tmp/pastewatch-audit.log"], + "enabled": true + } + } +} +``` + +Toggle: set `"enabled": false` + +--- + +## Codex CLI + +Config: `~/.codex/config.toml` + +```toml +[mcp_servers.pastewatch] +command = "pastewatch-cli" +args = ["mcp", "--audit-log", "/tmp/pastewatch-audit.log"] +enabled = true +``` + +Toggle: set `enabled = false` + +--- + +## Qwen Code + +Config: `~/.qwen/settings.json` + +```json +{ + "mcpServers": { + "pastewatch": { + "command": "pastewatch-cli", + "args": ["mcp", "--audit-log", "/tmp/pastewatch-audit.log"] + } + } +} +``` + +Toggle: remove the `mcpServers.pastewatch` key. + +--- + +## Verification + +For all agents: + +1. Start the agent - pastewatch should appear in the MCP/tools panel with 6 tools +2. Create a test file with a fake secret (e.g., `password=hunter2`) +3. Ask the agent to use `pastewatch_read_file` on the test file +4. Verify the secret is replaced with a `__PW_...__` placeholder +5. Check `/tmp/pastewatch-audit.log` for the read entry + +## Troubleshooting + +- **"command not found"**: ensure `pastewatch-cli` is on PATH (`brew install ppiankov/tap/pastewatch`) +- **JSON validation errors in Cline**: upgrade to pastewatch >= 0.7.1 (fixes JSON-RPC notification response) +- **No tools visible**: restart the agent after config change; verify config file JSON syntax +- **Audit log empty**: check the `--audit-log` path is writable; the flag is opt-in + +--- + +## Enforcing Pastewatch via Hooks + +MCP tools are opt-in - agents can still use native Read/Write and bypass redaction. To enforce pastewatch usage structurally, add hooks that block native file access when secrets are detected. + +### PreToolUse hook for Read/Write/Edit + +Intercepts native file tools and blocks them when the target file contains secrets at high+ severity. The agent gets a message telling it to use pastewatch MCP tools instead. + +**Claude Code** (`~/.claude/settings.json`): +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Read|Write|Edit", + "hooks": [ + { "type": "command", "command": "~/.claude/hooks/pastewatch-guard.sh" } + ] + } + ] + } +} +``` + +**Cline**: add the guard logic to your `hooks/PreToolUse` script (Cline uses JSON `{"cancel": true}` protocol instead of exit codes). + +Hook logic: +1. Extract file path from tool input +2. Skip binary files and `.git/` internals +3. For Write: check content for `__PW_...__` placeholders - block if found (must use `pastewatch_write_file`) +4. Run `pastewatch-cli scan --check --fail-on-severity high --file ` +5. Exit 6 from scan = secrets found → block with redirect message +6. Exit 0 = clean → allow native tool + +### Bash command guard + +Agents can also bypass pastewatch by running `cat .env` or `sed -i config.yml` via shell. The `guard` subcommand catches this: + +```bash +# In your Bash PreToolUse hook: +if command -v pastewatch-cli &>/dev/null; then + guard_output=$(pastewatch-cli guard "$command" 2>&1) + if [ $? -ne 0 ]; then + echo "$guard_output" + exit 2 # block + fi +fi +``` + +The `guard` subcommand extracts file paths from shell commands (`cat`, `head`, `tail`, `sed`, `grep`, etc.), scans them for secrets, and returns allow/block. + +### Escape hatch + +Structural guards need a bypass for legitimate cases - editing detection rules, testing patterns, or working with files that contain intentional secret-like strings. + +`PW_GUARD=0` is a native feature of pastewatch-cli. When set, `guard` and `scan --check` exit 0 immediately - every hook that calls pastewatch-cli gets the bypass for free, no per-hook logic needed. + +```bash +export PW_GUARD=0 # disable for current shell session +unset PW_GUARD # re-enable (or restart shell) +``` + +This is agent-proof by design: the guard runs in the hook's process, not the agent's shell. The agent cannot set `PW_GUARD=0` to bypass it - only the human can, before starting the agent session. The bypass requires human action outside the agent's control. + +### Enforcement matrix + +| Agent | Read/Write/Edit | Bash commands | Mechanism | +|-------|----------------|---------------|-----------| +| Claude Code | Structural | Structural | PreToolUse hooks | +| Cline | Structural | Structural | PreToolUse hooks | +| Cursor | Advisory | Advisory | Instructions only | +| OpenCode | Advisory | Advisory | Instructions only (no hook support yet) | +| Codex CLI | Advisory | Advisory | Instructions only (no hook support yet) | +| Qwen Code | Advisory | Advisory | Instructions only (no hook support yet) | diff --git a/docs/design-baseline.md b/docs/design-baseline.md deleted file mode 100644 index 2c43d17..0000000 --- a/docs/design-baseline.md +++ /dev/null @@ -1,141 +0,0 @@ -# Design Baseline - -## Core Principle - -**Principiis obsta** — resist the beginnings. - -Pastewatch applies this principle to clipboard data transmission. The irreversible boundary is the moment sensitive data leaves the user's control and enters an AI system. - -Once pasted: -- Data cannot be reliably recalled -- Data may be logged, stored, or used for training -- The user loses all control over its fate - -Pastewatch refuses that transition. - ---- - -## Why Before Paste - -Downstream controls fail because they operate too late: - -| Approach | Problem | -|----------|---------| -| Browser extension | Only sees web apps, blind to native | -| LLM proxy | Data already transmitted | -| DLP system | Blocks after detection, user already exposed intent | -| Prompt sanitizer | Runs after submission | - -The clipboard is the last moment of user control. After ⌘V, the data belongs to someone else. - -Pastewatch intervenes at the only point that matters: before the irreversible action. - ---- - -## Design Priorities - -### 1. Determinism over Convenience - -All detection uses regex and heuristics. No ML. No probabilistic scoring. - -Why: -- Users must be able to predict behavior -- No "confidence levels" to second-guess -- No model drift or version differences -- Explainable: "This matched pattern X" - -### 2. False Negatives over False Positives - -When uncertain, Pastewatch does nothing. - -Why: -- Breaking user workflow is worse than missing edge cases -- Users will disable tools that cry wolf -- Conservative tools build trust -- Missing one secret < breaking every paste - -### 3. Silence over Notification - -Default behavior is invisible. Feedback only when action taken. - -Why: -- Attention is expensive -- "Nothing happened" is the success state -- Users should forget the tool exists -- Notification fatigue kills adoption - -### 4. Local over Connected - -All processing happens on-device. No network calls. - -Why: -- Clipboard data is inherently sensitive -- Cloud processing defeats the purpose -- Latency would break UX -- No dependency on external services - ---- - -## Irreversible Boundaries - -Pastewatch guards a specific boundary: clipboard → external system. - -This boundary is irreversible because: -- AI systems may log all inputs -- Data may enter training pipelines -- No "undo" exists for transmitted data -- Legal/compliance implications are immediate - -The tool does not guard: -- Clipboard → local app (user's domain) -- File system operations -- Network traffic generally -- Anything after paste succeeds - -Scope is narrow by design. Broader scope = weaker guarantees. - ---- - -## Refusal as Feature - -Pastewatch refuses to: -- Detect ambiguous patterns -- Store clipboard history -- Phone home -- Guess user intent -- Block paste entirely - -These refusals are features, not limitations. - -A tool that does less, reliably, is more valuable than a tool that attempts everything and fails unpredictably. - ---- - -## Related Projects - -Pastewatch is part of a family applying **Principiis obsta** at different boundaries: - -| Project | Boundary | Intervention Point | -|---------|----------|-------------------| -| **Chainwatch** | AI agent execution | Before tool calls | -| **Pastewatch** | Data transmission | Before paste | -| **VaultSpectre** | Secrets lifecycle | Before exposure | -| **Relay** | Human connection | Before isolation compounds | - -Same principle. Different surfaces. Consistent philosophy. - ---- - -## Success Criteria - -Pastewatch succeeds when: -- Users forget it exists -- Sensitive data never reaches AI chats -- No false positives disrupt workflow -- The system returns to rest after each paste - -Pastewatch fails when: -- Users disable it due to annoyance -- Complex configuration is required -- Detection becomes unpredictable -- The tool demands attention diff --git a/docs/examples/README.md b/docs/examples/README.md new file mode 100644 index 0000000..6fd7dd6 --- /dev/null +++ b/docs/examples/README.md @@ -0,0 +1,287 @@ +# Agent Integration Examples + +Ready-to-use configurations for enforcing pastewatch secret redaction with AI coding agents. + +**Install first:** +```bash +brew install ppiankov/tap/pastewatch +``` + +--- + +## Quick Setup + +| Agent | Hook Support | Config Files | +|-------|-------------|--------------| +| [Claude Code](#claude-code) | Structural (PreToolUse) | [settings.json](claude-code/settings.json) + [pastewatch-guard.sh](claude-code/pastewatch-guard.sh) | +| [Cline](#cline) | Structural (PreToolUse) | [mcp-config.json](cline/mcp-config.json) + [pastewatch-hook.sh](cline/pastewatch-hook.sh) | +| [Cursor](#cursor) | MCP only (no hooks) | [mcp.json](cursor/mcp.json) | + +**Structural** = hooks block native file access and redirect to MCP tools. The agent cannot bypass the check. +**MCP only** = agent can use MCP tools but is not forced to. Add instructions to `.cursorrules` to request MCP usage. + +--- + +## Claude Code + +### 1. Register MCP server + +```bash +claude mcp add pastewatch -- pastewatch-cli mcp --audit-log /tmp/pastewatch-audit.log +``` + +### 2. Install the hook + +```bash +cp docs/examples/claude-code/pastewatch-guard.sh ~/.claude/hooks/pastewatch-guard.sh +chmod +x ~/.claude/hooks/pastewatch-guard.sh +``` + +### 3. Add hook to settings + +Merge into `~/.claude/settings.json` (global) or `.claude/settings.json` (per-project): + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Read|Write|Edit", + "hooks": [ + { + "type": "command", + "command": "~/.claude/hooks/pastewatch-guard.sh" + } + ] + } + ] + } +} +``` + +See [claude-code/settings.json](claude-code/settings.json) for the complete example with MCP registration included. + +### How it works + +1. Claude tries native Read/Write/Edit on a file with secrets +2. Hook scans the file, finds secrets at or above the severity threshold +3. Hook blocks (exit 2) with a message: "You MUST use pastewatch_read_file instead" +4. Claude automatically retries with the MCP tool - secrets are redacted + +--- + +## Cline + +### 1. Register MCP server + +Add to Cline MCP settings (`~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json`): + +```json +{ + "mcpServers": { + "pastewatch": { + "command": "pastewatch-cli", + "args": ["mcp", "--audit-log", "/tmp/pastewatch-audit.log"], + "disabled": false + } + } +} +``` + +See [cline/mcp-config.json](cline/mcp-config.json). + +### 2. Install the hook + +Copy [cline/pastewatch-hook.sh](cline/pastewatch-hook.sh) to your Cline hooks directory and make executable. + +The hook handles both bash commands (`execute_command` via `pastewatch-cli guard`) and file operations (`read_file`/`write_to_file`/`edit_file` via file scanning). + +### 3. Reduce approval noise + +With hooks enabled, each file with secrets triggers two steps: hook blocks native read, then Cline falls back to the MCP tool. To reduce manual approvals: + +- **Auto-approve MCP tools**: In Cline settings, auto-approve `pastewatch_read_file` and `pastewatch_write_file`. These are safety tools (not destructive) - auto-approving them means reads go through redaction automatically without confirmation. +- **Auto-approve read-only tools**: If your Cline version supports it, enable auto-approve for MCP read operations to cut approvals in half. + +The hook block itself shows as a notification - Cline should automatically retry with the MCP tool without asking for approval on the block. + +--- + +## Cursor + +### 1. Register MCP server + +Add to `~/.cursor/mcp.json`: + +```json +{ + "mcpServers": { + "pastewatch": { + "command": "pastewatch-cli", + "args": ["mcp", "--audit-log", "/tmp/pastewatch-audit.log"] + } + } +} +``` + +See [cursor/mcp.json](cursor/mcp.json). + +### 2. Add instructions (advisory) + +Cursor does not have structural hook enforcement. Add to `.cursorrules` in your project root: + +``` +When reading or writing files that may contain secrets (API keys, credentials, +connection strings, .env files, config files), use pastewatch MCP tools: +- Use pastewatch_read_file instead of native read +- Use pastewatch_write_file instead of native write +- Never output raw secret values +``` + +--- + +## Severity Alignment + +**This is the most common setup mistake.** Two settings must match: + +| Setting | Where | Controls | +|---------|-------|----------| +| Hook `--fail-on-severity` | `pastewatch-guard.sh` / `pastewatch-hook.sh` | Which files trigger a native Read/Write block | +| MCP `--min-severity` | MCP server args in agent config | Which findings get redacted in `pastewatch_read_file` | + +If they don't match, secrets leak: + +### Misaligned (broken) + +Hook blocks at `medium`, but MCP redacts at `high` (default): + +``` +Hook: --fail-on-severity medium → blocks native read (IP is medium) +MCP: --min-severity high → pastewatch_read_file passes IP through (not high enough) + +Result: IP address "172.16.161.206" leaks to the API via MCP read +``` + +### Aligned (correct) + +Both at the same severity: + +``` +Hook: --fail-on-severity medium → blocks native read +MCP: --min-severity medium → pastewatch_read_file redacts IP as __PW_IP_1__ + +Result: IP never leaves your machine +``` + +### How to set severity + +**Default (`high`)** - protects credentials, API keys, emails, phones. IPs and hostnames pass through. Good for most workflows. + +**Medium** - also protects IPs, hostnames, file paths. Use when infrastructure identifiers are sensitive. + +For hooks, set the `PW_SEVERITY` environment variable or edit the script directly: +```bash +export PW_SEVERITY=medium # before starting the agent +``` + +For MCP, add `--min-severity` to the server args: +```json +"args": ["mcp", "--audit-log", "/tmp/pastewatch-audit.log", "--min-severity", "medium"] +``` + +You can also set the default in `.pastewatch.json`: +```json +{ + "mcpMinSeverity": "medium" +} +``` + +**Precedence:** per-request `min_severity` parameter > `--min-severity` CLI flag > config `mcpMinSeverity` > default (`high`). + +--- + +## Multi-Agent Setup + +Different agents can use different severity thresholds. Each agent's MCP registration is independent: + +**Claude Code** - default severity (`high`): +```json +{ + "mcpServers": { + "pastewatch": { + "command": "pastewatch-cli", + "args": ["mcp", "--audit-log", "/tmp/pastewatch-audit.log"] + } + } +} +``` + +**Cline** - stricter (`medium`), also catches IPs and hostnames: +```json +{ + "mcpServers": { + "pastewatch": { + "command": "pastewatch-cli", + "args": ["mcp", "--audit-log", "/tmp/pastewatch-audit.log", "--min-severity", "medium"], + "disabled": false + } + } +} +``` + +Remember: if Cline's MCP uses `--min-severity medium`, the Cline hook must also use `PW_SEVERITY=medium` (or `--fail-on-severity medium` in the script). + +--- + +## What Gets Redacted + +| `min_severity` | Redacted | Passes Through | +|---|---|---| +| `critical` | AWS keys, API keys, DB connections, SSH keys, JWTs, webhooks | Credentials, emails, phones, IPs, hostnames | +| `high` (default) | All critical + credentials, emails, phones | IPs, hostnames, file paths, UUIDs | +| `medium` | All high + IPs, hostnames, file paths | UUIDs, high entropy | +| `low` | Everything | Nothing | + +--- + +## Verification + +After setting up any agent, verify the integration works end-to-end: + +```bash +# 1. Create a test file with a fake secret +echo 'DB_PASSWORD=SuperSecret123!' > /tmp/pastewatch-test.env + +# 2. Start your agent and ask it to read the file: +# "Read the file /tmp/pastewatch-test.env" +# +# Expected: hook blocks native read, agent falls back to pastewatch_read_file, +# you see __PW_CREDENTIAL_1__ instead of the password. + +# 3. Check the audit log +cat /tmp/pastewatch-audit.log +# Should show: READ /tmp/pastewatch-test.env redacted=1 [Credential] + +# 4. Ask the agent to write the file back: +# "Write the contents back to /tmp/pastewatch-test-copy.env" +# +# Expected: agent uses pastewatch_write_file, placeholders resolve to real values. + +# 5. Verify real values were restored +cat /tmp/pastewatch-test-copy.env +# Should contain: DB_PASSWORD=SuperSecret123! + +# 6. Clean up +rm /tmp/pastewatch-test.env /tmp/pastewatch-test-copy.env +``` + +### Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| Hook doesn't block | PW_GUARD=0 is set | `unset PW_GUARD` | +| Hook doesn't block | MCP not running | Restart agent, check MCP tools panel | +| MCP reads but doesn't redact | Severity too high for finding type | Lower `--min-severity` to match | +| IPs/hostnames leak through MCP | Severity misalignment | Set both hook and MCP to same level | +| "command not found" | pastewatch-cli not on PATH | `brew install ppiankov/tap/pastewatch` | +| Cline asks for too many approvals | MCP tools not auto-approved | Auto-approve pastewatch MCP tools in Cline settings | diff --git a/docs/examples/claude-code/pastewatch-guard.sh b/docs/examples/claude-code/pastewatch-guard.sh new file mode 100644 index 0000000..c2ec1aa --- /dev/null +++ b/docs/examples/claude-code/pastewatch-guard.sh @@ -0,0 +1,102 @@ +#!/bin/bash +# Claude Code PreToolUse hook: enforce pastewatch MCP tools for files with secrets +# +# Protocol: exit 0 = allow, exit 2 = block +# stdout = message shown to Claude +# stderr = notification shown to the human +# +# Install: +# 1. Copy to ~/.claude/hooks/pastewatch-guard.sh +# 2. chmod +x ~/.claude/hooks/pastewatch-guard.sh +# 3. Add the hook matcher to ~/.claude/settings.json (see settings.json in this directory) +# +# Configuration: +# PW_SEVERITY - severity threshold for blocking (default: "high") +# Must match the --min-severity flag on your MCP server registration. +# Example: PW_SEVERITY=medium for stricter enforcement. + +PW_SEVERITY="${PW_SEVERITY:-high}" + +# --- Session check --- +# Only enforce if pastewatch MCP is running in THIS Claude Code session. +# Hooks and MCP are both children of the same Claude process. +# If MCP is not running, allow native tools (fail-open). +_claude_pid=${PPID:-0} +pgrep -P "$_claude_pid" -qf 'pastewatch-cli mcp' 2>/dev/null || exit 0 + +input=$(cat) +tool=$(echo "$input" | jq -r '.tool_name // empty') +file_path=$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.filePath // empty') + +# Only check Read, Write, Edit tools +case "$tool" in + Read|Write|Edit) ;; + *) exit 0 ;; +esac + +# Skip if no file path +[ -z "$file_path" ] && exit 0 + +# Skip binary/non-text files +case "$file_path" in + *.png|*.jpg|*.jpeg|*.gif|*.ico|*.bmp|*.webp|*.svg) exit 0 ;; + *.woff|*.woff2|*.ttf|*.eot|*.otf) exit 0 ;; + *.zip|*.tar|*.gz|*.bz2|*.xz|*.7z|*.rar) exit 0 ;; + *.exe|*.dll|*.so|*.dylib|*.a|*.o|*.class|*.pyc) exit 0 ;; + *.pdf|*.doc|*.docx|*.xls|*.xlsx) exit 0 ;; + *.mp3|*.mp4|*.wav|*.avi|*.mov|*.mkv) exit 0 ;; + *.sqlite|*.db) exit 0 ;; +esac + +# Skip .git internals +echo "$file_path" | grep -qF '/.git/' && exit 0 + +# --- PATH PROTECTION: Block access to sensitive directories --- +case "$file_path" in + "$HOME/.openclaw/"*|"$HOME/.openclaw") + echo "BLOCKED: $file_path is inside a protected directory. Use pastewatch MCP tools instead." + echo "Blocked: protected directory - use pastewatch MCP tools" >&2 + exit 2 ;; +esac + +# --- WRITE: Check for pastewatch placeholders in content --- +if [ "$tool" = "Write" ]; then + content=$(echo "$input" | jq -r '.tool_input.content // empty') + if [ -n "$content" ] && echo "$content" | grep -qE '__PW_[A-Z][A-Z0-9_]*_[0-9]+__'; then + echo "BLOCKED: content contains pastewatch placeholders (__PW_...__). Use pastewatch_write_file to resolve placeholders back to real values." + echo "Blocked: pastewatch placeholders in Write" >&2 + exit 2 + fi +fi + +# --- READ/WRITE/EDIT: Scan the file on disk for secrets --- +# Only scan existing files (new files won't have secrets on disk) +[ ! -f "$file_path" ] && exit 0 + +# Fail-open if pastewatch-cli not installed +command -v pastewatch-cli &>/dev/null || exit 0 + +# Scan file at configured severity threshold +pastewatch-cli scan --check --fail-on-severity "$PW_SEVERITY" --file "$file_path" >/dev/null 2>&1 +scan_exit=$? + +if [ "$scan_exit" -eq 6 ]; then + case "$tool" in + Read) + echo "BLOCKED: $file_path contains secrets. You MUST use pastewatch_read_file instead. Do NOT use python3, cat, or any workaround." + echo "Blocked: secrets in Read target - use pastewatch_read_file" >&2 + ;; + Write) + echo "BLOCKED: $file_path contains secrets on disk. You MUST use pastewatch_write_file instead. Do NOT delete the file or use python3 as a workaround." + echo "Blocked: secrets in Write target - use pastewatch_write_file" >&2 + ;; + Edit) + echo "BLOCKED: $file_path contains secrets. You MUST use pastewatch_read_file to read, then pastewatch_write_file to write back. Do NOT use any workaround." + echo "Blocked: secrets in Edit target - use pastewatch_read_file + pastewatch_write_file" >&2 + ;; + esac + exit 2 +fi + +# Clean file or scan error - allow native tool +exit 0 diff --git a/docs/examples/claude-code/settings.json b/docs/examples/claude-code/settings.json new file mode 100644 index 0000000..e8d88a6 --- /dev/null +++ b/docs/examples/claude-code/settings.json @@ -0,0 +1,21 @@ +{ + "mcpServers": { + "pastewatch": { + "command": "pastewatch-cli", + "args": ["mcp", "--audit-log", "/tmp/pastewatch-audit.log"] + } + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Read|Write|Edit", + "hooks": [ + { + "type": "command", + "command": "~/.claude/hooks/pastewatch-guard.sh" + } + ] + } + ] + } +} diff --git a/docs/examples/cline/mcp-config.json b/docs/examples/cline/mcp-config.json new file mode 100644 index 0000000..63a3e19 --- /dev/null +++ b/docs/examples/cline/mcp-config.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "pastewatch": { + "command": "pastewatch-cli", + "args": ["mcp", "--audit-log", "/tmp/pastewatch-audit.log"], + "disabled": false + } + } +} diff --git a/docs/examples/cline/pastewatch-hook.sh b/docs/examples/cline/pastewatch-hook.sh new file mode 100644 index 0000000..74b540d --- /dev/null +++ b/docs/examples/cline/pastewatch-hook.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# Cline PreToolUse hook: enforce pastewatch MCP tools for files with secrets +# +# Protocol: JSON stdout +# {"cancel": true, "errorMessage": "..."} = block +# {"cancel": false} = allow +# Non-zero exit without valid JSON = allow (fail-open) +# +# Install: +# 1. Save as your Cline PreToolUse hook (location depends on Cline version) +# 2. chmod +x pastewatch-hook.sh +# 3. Register MCP server in Cline settings (see mcp-config.json in this directory) +# +# Configuration: +# PW_SEVERITY - severity threshold for blocking (default: "high") +# Must match the --min-severity flag on your MCP server registration. +# Example: PW_SEVERITY=medium for stricter enforcement. +# +# Note: This is the pastewatch-only hook. If you have other PreToolUse guards +# (bash safety, doc blocking, etc.), combine them into a single hook script. + +PW_SEVERITY="${PW_SEVERITY:-high}" + +block() { + local msg="$1" + printf '{"cancel": true, "errorMessage": "%s"}\n' "$msg" + exit 0 +} + +input=$(cat) +tool_name=$(echo "$input" | jq -r '.preToolUse.toolName // empty') + +# --- Session check --- +# Only enforce if pastewatch MCP is running in THIS Cline session. +# Cline runs hooks as children of its node process - check siblings. +_pw_mcp_ok=false +_cline_pid=${PPID:-0} +if command -v pastewatch-cli &>/dev/null && pgrep -P "$_cline_pid" -qf 'pastewatch-cli mcp' 2>/dev/null; then + _pw_mcp_ok=true +fi + +# If MCP not available, allow everything (fail-open) +$_pw_mcp_ok || { echo '{"cancel": false}'; exit 0; } + +# ====== BASH GUARD (execute_command) ====== +if [ "$tool_name" = "execute_command" ]; then + command=$(echo "$input" | jq -r '.preToolUse.parameters.command // empty') + [ -z "$command" ] && { echo '{"cancel": false}'; exit 0; } + + # Block commands that leak secrets via file access (cat .env, grep passwords, etc.) + guard_output=$(pastewatch-cli guard "$command" 2>&1) + if [ $? -ne 0 ]; then + block "$guard_output" + fi +fi + +# ====== FILE GUARD (read_file, write_to_file, edit_file) ====== +if [ "$tool_name" = "read_file" ] || [ "$tool_name" = "write_to_file" ] || [ "$tool_name" = "edit_file" ]; then + pw_path=$(echo "$input" | jq -r '.preToolUse.parameters.path // empty') + + if [ -n "$pw_path" ]; then + # Skip binary files + case "$pw_path" in + *.png|*.jpg|*.jpeg|*.gif|*.ico|*.bmp|*.webp|*.svg|*.woff|*.woff2|*.ttf|\ + *.zip|*.tar|*.gz|*.bz2|*.exe|*.dll|*.so|*.dylib|*.pdf|*.mp3|*.mp4|\ + *.sqlite|*.db|*.pyc|*.o|*.a|*.class) + ;; # skip binary - fall through to allow + *) + # Check for placeholder leak in write content + if [ "$tool_name" = "write_to_file" ]; then + pw_content=$(echo "$input" | jq -r '.preToolUse.parameters.content // empty') + if [ -n "$pw_content" ] && echo "$pw_content" | grep -qE '__PW\{[A-Z][A-Z0-9_]*_[0-9]+\}__'; then + block "BLOCKED: content contains pastewatch placeholders. Use pastewatch_write_file to resolve them." + fi + fi + + # Scan file on disk for secrets + if [ -f "$pw_path" ] && command -v pastewatch-cli &>/dev/null; then + if ! echo "$pw_path" | grep -qF '/.git/'; then + pastewatch-cli scan --check --fail-on-severity "$PW_SEVERITY" --file "$pw_path" >/dev/null 2>&1 + if [ $? -eq 6 ]; then + case "$tool_name" in + read_file) block "BLOCKED: $pw_path contains secrets. You MUST use pastewatch_read_file instead. Do NOT use any workaround." ;; + write_to_file) block "BLOCKED: $pw_path contains secrets. You MUST use pastewatch_write_file instead. Do NOT delete the file or use any workaround." ;; + edit_file) block "BLOCKED: $pw_path contains secrets. You MUST use pastewatch_read_file then pastewatch_write_file. Do NOT use any workaround." ;; + esac + fi + fi + fi + ;; + esac + fi +fi + +# Allow by default +echo '{"cancel": false}' +exit 0 diff --git a/docs/examples/cursor/mcp.json b/docs/examples/cursor/mcp.json new file mode 100644 index 0000000..71f08f4 --- /dev/null +++ b/docs/examples/cursor/mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "pastewatch": { + "command": "pastewatch-cli", + "args": ["mcp", "--audit-log", "/tmp/pastewatch-audit.log"] + } + } +} diff --git a/docs/hard-constraints.md b/docs/hard-constraints.md index a282c37..7ace488 100644 --- a/docs/hard-constraints.md +++ b/docs/hard-constraints.md @@ -6,6 +6,21 @@ Violating any constraint means building a different tool. --- +## Why Before Paste + +Downstream controls fail because they operate too late: + +| Approach | Problem | +|----------|---------| +| Browser extension | Only sees web apps, blind to native | +| LLM proxy | Data already transmitted | +| DLP system | Blocks after detection, user already exposed intent | +| Prompt sanitizer | Runs after submission | + +The clipboard is the last moment of user control. After paste, the data belongs to someone else. Pastewatch intervenes at the only point that matters: before the irreversible action. + +--- + ## 1. Local-Only Operation **No network calls. No exceptions.** @@ -102,14 +117,16 @@ The user's intent is sacred. Modify the data, not the action. ## 8. Scope Limitation -**Only guard clipboard → external system boundary.** +**Guard boundaries where data crosses to external systems.** +- GUI: clipboard → AI chat boundary (before paste) +- CLI: file/stdin → stdout boundary (scan and report) - Not a general DLP tool - Not a compliance product -- Not a file scanner - Not a network monitor +- Not a replacement for Loki, ELK, or full SAST -Narrow scope = strong guarantees. Broader scope = weaker everything. +The CLI extends scanning to files and directories for CI/pre-commit use, but the principle holds: detect at the boundary, report findings, let the user decide. --- @@ -123,4 +140,4 @@ Not "behind a config." No. -These constraints exist because removing them creates a different tool — one that erodes trust, demands attention, and eventually gets uninstalled. +These constraints exist because removing them creates a different tool - one that erodes trust, demands attention, and eventually gets uninstalled. diff --git a/docs/status.md b/docs/status.md index 2726229..4b295a4 100644 --- a/docs/status.md +++ b/docs/status.md @@ -2,15 +2,33 @@ ## Current State -**MVP — Experimental Prototype** - -The core functionality works: -- Clipboard monitoring active -- Detection rules implemented -- Obfuscation functional -- macOS menubar app running - -Edge cases exist. Feedback welcome. +**Stable - v0.24.1** + +Core and CLI functionality complete: +- Clipboard monitoring and obfuscation (GUI) +- 30 detection types with severity levels (critical/high/medium/low) +- CLI: file, directory, stdin, git-diff, and git-log scanning +- Linux binary for CI runners +- SARIF 2.1.0 and markdown output with severity-appropriate levels +- Format-aware parsing (.env, JSON, YAML, properties) +- Allowlist, custom detection rules with custom severity, inline allowlist comments +- MCP server for AI agent integration with per-agent severity thresholds +- Bash command guard with pipe chains, subshells, redirects, database CLIs, infra tools +- Read/Write tool guards for Claude Code hooks +- Baseline diff mode for existing projects +- Pre-commit hook installer + pre-commit.com framework integration +- Project-level config init, resolution, and validation +- fix subcommand for secret externalization to env vars +- inventory subcommand for secret posture reports with compare mode +- doctor subcommand for installation health checks +- setup subcommand for one-command agent integration +- report subcommand for MCP audit log session reports +- canary subcommand for leak detection honeypots +- VS Code extension with real-time diagnostics +- Entropy-based detection (opt-in) +- --stdin-filename, --fail-on-severity, --output, --ignore flags +- .pastewatchignore for glob-based path exclusion +- explain and config check subcommands --- @@ -25,14 +43,87 @@ Edge cases exist. Feedback welcome. | Generic API key detection | ✓ Stable | | GitHub token detection | ✓ Stable | | Stripe key detection | ✓ Stable | +| OpenAI key detection | ✓ Stable | +| Anthropic key detection | ✓ Stable | +| Hugging Face token detection | ✓ Stable | +| Groq key detection | ✓ Stable | +| npm token detection | ✓ Stable | +| PyPI token detection | ✓ Stable | +| RubyGems token detection | ✓ Stable | +| GitLab token detection | ✓ Stable | +| Telegram bot token detection | ✓ Stable | +| SendGrid key detection | ✓ Stable | +| Shopify token detection | ✓ Stable | +| DigitalOcean token detection | ✓ Stable | | UUID detection | ✓ Stable | | JWT detection | ✓ Stable | | DB connection string detection | ✓ Stable | | SSH private key detection | ✓ Stable | | Credit card detection (Luhn) | ✓ Stable | +| File path detection | ✓ Stable | +| Hostname detection | ✓ Stable | +| Credential detection | ✓ Stable | | Menubar UI | ✓ Functional | | System notifications | ✓ Functional | | Configuration persistence | ✓ Functional | +| CLI scan (file/stdin) | ✓ Stable | +| CLI directory scanning | ✓ Stable | +| SARIF 2.1.0 output | ✓ Stable | +| Format-aware parsing | ✓ Stable | +| Allowlist | ✓ Stable | +| Custom detection rules | ✓ Stable | +| MCP server | ✓ Stable | +| Baseline diff mode | ✓ Stable | +| Pre-commit hook installer | ✓ Stable | +| Config init / resolution | ✓ Stable | +| Linux CLI binary | ✓ Stable | +| Severity levels | ✓ Stable | +| Inline allowlist comments | ✓ Stable | +| Pre-commit framework | ✓ Stable | +| Stdin filename hint | ✓ Stable | +| Slack Webhook detection | ✓ Stable | +| Discord Webhook detection | ✓ Stable | +| Azure Connection String detection | ✓ Stable | +| GCP Service Account detection | ✓ Stable | +| --fail-on-severity threshold | ✓ Stable | +| --output file reporting | ✓ Stable | +| Markdown output format | ✓ Stable | +| Custom rule severity | ✓ Stable | +| .pastewatchignore | ✓ Stable | +| explain subcommand | ✓ Stable | +| config check subcommand | ✓ Stable | +| MCP redacted read/write | ✓ Stable | +| Agent safety guide | ✓ Stable | +| LLM key detection (OpenAI, Anthropic, HF, Groq) | ✓ Stable | +| Registry token detection (npm, PyPI, RubyGems) | ✓ Stable | +| Platform token detection (GitLab, Telegram, SendGrid, Shopify, DO) | ✓ Stable | +| ClickHouse connection string detection | ✓ Stable | +| MCP audit logging (--audit-log) | ✓ Stable | +| MCP per-agent severity (--min-severity) | ✓ Stable | +| Guard: Bash command scanning | ✓ Stable | +| Guard: pipe chains, command chaining | ✓ Stable | +| Guard: scripting interpreters | ✓ Stable | +| Guard: file transfer tools (scp, rsync, ssh) | ✓ Stable | +| Guard: infrastructure tools (terraform, docker, kubectl) | ✓ Stable | +| Guard: database CLIs (psql, mysql, redis-cli) | ✓ Stable | +| Guard: redirect operators, subshell extraction | ✓ Stable | +| Guard: inline value scanning (connection strings, passwords) | ✓ Stable | +| Guard-read / guard-write (Read/Write tool hooks) | ✓ Stable | +| Fix subcommand (secret externalization) | ✓ Stable | +| Inventory subcommand (posture reports) | ✓ Stable | +| Doctor subcommand (health check) | ✓ Stable | +| Setup subcommand (agent auto-setup) | ✓ Stable | +| Report subcommand (MCP session report) | ✓ Stable | +| Canary subcommand (leak detection honeypots) | ✓ Stable | +| Git diff scanning (--git-diff) | ✓ Stable | +| Git history scanning (--git-log) | ✓ Stable | +| Entropy-based detection (opt-in) | ✓ Stable | +| VS Code extension | ✓ Stable | +| safeHosts / sensitiveHosts config | ✓ Stable | +| sensitiveIPPrefixes config | ✓ Stable | +| allowedPatterns config | ✓ Stable | +| PW_GUARD=0 bypass | ✓ Stable | +| Homebrew distribution | ✓ Stable | --- @@ -40,7 +131,7 @@ Edge cases exist. Feedback welcome. | Limitation | Notes | |------------|-------| -| macOS 14+ only | Uses modern SwiftUI APIs | +| GUI macOS 14+ only | Uses modern SwiftUI APIs (CLI works on Linux) | | Polling-based | 500ms interval, not event-driven | | String content only | Images, files not scanned | | English-centric patterns | Phone formats may miss some regions | @@ -53,13 +144,11 @@ Edge cases exist. Feedback welcome. **Considered for future versions:** - Additional regional phone formats -- Custom pattern definitions - Keyboard shortcut for pause/resume - Launch at login option **Will evaluate carefully:** -- Pattern import/export - Detection statistics (local only) --- @@ -73,7 +162,7 @@ Edge cases exist. Feedback welcome. | Cloud sync | Violates local-only constraint | | ML detection | Violates deterministic constraint | | Clipboard history | Violates memory-only constraint | -| Cross-platform | macOS-native by design | +| Cross-platform GUI | macOS-native by design (CLI is cross-platform) | | Browser extension | Different tool, different boundary | | Compliance certification | Not a compliance product | | Enterprise features | Not an enterprise tool | @@ -93,6 +182,5 @@ See [CHANGELOG.md](../CHANGELOG.md) for detailed version history. ## Contributing Before proposing changes, read: -- [docs/design-baseline.md](design-baseline.md) — Core philosophy -- [docs/hard-constraints.md](hard-constraints.md) — Non-negotiable rules -- [CONTRIBUTING.md](../CONTRIBUTING.md) — Development workflow +- [docs/hard-constraints.md](hard-constraints.md) - Design philosophy and non-negotiable rules +- [CONTRIBUTING.md](../CONTRIBUTING.md) - Development workflow diff --git a/vscode-pastewatch/.gitignore b/vscode-pastewatch/.gitignore new file mode 100644 index 0000000..a08e1da --- /dev/null +++ b/vscode-pastewatch/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.vsix diff --git a/vscode-pastewatch/.vscodeignore b/vscode-pastewatch/.vscodeignore new file mode 100644 index 0000000..608f8be --- /dev/null +++ b/vscode-pastewatch/.vscodeignore @@ -0,0 +1,6 @@ +.vscode/** +src/** +**/*.ts +**/*.map +tsconfig.json +package-lock.json diff --git a/vscode-pastewatch/README.md b/vscode-pastewatch/README.md new file mode 100644 index 0000000..fea32f3 --- /dev/null +++ b/vscode-pastewatch/README.md @@ -0,0 +1,59 @@ +# pastewatch — VS Code Extension + +Real-time secret detection in the editor. Catches secrets as you type — before they reach git history or CI. + +## Features + +- **Inline diagnostics** — red/yellow/blue squiggles on detected secrets +- **Hover tooltips** — detection type and severity on hover +- **Quick-fix actions** — add `// pastewatch:allow` inline or append to `.pastewatch-allow` +- **Status bar** — finding count for the active file +- **Auto-refresh** — re-scans on file save (debounced, configurable) + +## Requirements + +`pastewatch-cli` must be installed and available in your PATH. + +```sh +brew install ppiankov/tap/pastewatch-cli +``` + +## Installation + +### From Marketplace + +Search for "pastewatch" in the VS Code Extensions view. + +### From VSIX + +```sh +cd vscode-pastewatch +npm run package:vsix +code --install-extension pastewatch-*.vsix +``` + +## Configuration + +| Setting | Default | Description | +|---|---|---| +| `pastewatch.autoRefresh` | `true` | Re-run diagnostics on file save | +| `pastewatch.binaryPath` | `pastewatch-cli` | Path to the CLI binary | +| `pastewatch.debounceMs` | `500` | Debounce window for save-triggered refresh | +| `pastewatch.failOnSeverity` | `low` | Minimum severity to show diagnostics | + +## How It Works + +The extension shells out to `pastewatch-cli scan --format json --file ` for each file. No detection logic is bundled — the CLI is the single source of truth. + +### Severity Mapping + +| CLI Severity | VS Code Diagnostic | Squiggle Color | +|---|---|---| +| critical | Error | Red | +| high | Error | Red | +| medium | Warning | Yellow | +| low | Information | Blue | + +## License + +MIT diff --git a/vscode-pastewatch/icon.png b/vscode-pastewatch/icon.png new file mode 100644 index 0000000..7b1f9d2 Binary files /dev/null and b/vscode-pastewatch/icon.png differ diff --git a/vscode-pastewatch/package-lock.json b/vscode-pastewatch/package-lock.json new file mode 100644 index 0000000..fa3835b --- /dev/null +++ b/vscode-pastewatch/package-lock.json @@ -0,0 +1,2481 @@ +{ + "name": "pastewatch", + "version": "0.1.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pastewatch", + "version": "0.1.1", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.17.57", + "@types/vscode": "^1.90.0", + "@vscode/vsce": "^2.26.0", + "typescript": "^5.8.2" + }, + "engines": { + "vscode": "^1.90.0" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", + "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.22.2", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.2.tgz", + "integrity": "sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.0.tgz", + "integrity": "sha512-uWC0fssc+hs1TGGVkkghiaFkkS7NkTxfnCH+Hdg+yTehTpMcehpok4PgUKKdyCH+9ldu6FhiHRv84Ntqj1vVcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^4.2.0", + "@azure/msal-node": "^3.5.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.29.0.tgz", + "integrity": "sha512-/f3eHkSNUTl6DLQHm+bKecjBKcRQxbd/XLx8lvSYp8Nl/HRyPuIPOijt9Dt0sH50/SxOwQ62RnFCmFlGK+bR/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.15.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.15.0.tgz", + "integrity": "sha512-/n+bN0AKlVa+AOcETkJSKj38+bvFs78BaP4rNtv3MJCmPH0YrHiskMRe74OhyZ5DZjGISlFyxqvf9/4QVEi2tw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "3.8.8", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.8.8.tgz", + "integrity": "sha512-+f1VrJH1iI517t4zgmuhqORja0bL6LDQXfBqkjuMmfTYXTQQnh1EvwwxO3UbKLT05N0obF72SRHFrC1RBDv5Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.15.0", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@types/node": { + "version": "20.19.35", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", + "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.109.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.109.0.tgz", + "integrity": "sha512-0Pf95rnwEIwDbmXGC08r0B4TQhAbsHQ5UyTIgVgoieDe4cOnf92usuR5dEczb6bTKEp7ziZH4TV1TRGPPCExtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.3.tgz", + "integrity": "sha512-91fp6CAAJSRtH5ja95T1FHSKa8aPW9/Zw6cta81jlZTUw/+Vq8jM/AfF/14h2b71wwR84JUTW/3Y8QPhDAawFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@vscode/vsce": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-2.32.0.tgz", + "integrity": "sha512-3EFJfsgrSftIqt3EtdRcAygy/OJ3hstyI1cDmIgkU9CFZW5C+3djr6mfosndCUqcVYuyjmxOK1xmFp/Bq7+NIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/identity": "^4.1.0", + "@vscode/vsce-sign": "^2.0.0", + "azure-devops-node-api": "^12.5.0", + "chalk": "^2.4.2", + "cheerio": "^1.0.0-rc.9", + "cockatiel": "^3.1.2", + "commander": "^6.2.1", + "form-data": "^4.0.0", + "glob": "^7.0.6", + "hosted-git-info": "^4.0.2", + "jsonc-parser": "^3.2.0", + "leven": "^3.1.0", + "markdown-it": "^12.3.2", + "mime": "^1.3.4", + "minimatch": "^3.0.3", + "parse-semver": "^1.1.1", + "read": "^1.0.7", + "semver": "^7.5.2", + "tmp": "^0.2.1", + "typed-rest-client": "^1.8.4", + "url-join": "^4.0.1", + "xml2js": "^0.5.0", + "yauzl": "^2.3.1", + "yazl": "^2.2.2" + }, + "bin": { + "vsce": "vsce" + }, + "engines": { + "node": ">= 16" + }, + "optionalDependencies": { + "keytar": "^7.7.0" + } + }, + "node_modules/@vscode/vsce-sign": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.0.9.tgz", + "integrity": "sha512-8IvaRvtFyzUnGGl3f5+1Cnor3LqaUWvhaUjAYO8Y39OUYlOf3cRd+dowuQYLpZcP3uwSG+mURwjEBOSq4SOJ0g==", + "dev": true, + "hasInstallScript": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optionalDependencies": { + "@vscode/vsce-sign-alpine-arm64": "2.0.6", + "@vscode/vsce-sign-alpine-x64": "2.0.6", + "@vscode/vsce-sign-darwin-arm64": "2.0.6", + "@vscode/vsce-sign-darwin-x64": "2.0.6", + "@vscode/vsce-sign-linux-arm": "2.0.6", + "@vscode/vsce-sign-linux-arm64": "2.0.6", + "@vscode/vsce-sign-linux-x64": "2.0.6", + "@vscode/vsce-sign-win32-arm64": "2.0.6", + "@vscode/vsce-sign-win32-x64": "2.0.6" + } + }, + "node_modules/@vscode/vsce-sign-alpine-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-arm64/-/vsce-sign-alpine-arm64-2.0.6.tgz", + "integrity": "sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-alpine-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-x64/-/vsce-sign-alpine-x64-2.0.6.tgz", + "integrity": "sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-arm64/-/vsce-sign-darwin-arm64-2.0.6.tgz", + "integrity": "sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.6.tgz", + "integrity": "sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.6.tgz", + "integrity": "sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.6.tgz", + "integrity": "sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.6.tgz", + "integrity": "sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-win32-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.6.tgz", + "integrity": "sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/vsce-sign-win32-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.6.tgz", + "integrity": "sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/azure-devops-node-api": { + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", + "integrity": "sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "0.0.6", + "typed-rest-client": "^1.8.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/cockatiel": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", + "integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keytar": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", + "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^4.3.0", + "prebuild-install": "^7.0.1" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/linkify-it": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz", + "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^1.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/markdown-it": { + "version": "12.3.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz", + "integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "~2.1.0", + "linkify-it": "^3.0.1", + "mdurl": "^1.0.1", + "uc.micro": "^1.0.5" + }, + "bin": { + "markdown-it": "bin/markdown-it.js" + } + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==", + "dev": true, + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true, + "license": "ISC" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-semver": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/parse-semver/-/parse-semver-1.1.1.tgz", + "integrity": "sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.1.0" + } + }, + "node_modules/parse-semver/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/read": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", + "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "mute-stream": "~0.0.4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", + "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/typed-rest-client": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", + "integrity": "sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "qs": "^6.9.1", + "tunnel": "0.0.6", + "underscore": "^1.12.1" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", + "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yazl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", + "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3" + } + } + } +} diff --git a/vscode-pastewatch/package.json b/vscode-pastewatch/package.json new file mode 100644 index 0000000..457368d --- /dev/null +++ b/vscode-pastewatch/package.json @@ -0,0 +1,77 @@ +{ + "name": "pastewatch", + "displayName": "pastewatch", + "description": "Real-time secret detection in the editor.", + "version": "0.1.1", + "publisher": "ppiankov", + "license": "MIT", + "icon": "icon.png", + "repository": { + "type": "git", + "url": "https://github.com/ppiankov/pastewatch" + }, + "homepage": "https://github.com/ppiankov/pastewatch", + "bugs": { + "url": "https://github.com/ppiankov/pastewatch/issues" + }, + "engines": { + "vscode": "^1.90.0" + }, + "categories": [ + "Linters", + "Other" + ], + "activationEvents": [ + "onStartupFinished" + ], + "main": "./dist/extension.js", + "contributes": { + "commands": [ + { + "command": "pastewatch.refresh", + "title": "pastewatch: Refresh Diagnostics" + } + ], + "configuration": { + "title": "pastewatch", + "properties": { + "pastewatch.autoRefresh": { + "type": "boolean", + "default": true, + "description": "Re-run pastewatch diagnostics on file save (debounced)." + }, + "pastewatch.binaryPath": { + "type": "string", + "default": "pastewatch-cli", + "description": "Path to the pastewatch-cli binary." + }, + "pastewatch.debounceMs": { + "type": "number", + "default": 500, + "minimum": 100, + "maximum": 10000, + "description": "Debounce window in milliseconds for save-triggered refresh." + }, + "pastewatch.failOnSeverity": { + "type": "string", + "default": "low", + "enum": ["critical", "high", "medium", "low"], + "description": "Minimum severity to show diagnostics." + } + } + } + }, + "scripts": { + "build": "tsc -p ./", + "watch": "tsc -w -p ./", + "lint": "tsc -p ./ --noEmit", + "package:vsix": "npm run build && npx @vscode/vsce package", + "publish:marketplace": "npm run build && npx @vscode/vsce publish" + }, + "devDependencies": { + "@types/node": "^20.17.57", + "@types/vscode": "^1.90.0", + "@vscode/vsce": "^2.26.0", + "typescript": "^5.8.2" + } +} diff --git a/vscode-pastewatch/src/codeActions.ts b/vscode-pastewatch/src/codeActions.ts new file mode 100644 index 0000000..578b9f0 --- /dev/null +++ b/vscode-pastewatch/src/codeActions.ts @@ -0,0 +1,111 @@ +import * as vscode from "vscode"; +import * as path from "path"; + +import { isDiagnosticWithData } from "./diagnostics"; + +const DIAGNOSTIC_SOURCE = "pastewatch"; + +export class PastewatchCodeActionProvider implements vscode.CodeActionProvider { + static readonly providedCodeActionKinds = [vscode.CodeActionKind.QuickFix]; + + provideCodeActions( + document: vscode.TextDocument, + range: vscode.Range | vscode.Selection, + context: vscode.CodeActionContext, + ): vscode.CodeAction[] { + const actions: vscode.CodeAction[] = []; + + for (const diag of context.diagnostics) { + if (diag.source !== DIAGNOSTIC_SOURCE) continue; + + actions.push(this.createInlineAllowAction(document, diag)); + + if (isDiagnosticWithData(diag)) { + actions.push(this.createAllowlistAction(document, diag)); + } + } + + return actions; + } + + private createInlineAllowAction( + document: vscode.TextDocument, + diag: vscode.Diagnostic, + ): vscode.CodeAction { + const action = new vscode.CodeAction( + "Add inline pastewatch:allow", + vscode.CodeActionKind.QuickFix, + ); + action.diagnostics = [diag]; + + const line = document.lineAt(diag.range.start.line); + const edit = new vscode.WorkspaceEdit(); + const insertPos = line.range.end; + edit.insert(document.uri, insertPos, " // pastewatch:allow"); + + action.edit = edit; + return action; + } + + private createAllowlistAction( + document: vscode.TextDocument, + diag: vscode.Diagnostic, + ): vscode.CodeAction { + if (!isDiagnosticWithData(diag)) { + return new vscode.CodeAction( + "Add to .pastewatch-allow", + vscode.CodeActionKind.QuickFix, + ); + } + + const action = new vscode.CodeAction( + "Add to .pastewatch-allow", + vscode.CodeActionKind.QuickFix, + ); + action.diagnostics = [diag]; + action.command = { + command: "pastewatch.addToAllowlist", + title: "Add to allowlist", + arguments: [diag.data.finding.value], + }; + + return action; + } +} + +export async function addToAllowlist(value: string): Promise { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + vscode.window.showErrorMessage("No workspace folder open."); + return; + } + + const allowlistPath = path.join( + workspaceFolder.uri.fsPath, + ".pastewatch-allow", + ); + const uri = vscode.Uri.file(allowlistPath); + + let existing = ""; + try { + const content = await vscode.workspace.fs.readFile(uri); + existing = Buffer.from(content).toString("utf8"); + } catch { + // file doesn't exist yet + } + + const entry = value.trim(); + if (existing.split("\n").some((line) => line.trim() === entry)) { + vscode.window.showInformationMessage("Value already in allowlist."); + return; + } + + const newContent = existing.endsWith("\n") + ? existing + entry + "\n" + : existing === "" + ? entry + "\n" + : existing + "\n" + entry + "\n"; + + await vscode.workspace.fs.writeFile(uri, Buffer.from(newContent, "utf8")); + vscode.window.showInformationMessage(`Added to .pastewatch-allow: ${entry}`); +} diff --git a/vscode-pastewatch/src/diagnostics.ts b/vscode-pastewatch/src/diagnostics.ts new file mode 100644 index 0000000..5fff9a5 --- /dev/null +++ b/vscode-pastewatch/src/diagnostics.ts @@ -0,0 +1,65 @@ +import * as vscode from "vscode"; + +import { Finding, Severity } from "./types"; + +const DIAGNOSTIC_SOURCE = "pastewatch"; + +export function buildDiagnostics( + findings: Finding[], + document: vscode.TextDocument, +): vscode.Diagnostic[] { + const diagnostics: vscode.Diagnostic[] = []; + const text = document.getText(); + + for (const finding of findings) { + const range = findRange(finding.value, text, document); + if (!range) continue; + + const diag = new vscode.Diagnostic( + range, + `${finding.type}: secret detected (${finding.severity})`, + mapSeverity(finding.severity), + ); + diag.source = DIAGNOSTIC_SOURCE; + (diag as DiagnosticWithData).data = { finding }; + diagnostics.push(diag); + } + + return diagnostics; +} + +export interface DiagnosticWithData extends vscode.Diagnostic { + data: { finding: Finding }; +} + +export function isDiagnosticWithData( + diag: vscode.Diagnostic, +): diag is DiagnosticWithData { + const d = diag as DiagnosticWithData; + return d.data != null && d.data.finding != null; +} + +function mapSeverity(severity: Severity): vscode.DiagnosticSeverity { + switch (severity) { + case "critical": + case "high": + return vscode.DiagnosticSeverity.Error; + case "medium": + return vscode.DiagnosticSeverity.Warning; + case "low": + return vscode.DiagnosticSeverity.Information; + } +} + +function findRange( + value: string, + text: string, + document: vscode.TextDocument, +): vscode.Range | undefined { + const idx = text.indexOf(value); + if (idx === -1) return undefined; + + const start = document.positionAt(idx); + const end = document.positionAt(idx + value.length); + return new vscode.Range(start, end); +} diff --git a/vscode-pastewatch/src/extension.ts b/vscode-pastewatch/src/extension.ts new file mode 100644 index 0000000..3f1b0a5 --- /dev/null +++ b/vscode-pastewatch/src/extension.ts @@ -0,0 +1,142 @@ +import * as vscode from "vscode"; + +import { PastewatchCodeActionProvider, addToAllowlist } from "./codeActions"; +import { buildDiagnostics } from "./diagnostics"; +import { PastewatchHoverProvider } from "./hover"; +import { loadConfig, runScan } from "./scanner"; + +let debounceTimer: ReturnType | undefined; + +export function activate(context: vscode.ExtensionContext): void { + const output = vscode.window.createOutputChannel("pastewatch"); + const diagnostics = vscode.languages.createDiagnosticCollection("pastewatch"); + + // Status bar + const statusBar = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + 100, + ); + statusBar.command = "pastewatch.refresh"; + statusBar.tooltip = "Click to refresh pastewatch diagnostics"; + updateStatusBar(statusBar, 0); + statusBar.show(); + + // Hover provider + const hoverProvider = new PastewatchHoverProvider(diagnostics); + context.subscriptions.push( + vscode.languages.registerHoverProvider({ scheme: "file" }, hoverProvider), + ); + + // Code action provider + const codeActionProvider = new PastewatchCodeActionProvider(); + context.subscriptions.push( + vscode.languages.registerCodeActionsProvider( + { scheme: "file" }, + codeActionProvider, + { providedCodeActionKinds: PastewatchCodeActionProvider.providedCodeActionKinds }, + ), + ); + + // Command: refresh + context.subscriptions.push( + vscode.commands.registerCommand("pastewatch.refresh", () => { + const editor = vscode.window.activeTextEditor; + if (editor) { + void scanDocument(editor.document, diagnostics, statusBar, output); + } + }), + ); + + // Command: add to allowlist + context.subscriptions.push( + vscode.commands.registerCommand( + "pastewatch.addToAllowlist", + (value: string) => { + void addToAllowlist(value).then(() => { + // Re-scan after allowlist update + const editor = vscode.window.activeTextEditor; + if (editor) { + void scanDocument(editor.document, diagnostics, statusBar, output); + } + }); + }, + ), + ); + + // Scan on save (debounced) + context.subscriptions.push( + vscode.workspace.onDidSaveTextDocument((document) => { + const config = loadConfig(); + if (!config.autoRefresh) return; + + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + void scanDocument(document, diagnostics, statusBar, output); + }, config.debounceMs); + }), + ); + + // Scan when active editor changes + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor((editor) => { + if (editor) { + updateStatusBar(statusBar, diagnostics.get(editor.document.uri)?.length ?? 0); + } + }), + ); + + // Clear diagnostics when file is closed + context.subscriptions.push( + vscode.workspace.onDidCloseTextDocument((document) => { + diagnostics.delete(document.uri); + }), + ); + + context.subscriptions.push(output, diagnostics, statusBar); + + // Scan active file on activation + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor) { + void scanDocument(activeEditor.document, diagnostics, statusBar, output); + } +} + +async function scanDocument( + document: vscode.TextDocument, + diagnosticCollection: vscode.DiagnosticCollection, + statusBar: vscode.StatusBarItem, + output: vscode.OutputChannel, +): Promise { + if (document.uri.scheme !== "file") return; + + const config = loadConfig(); + + try { + const result = await runScan(document.uri.fsPath, config, output); + const diags = buildDiagnostics(result.findings, document); + diagnosticCollection.set(document.uri, diags); + updateStatusBar(statusBar, diags.length); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + output.appendLine(`error: ${msg}`); + if (msg.includes("not found")) { + vscode.window.showWarningMessage(msg); + } + } +} + +function updateStatusBar(statusBar: vscode.StatusBarItem, count: number): void { + if (count === 0) { + statusBar.text = "$(shield) pastewatch: clean"; + statusBar.backgroundColor = undefined; + } else { + statusBar.text = `$(warning) pastewatch: ${count} finding${count === 1 ? "" : "s"}`; + statusBar.backgroundColor = new vscode.ThemeColor( + "statusBarItem.warningBackground", + ); + } +} + +export function deactivate(): void { + if (debounceTimer) clearTimeout(debounceTimer); +} diff --git a/vscode-pastewatch/src/hover.ts b/vscode-pastewatch/src/hover.ts new file mode 100644 index 0000000..77697e4 --- /dev/null +++ b/vscode-pastewatch/src/hover.ts @@ -0,0 +1,40 @@ +import * as vscode from "vscode"; + +import { isDiagnosticWithData } from "./diagnostics"; + +const DIAGNOSTIC_SOURCE = "pastewatch"; + +export class PastewatchHoverProvider implements vscode.HoverProvider { + constructor( + private readonly diagnosticCollection: vscode.DiagnosticCollection, + ) {} + + provideHover( + document: vscode.TextDocument, + position: vscode.Position, + ): vscode.Hover | undefined { + const diagnostics = this.diagnosticCollection.get(document.uri); + if (!diagnostics) return undefined; + + for (const diag of diagnostics) { + if (diag.source !== DIAGNOSTIC_SOURCE) continue; + if (!diag.range.contains(position)) continue; + if (!isDiagnosticWithData(diag)) continue; + + const { finding } = diag.data; + + const md = new vscode.MarkdownString(); + md.appendMarkdown(`**pastewatch** — ${finding.type}\n\n`); + md.appendMarkdown(`**Severity:** \`${finding.severity}\`\n\n`); + md.appendMarkdown( + `This value was detected as a potential secret. ` + + `Use the quick-fix to suppress or add it to the allowlist.`, + ); + md.isTrusted = true; + + return new vscode.Hover(md, diag.range); + } + + return undefined; + } +} diff --git a/vscode-pastewatch/src/scanner.ts b/vscode-pastewatch/src/scanner.ts new file mode 100644 index 0000000..3d8bc4e --- /dev/null +++ b/vscode-pastewatch/src/scanner.ts @@ -0,0 +1,88 @@ +import { execFile, ExecFileException } from "child_process"; +import * as vscode from "vscode"; + +import { PastewatchConfig, ScanOutput } from "./types"; + +interface CommandResult { + stdout: string; + stderr: string; + exitCode: number; +} + +export function loadConfig(): PastewatchConfig { + const cfg = vscode.workspace.getConfiguration("pastewatch"); + return { + autoRefresh: cfg.get("autoRefresh", true), + binaryPath: cfg.get("binaryPath", "pastewatch-cli"), + debounceMs: cfg.get("debounceMs", 500), + failOnSeverity: cfg.get("failOnSeverity", "low") as PastewatchConfig["failOnSeverity"], + }; +} + +export async function runScan( + filePath: string, + config: PastewatchConfig, + output: vscode.OutputChannel, +): Promise { + const args = ["scan", "--format", "json", "--file", filePath, "--fail-on-severity", config.failOnSeverity]; + + output.appendLine(`pastewatch-cli ${args.join(" ")}`); + + const result = await runCommand(config.binaryPath, args); + + if (result.stderr.trim() !== "") { + output.appendLine(`stderr: ${result.stderr.trim()}`); + } + + if (result.stdout.trim() === "") { + return { findings: [], count: 0, obfuscated: null }; + } + + let scanOutput: ScanOutput; + try { + scanOutput = JSON.parse(result.stdout) as ScanOutput; + } catch (err) { + throw new Error(`invalid JSON from pastewatch-cli: ${String(err)}`); + } + + if (!Array.isArray(scanOutput.findings)) { + throw new Error("unexpected pastewatch-cli output schema"); + } + + return scanOutput; +} + +function runCommand(binary: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile( + binary, + args, + { maxBuffer: 10 * 1024 * 1024, env: process.env }, + (error, stdout, stderr) => { + const execErr = error as ExecFileException | null; + + if (execErr && isBinaryMissing(execErr)) { + reject(new Error(`pastewatch-cli not found: ${binary}. Install it with: brew install ppiankov/tap/pastewatch-cli`)); + return; + } + + // pastewatch-cli exits non-zero (6) when findings exist — that's expected + if (execErr && stdout.trim() === "") { + const detail = stderr.trim() || execErr.message; + reject(new Error(detail)); + return; + } + + resolve({ + stdout, + stderr, + exitCode: typeof execErr?.code === "number" ? execErr.code : 0, + }); + }, + ); + }); +} + +function isBinaryMissing(err: ExecFileException): boolean { + return err.code === "ENOENT" || /ENOENT/.test(err.message); +} diff --git a/vscode-pastewatch/src/types.ts b/vscode-pastewatch/src/types.ts new file mode 100644 index 0000000..81e0e02 --- /dev/null +++ b/vscode-pastewatch/src/types.ts @@ -0,0 +1,20 @@ +export type Severity = "critical" | "high" | "medium" | "low"; + +export interface Finding { + type: string; + value: string; + severity: Severity; +} + +export interface ScanOutput { + findings: Finding[]; + count: number; + obfuscated: string | null; +} + +export interface PastewatchConfig { + autoRefresh: boolean; + binaryPath: string; + debounceMs: number; + failOnSeverity: Severity; +} diff --git a/vscode-pastewatch/tsconfig.json b/vscode-pastewatch/tsconfig.json new file mode 100644 index 0000000..e4c6931 --- /dev/null +++ b/vscode-pastewatch/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2022", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "sourceMap": true, + "strict": true, + "noImplicitAny": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "moduleResolution": "node", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +}