From 23b953511d89514b3e9feb1032eb85d2df607112 Mon Sep 17 00:00:00 2001 From: Alex Holmberg Date: Sat, 7 Jun 2025 23:10:06 +0200 Subject: [PATCH 1/3] fix: re-applying release-plz for bump improvements --- .github/workflows/release-plz.yml | 79 +++---------------------------- release-plz.toml | 54 --------------------- 2 files changed, 6 insertions(+), 127 deletions(-) delete mode 100644 release-plz.toml diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index 4d19d2a0..7e0b996a 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -1,39 +1,18 @@ name: Release-plz on: - workflow_dispatch: # Manual releases only - inputs: - release_type: - description: 'Type of release (patch, minor, auto) - NO MAJOR BUMPS' - required: false - default: 'auto' - type: choice - options: - - auto - - patch - - minor - dry_run: - description: 'Dry run (no actual release)' - required: false - default: false - type: boolean + push: + branches: + - main jobs: release-plz-release: name: Release-plz release runs-on: ubuntu-latest - if: ${{ github.repository_owner == 'syncable-dev' && github.event.inputs.dry_run != 'true' }} - + if: ${{ github.repository_owner == 'syncable-dev' }} permissions: contents: write steps: - - name: Show manual release inputs - run: | - echo "πŸš€ Manual Release Configuration:" - echo "Release Type: ${{ github.event.inputs.release_type || 'auto' }}" - echo "Dry Run: ${{ github.event.inputs.dry_run || 'false' }}" - echo "⚠️ Version Constraint: Will stay in 0.x.x range (no 1.0.0 bumps)" - - name: Checkout repository uses: actions/checkout@v4 with: @@ -41,13 +20,6 @@ jobs: token: ${{ secrets.RELEASE_PLZ_TOKEN }} - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable - - name: Check current version and constraints - run: | - CURRENT_VERSION=$(grep '^version =' Cargo.toml | cut -d'"' -f2) - echo "πŸ“Š Current version: $CURRENT_VERSION" - echo "πŸ“‹ Release type: ${{ github.event.inputs.release_type || 'auto' }}" - echo "🎯 Version constraint: Max 0.99.99 (stays in 0.x.x range)" - echo "βœ… Safe from automatic 1.0.0 bumps" - name: Run release-plz uses: release-plz/action@v0.5 with: @@ -56,44 +28,10 @@ jobs: GITHUB_TOKEN: ${{ secrets.RELEASE_PLZ_TOKEN }} CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - release-plz-dry-run: - name: Release-plz dry run - runs-on: ubuntu-latest - if: ${{ github.repository_owner == 'syncable-dev' && github.event.inputs.dry_run == 'true' }} - permissions: - contents: read - steps: - - name: Show dry run information - run: | - echo "πŸ§ͺ DRY RUN MODE - No actual release will be performed" - echo "Release Type: ${{ github.event.inputs.release_type || 'auto' }}" - echo "This would analyze the repository and show what changes would be released." - - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.RELEASE_PLZ_TOKEN }} - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - name: Analyze release changes (dry run) - run: | - CURRENT_VERSION=$(grep '^version =' Cargo.toml | cut -d'"' -f2) - echo "πŸ“Š Analyzing repository for potential release..." - echo "Current version: $CURRENT_VERSION" - echo "Release type: ${{ github.event.inputs.release_type || 'auto' }}" - echo "🎯 Version constraint: Max 0.99.99 (will NOT bump to 1.0.0)" - echo "" - echo "Recent commits:" - git log --oneline -10 - echo "" - echo "βœ… Dry run complete - no actual release performed" - echo "πŸ›‘οΈ Protected from major version bumps!" - release-plz-pr: name: Release-plz PR runs-on: ubuntu-latest - if: ${{ github.repository_owner == 'syncable-dev' && github.event.inputs.dry_run != 'true' }} + if: ${{ github.repository_owner == 'syncable-dev' }} permissions: pull-requests: write contents: write @@ -101,11 +39,6 @@ jobs: group: release-plz-${{ github.ref }} cancel-in-progress: false steps: - - name: Show manual release inputs - run: | - echo "πŸ“ Creating Release PR with configuration:" - echo "Release Type: ${{ github.event.inputs.release_type || 'auto' }}" - - name: Checkout repository uses: actions/checkout@v4 with: @@ -119,4 +52,4 @@ jobs: command: release-pr env: GITHUB_TOKEN: ${{ secrets.RELEASE_PLZ_TOKEN }} - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} \ No newline at end of file diff --git a/release-plz.toml b/release-plz.toml deleted file mode 100644 index 40947dea..00000000 --- a/release-plz.toml +++ /dev/null @@ -1,54 +0,0 @@ -[workspace] -# Manual releases only - don't release on every commit -release_always = false - -# Allow dirty working directories (for CI) -allow_dirty = false - -# Enable git operations -git_release_enable = true -git_tag_enable = true - -# Enable publishing to crates.io -publish = true - -# Changelog updates -changelog_update = true - -# Semver check -semver_check = true - -# IMPORTANT: Features always increment minor version in 0.x releases -# This prevents features from bumping 0.x to 1.0 -features_always_increment_minor = true - -# Optional: Only release on certain commit types -# This filters which commits can trigger a release -# Uncomment to be more selective: -# release_commits = "^(fix|feat|perf|docs):" - -[[package]] -name = "syncable-cli" - -# This package should be released -release = true - -# Use semantic versioning checks -semver_check = true - -# Publish this package -publish = true - -# Override at package level to ensure features don't bump major -features_always_increment_minor = true - -# Version constraints - stay in 0.x.x range -# Note: version_max might not be supported in this version -# We'll handle version constraints manually - -[changelog] -# Changelog will be updated -# Using default configuration which follows Keep a Changelog format - -# Protect breaking changes from being ignored -protect_breaking_commits = false \ No newline at end of file From e7ceaf23b3f4dcb192890be2b24e03edafa930d2 Mon Sep 17 00:00:00 2001 From: Alex Holmberg Date: Sun, 8 Jun 2025 09:47:04 +0200 Subject: [PATCH 2/3] patch: updated cli-display-modes.md file for better visualization --- docs/cli-display-modes.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/cli-display-modes.md b/docs/cli-display-modes.md index 96a6f01a..a72530d7 100644 --- a/docs/cli-display-modes.md +++ b/docs/cli-display-modes.md @@ -20,34 +20,34 @@ sync-ctl analyze . πŸ“Š PROJECT ANALYSIS DASHBOARD ═══════════════════════════════════════════════════════════════════════════════════════════════════ -β”Œβ”€ Architecture Overview ────────────────────────────────────────────────────────────────────────┐ +β”Œβ”€ Architecture Overview ─────────────────────────────────────────────────────────────────────────┐ β”‚ Type: Monorepo (3 projects) β”‚ β”‚ Pattern: Fullstack β”‚ β”‚ Full-stack app with frontend/backend separation β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -β”Œβ”€ Technology Stack ─────────────────────────────────────────────────────────────────────────────┐ +β”Œβ”€ Technology Stack ──────────────────────────────────────────────────────────────────────────────┐ β”‚ Languages: TypeScript β”‚ β”‚ Frameworks: Encore, Tanstack Start β”‚ β”‚ Databases: Drizzle ORM β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”Œβ”€ Projects Matrix ──────────────────────────────────────────────────────────────────────────────┐ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ Project β”‚ Type β”‚ Languages β”‚ Main Tech β”‚ Ports β”‚ Docker β”‚ Deps β”‚ β”‚ -β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€ β”‚ -β”‚ β”‚ βš™οΈ backend β”‚ Backend β”‚ TypeScriptβ”‚ Encore β”‚ 4000 β”‚ βœ“ β”‚ 32 β”‚ β”‚ -β”‚ β”‚ πŸ—οΈ devops-agent β”‚ Infrastructureβ”‚ TypeScriptβ”‚ - β”‚ - β”‚ βœ— β”‚ 5 β”‚ β”‚ -β”‚ β”‚ 🌐 frontend β”‚ Frontend β”‚ TypeScriptβ”‚ Tanstack Start β”‚ 3000 β”‚ βœ“ β”‚ 123 β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - -β”Œβ”€ Docker Infrastructure ────────────────────────────────────────────────────────────────────────┐ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Project β”‚ Type β”‚ Languages β”‚ Main Tech β”‚ Ports β”‚ Docker β”‚ Deps β”‚ β”‚ +β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ +β”‚ β”‚ backend β”‚ Backend β”‚ TypeScriptβ”‚ Encore β”‚ 4000 β”‚ βœ“ β”‚ 32 β”‚ β”‚ +β”‚ β”‚ devops-agent β”‚ Infrastructure β”‚ TypeScript β”‚ - β”‚ - β”‚ βœ— β”‚ 5 β”‚ β”‚ +β”‚ β”‚ frontend β”‚ Frontend β”‚ TypeScriptβ”‚ Tanstack Start β”‚ 3000 β”‚ βœ“ β”‚ 123 β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +β”Œβ”€ Docker Infrastructure ─────────────────────────────────────────────────────────────────────────┐ β”‚ Dockerfiles: 2 β”‚ β”‚ Compose Files: 2 β”‚ β”‚ Total Services: 5 β”‚ β”‚ Orchestration Patterns: Microservices β”‚ -β”‚ ───────────────────────────────────────────────────────────────────────────────────────────── β”‚ +β”‚ ────────────────────────────────────────────────────────────────────────────────────────────────│ β”‚ Service Connectivity: β”‚ β”‚ encore-postgres: 5431:5432 β”‚ β”‚ encore: 4000:8080 β†’ encore-postgres β”‚ @@ -55,8 +55,8 @@ sync-ctl analyze . β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”Œβ”€ Analysis Metrics ─────────────────────────────────────────────────────────────────────────────┐ -β”‚ ⏱️ Duration: 57ms πŸ“ Files: 294 🎯 Score: 87% πŸ”– Version: 0.3.0 β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +β”‚ Duration: 57ms Files: 294 Score: 87% Version: 0.3.0 β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ═══════════════════════════════════════════════════════════════════════════════════════════════════ ``` From 0f78da8ecd1ba3e5a689ca233a33097e7fb8455e Mon Sep 17 00:00:00 2001 From: Alex Holmberg Date: Sun, 8 Jun 2025 18:02:14 +0200 Subject: [PATCH 3/3] feat: huge improvements towards security and secret variable detection. With the new update we don't get false positive towards files name conventions such as .env.samples, .env.templates, env.examples etc. We are also skipping if files are ignored within .gitignore, since those files aren't being track. upcoming is to ensure git cache isn't storing .gitignored files, to ensure mistakes doesn't happen --- Cargo.lock | 11 + Cargo.toml | 1 + examples/enhanced_security.rs | 123 ++++ src/analyzer/frameworks/go.rs | 4 +- src/analyzer/frameworks/rust.rs | 12 +- src/analyzer/mod.rs | 10 + src/analyzer/security/config.rs | 318 +++++++++ src/analyzer/security/core.rs | 94 +++ src/analyzer/security/gitignore.rs | 531 ++++++++++++++ src/analyzer/security/javascript.rs | 1013 +++++++++++++++++++++++++++ src/analyzer/security/mod.rs | 77 ++ src/analyzer/security/patterns.rs | 377 ++++++++++ src/analyzer/security_analyzer.rs | 390 ++++++++++- src/main.rs | 317 ++++++--- 14 files changed, 3164 insertions(+), 114 deletions(-) create mode 100644 examples/enhanced_security.rs create mode 100644 src/analyzer/security/config.rs create mode 100644 src/analyzer/security/core.rs create mode 100644 src/analyzer/security/gitignore.rs create mode 100644 src/analyzer/security/javascript.rs create mode 100644 src/analyzer/security/mod.rs create mode 100644 src/analyzer/security/patterns.rs diff --git a/Cargo.lock b/Cargo.lock index a5edabf5..5e48a6c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3363,6 +3363,7 @@ dependencies = [ "serde_yaml", "tempfile", "tera", + "term_size", "termcolor", "textwrap", "thiserror 1.0.69", @@ -3474,6 +3475,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "term_size" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4129646ca0ed8f45d09b929036bafad5377103edd06e50bf574b353d2b08d9" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "termcolor" version = "1.4.1" diff --git a/Cargo.toml b/Cargo.toml index 23e07397..98e67a90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ termcolor = "1" chrono = { version = "0.4", features = ["serde"] } colored = "2" prettytable = "0.10" +term_size = "0.3" # Vulnerability checking dependencies rustsec = "0.29" diff --git a/examples/enhanced_security.rs b/examples/enhanced_security.rs new file mode 100644 index 00000000..3402ac6d --- /dev/null +++ b/examples/enhanced_security.rs @@ -0,0 +1,123 @@ +//! Example: Enhanced Security Analysis +//! +//! This example demonstrates the enhanced security analysis capabilities +//! including the new modular JavaScript/TypeScript security analyzer. + +use std::path::Path; +use syncable_cli::analyzer::{analyze_project, SecurityAnalyzer}; + +fn main() -> Result<(), Box> { + env_logger::init(); + + // For this example, analyze the current directory or a provided path + let project_path = std::env::args() + .nth(1) + .map(|p| Path::new(&p).to_path_buf()) + .unwrap_or_else(|| std::env::current_dir().unwrap()); + + println!("πŸ” Analyzing project security for: {}", project_path.display()); + + // First, perform regular project analysis to detect languages + let analysis = analyze_project(&project_path)?; + + println!("\nπŸ“‹ Detected Languages:"); + for lang in &analysis.languages { + println!(" β€’ {} (confidence: {:.1}%)", lang.name, lang.confidence * 100.0); + } + + println!("\nπŸ”§ Detected Technologies:"); + for tech in &analysis.technologies { + println!(" β€’ {} v{} ({:?})", + tech.name, + tech.version.as_deref().unwrap_or("unknown"), + tech.category + ); + } + + // Check if this is a JavaScript/TypeScript project + let has_js = analysis.languages.iter() + .any(|lang| matches!(lang.name.as_str(), "JavaScript" | "TypeScript" | "JSX" | "TSX")); + + if has_js { + println!("\nβœ… JavaScript/TypeScript project detected! Using enhanced security analysis..."); + } else { + println!("\nπŸ“„ Using general security analysis..."); + } + + // Run enhanced security analysis + println!("\nπŸ›‘οΈ Starting enhanced security analysis..."); + + let mut security_analyzer = SecurityAnalyzer::new()?; + let security_report = security_analyzer.analyze_security_enhanced(&analysis)?; + + // Display results + println!("\nπŸ“Š Security Analysis Results:"); + println!(" Overall Score: {:.1}/100", security_report.overall_score); + println!(" Risk Level: {:?}", security_report.risk_level); + println!(" Total Findings: {}", security_report.total_findings); + + if security_report.total_findings > 0 { + println!("\n🚨 Security Findings:"); + + // Group findings by severity + for severity in [ + syncable_cli::analyzer::security::core::SecuritySeverity::Critical, + syncable_cli::analyzer::security::core::SecuritySeverity::High, + syncable_cli::analyzer::security::core::SecuritySeverity::Medium, + syncable_cli::analyzer::security::core::SecuritySeverity::Low, + ] { + let findings: Vec<_> = security_report.findings.iter() + .filter(|f| f.severity == severity) + .collect(); + + if !findings.is_empty() { + let severity_icon = match severity { + syncable_cli::analyzer::security::core::SecuritySeverity::Critical => "πŸ”΄", + syncable_cli::analyzer::security::core::SecuritySeverity::High => "🟠", + syncable_cli::analyzer::security::core::SecuritySeverity::Medium => "🟑", + syncable_cli::analyzer::security::core::SecuritySeverity::Low => "πŸ”΅", + _ => "βšͺ", + }; + + println!("\n{} {:?} Severity ({} findings):", severity_icon, severity, findings.len()); + + for finding in findings.iter().take(3) { // Show first 3 of each severity + println!(" πŸ“ {}", finding.title); + if let Some(ref file_path) = finding.file_path { + let relative_path = file_path.strip_prefix(&project_path) + .unwrap_or(file_path); + print!(" πŸ“„ {}", relative_path.display()); + if let Some(line) = finding.line_number { + print!(":{}", line); + } + println!(); + } + println!(" πŸ’‘ {}", finding.description); + + if !finding.remediation.is_empty() { + println!(" πŸ”§ Remediation: {}", finding.remediation[0]); + } + println!(); + } + + if findings.len() > 3 { + println!(" ... and {} more findings", findings.len() - 3); + } + } + } + + // Show recommendations + if !security_report.recommendations.is_empty() { + println!("\nπŸ’‘ Recommendations:"); + for (i, recommendation) in security_report.recommendations.iter().enumerate() { + println!(" {}. {}", i + 1, recommendation); + } + } + } else { + println!("βœ… No security issues detected!"); + } + + println!("\n✨ Enhanced security analysis complete!"); + + Ok(()) +} \ No newline at end of file diff --git a/src/analyzer/frameworks/go.rs b/src/analyzer/frameworks/go.rs index 44d1ade8..3faa51ab 100644 --- a/src/analyzer/frameworks/go.rs +++ b/src/analyzer/frameworks/go.rs @@ -232,12 +232,12 @@ fn get_go_technology_rules() -> Vec { // CLI FRAMEWORKS TechnologyRule { name: "Cobra".to_string(), - category: TechnologyCategory::Library(LibraryType::Utility), + category: TechnologyCategory::Library(LibraryType::CLI), confidence: 0.85, dependency_patterns: vec!["github.com/spf13/cobra".to_string(), "cobra".to_string()], requires: vec![], conflicts_with: vec![], - is_primary_indicator: false, + is_primary_indicator: true, alternative_names: vec!["spf13/cobra".to_string()], }, diff --git a/src/analyzer/frameworks/rust.rs b/src/analyzer/frameworks/rust.rs index e9c07f1d..1b2c7cff 100644 --- a/src/analyzer/frameworks/rust.rs +++ b/src/analyzer/frameworks/rust.rs @@ -414,32 +414,32 @@ fn get_rust_technology_rules() -> Vec { // CLI FRAMEWORKS TechnologyRule { name: "clap".to_string(), - category: TechnologyCategory::Library(LibraryType::Utility), + category: TechnologyCategory::Library(LibraryType::CLI), confidence: 0.85, dependency_patterns: vec!["clap".to_string()], requires: vec![], conflicts_with: vec![], - is_primary_indicator: false, + is_primary_indicator: true, alternative_names: vec![], }, TechnologyRule { name: "structopt".to_string(), - category: TechnologyCategory::Library(LibraryType::Utility), + category: TechnologyCategory::Library(LibraryType::CLI), confidence: 0.85, dependency_patterns: vec!["structopt".to_string()], requires: vec![], conflicts_with: vec![], - is_primary_indicator: false, + is_primary_indicator: true, alternative_names: vec![], }, TechnologyRule { name: "argh".to_string(), - category: TechnologyCategory::Library(LibraryType::Utility), + category: TechnologyCategory::Library(LibraryType::CLI), confidence: 0.85, dependency_patterns: vec!["argh".to_string()], requires: vec![], conflicts_with: vec![], - is_primary_indicator: false, + is_primary_indicator: true, alternative_names: vec![], }, diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index 5d19830f..4951c81a 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -19,6 +19,7 @@ pub mod language_detector; pub mod project_context; pub mod vulnerability_checker; pub mod security_analyzer; +pub mod security; pub mod tool_installer; pub mod monorepo_detector; pub mod docker_analyzer; @@ -36,6 +37,13 @@ pub use security_analyzer::{ SecurityCategory, ComplianceStatus, SecurityAnalysisConfig }; +// Re-export new modular security analysis types +pub use security::{ + ModularSecurityAnalyzer, JavaScriptSecurityAnalyzer, + SecretPatternManager +}; +pub use security::config::SecurityConfigPreset; + // Re-export monorepo analysis types pub use monorepo_detector::{ MonorepoDetectionConfig, analyze_monorepo, analyze_monorepo_with_config @@ -102,6 +110,8 @@ pub enum LibraryType { HttpClient, /// Authentication (Auth0, Firebase Auth) Authentication, + /// CLI frameworks (clap, structopt, argh) + CLI, /// Other specific types Other(String), } diff --git a/src/analyzer/security/config.rs b/src/analyzer/security/config.rs new file mode 100644 index 00000000..473c083e --- /dev/null +++ b/src/analyzer/security/config.rs @@ -0,0 +1,318 @@ +//! # Security Analysis Configuration +//! +//! Configuration options for customizing security analysis behavior. + +use serde::{Deserialize, Serialize}; + +/// Configuration for security analysis +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecurityAnalysisConfig { + // General settings + pub include_low_severity: bool, + pub include_info_level: bool, + + // Analysis scope + pub check_secrets: bool, + pub check_code_patterns: bool, + pub check_infrastructure: bool, + pub check_compliance: bool, + + // Language-specific settings + pub javascript_enabled: bool, + pub python_enabled: bool, + pub rust_enabled: bool, + + // Framework-specific settings + pub frameworks_to_check: Vec, + + // File filtering + pub ignore_patterns: Vec, + pub include_patterns: Vec, + + // Git integration + pub skip_gitignored_files: bool, + pub downgrade_gitignored_severity: bool, + pub check_git_history: bool, + + // Environment variable handling + pub check_env_files: bool, + pub warn_on_public_env_vars: bool, + pub sensitive_env_keywords: Vec, + + // JavaScript/TypeScript specific + pub check_package_json: bool, + pub check_node_modules: bool, + pub framework_env_prefixes: Vec, + + // Output customization + pub max_findings_per_file: Option, + pub deduplicate_findings: bool, + pub group_by_severity: bool, + + // Performance settings + pub max_file_size_mb: Option, + pub parallel_analysis: bool, + pub analysis_timeout_seconds: Option, +} + +impl Default for SecurityAnalysisConfig { + fn default() -> Self { + Self { + // General settings + include_low_severity: false, + include_info_level: false, + + // Analysis scope + check_secrets: true, + check_code_patterns: true, + check_infrastructure: true, + check_compliance: false, // Disabled by default as it requires more setup + + // Language-specific settings + javascript_enabled: true, + python_enabled: true, + rust_enabled: true, + + // Framework-specific settings + frameworks_to_check: vec![ + "React".to_string(), + "Vue".to_string(), + "Angular".to_string(), + "Next.js".to_string(), + "Vite".to_string(), + "Express".to_string(), + "Django".to_string(), + "Spring Boot".to_string(), + ], + + // File filtering + ignore_patterns: vec![ + "node_modules".to_string(), + ".git".to_string(), + "target".to_string(), + "build".to_string(), + ".next".to_string(), + "coverage".to_string(), + "dist".to_string(), + "*.min.js".to_string(), + "*.bundle.js".to_string(), + "*.map".to_string(), + "*.lock".to_string(), + "*_sample.*".to_string(), + "*example*".to_string(), + "*test*".to_string(), + "*spec*".to_string(), + "*mock*".to_string(), + "*.d.ts".to_string(), // TypeScript declaration files + ], + include_patterns: vec![], // Empty means include all (subject to ignore patterns) + + // Git integration + skip_gitignored_files: true, + downgrade_gitignored_severity: false, + check_git_history: false, // Disabled by default for performance + + // Environment variable handling + check_env_files: true, + warn_on_public_env_vars: true, + sensitive_env_keywords: vec![ + "SECRET".to_string(), + "KEY".to_string(), + "TOKEN".to_string(), + "PASSWORD".to_string(), + "PASS".to_string(), + "AUTH".to_string(), + "API".to_string(), + "PRIVATE".to_string(), + "CREDENTIAL".to_string(), + "CERT".to_string(), + "SSL".to_string(), + "TLS".to_string(), + "OAUTH".to_string(), + "CLIENT_SECRET".to_string(), + "ACCESS_TOKEN".to_string(), + "REFRESH_TOKEN".to_string(), + "DATABASE_URL".to_string(), + "DB_PASS".to_string(), + "STRIPE_SECRET".to_string(), + "AWS_SECRET".to_string(), + "FIREBASE_PRIVATE".to_string(), + ], + + // JavaScript/TypeScript specific + check_package_json: true, + check_node_modules: false, // Usually don't want to scan dependencies + framework_env_prefixes: vec![ + "REACT_APP_".to_string(), + "NEXT_PUBLIC_".to_string(), + "VITE_".to_string(), + "VUE_APP_".to_string(), + "EXPO_PUBLIC_".to_string(), + "NUXT_PUBLIC_".to_string(), + "GATSBY_".to_string(), + "STORYBOOK_".to_string(), + ], + + // Output customization + max_findings_per_file: Some(50), // Prevent overwhelming output + deduplicate_findings: true, + group_by_severity: true, + + // Performance settings + max_file_size_mb: Some(10), // Skip very large files + parallel_analysis: true, + analysis_timeout_seconds: Some(300), // 5 minutes max + } + } +} + +impl SecurityAnalysisConfig { + /// Create a configuration optimized for JavaScript/TypeScript projects + pub fn for_javascript() -> Self { + let mut config = Self::default(); + config.javascript_enabled = true; + config.python_enabled = false; + config.rust_enabled = false; + config.check_package_json = true; + config.frameworks_to_check = vec![ + "React".to_string(), + "Vue".to_string(), + "Angular".to_string(), + "Next.js".to_string(), + "Vite".to_string(), + "Express".to_string(), + "Svelte".to_string(), + "Nuxt".to_string(), + ]; + config + } + + /// Create a configuration optimized for Python projects + pub fn for_python() -> Self { + let mut config = Self::default(); + config.javascript_enabled = false; + config.python_enabled = true; + config.rust_enabled = false; + config.check_package_json = false; + config.frameworks_to_check = vec![ + "Django".to_string(), + "Flask".to_string(), + "FastAPI".to_string(), + "Tornado".to_string(), + ]; + config + } + + /// Create a high-security configuration with strict settings + pub fn high_security() -> Self { + let mut config = Self::default(); + config.include_low_severity = true; + config.include_info_level = true; + config.skip_gitignored_files = false; // Check everything + config.check_git_history = true; + config.warn_on_public_env_vars = true; + config.max_findings_per_file = None; // No limit + config + } + + /// Create a fast configuration for CI/CD pipelines + pub fn fast_ci() -> Self { + let mut config = Self::default(); + config.include_low_severity = false; + config.include_info_level = false; + config.check_compliance = false; + config.check_git_history = false; + config.parallel_analysis = true; + config.max_findings_per_file = Some(20); // Limit output + config.analysis_timeout_seconds = Some(120); // 2 minutes max + config + } + + /// Check if a file should be analyzed based on patterns + pub fn should_analyze_file(&self, file_path: &std::path::Path) -> bool { + let file_path_str = file_path.to_string_lossy(); + let file_name = file_path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + // Check ignore patterns first + for pattern in &self.ignore_patterns { + if self.matches_pattern(pattern, &file_path_str, file_name) { + return false; + } + } + + // If include patterns are specified, file must match at least one + if !self.include_patterns.is_empty() { + return self.include_patterns.iter().any(|pattern| { + self.matches_pattern(pattern, &file_path_str, file_name) + }); + } + + true + } + + /// Check if a pattern matches a file + fn matches_pattern(&self, pattern: &str, file_path: &str, file_name: &str) -> bool { + if pattern.contains('*') { + // Use glob matching for wildcard patterns + glob::Pattern::new(pattern) + .map(|p| p.matches(file_path) || p.matches(file_name)) + .unwrap_or(false) + } else { + // Simple string matching + file_path.contains(pattern) || file_name.contains(pattern) + } + } + + /// Check if an environment variable name appears sensitive + pub fn is_sensitive_env_var(&self, var_name: &str) -> bool { + let var_upper = var_name.to_uppercase(); + self.sensitive_env_keywords.iter() + .any(|keyword| var_upper.contains(keyword)) + } + + /// Check if an environment variable should be public (safe for client-side) + pub fn is_public_env_var(&self, var_name: &str) -> bool { + self.framework_env_prefixes.iter() + .any(|prefix| var_name.starts_with(prefix)) + } + + /// Get the maximum file size to analyze in bytes + pub fn max_file_size_bytes(&self) -> Option { + self.max_file_size_mb.map(|mb| mb * 1024 * 1024) + } +} + +/// Preset configurations for common use cases +#[derive(Debug, Clone, Copy)] +pub enum SecurityConfigPreset { + /// Default balanced configuration + Default, + /// Optimized for JavaScript/TypeScript projects + JavaScript, + /// Optimized for Python projects + Python, + /// High-security configuration with strict settings + HighSecurity, + /// Fast configuration for CI/CD pipelines + FastCI, +} + +impl SecurityConfigPreset { + pub fn to_config(self) -> SecurityAnalysisConfig { + match self { + Self::Default => SecurityAnalysisConfig::default(), + Self::JavaScript => SecurityAnalysisConfig::for_javascript(), + Self::Python => SecurityAnalysisConfig::for_python(), + Self::HighSecurity => SecurityAnalysisConfig::high_security(), + Self::FastCI => SecurityAnalysisConfig::fast_ci(), + } + } +} + +impl From for SecurityAnalysisConfig { + fn from(preset: SecurityConfigPreset) -> Self { + preset.to_config() + } +} \ No newline at end of file diff --git a/src/analyzer/security/core.rs b/src/analyzer/security/core.rs new file mode 100644 index 00000000..edba639f --- /dev/null +++ b/src/analyzer/security/core.rs @@ -0,0 +1,94 @@ +//! # Core Security Analysis Types +//! +//! Base types and functionality shared across all security analyzers. + +use std::collections::HashMap; +use std::path::PathBuf; +use serde::{Deserialize, Serialize}; + +/// Security finding severity levels +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SecuritySeverity { + Critical, + High, + Medium, + Low, + Info, +} + +/// Categories of security findings +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum SecurityCategory { + /// Exposed secrets, API keys, passwords + SecretsExposure, + /// Insecure configuration settings + InsecureConfiguration, + /// Language/framework-specific security patterns + CodeSecurityPattern, + /// Infrastructure and deployment security + InfrastructureSecurity, + /// Authentication and authorization issues + AuthenticationSecurity, + /// Data protection and privacy concerns + DataProtection, + /// Network and communication security + NetworkSecurity, + /// Compliance and regulatory requirements + Compliance, +} + +/// A security finding with details and remediation +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecurityFinding { + pub id: String, + pub title: String, + pub description: String, + pub severity: SecuritySeverity, + pub category: SecurityCategory, + pub file_path: Option, + pub line_number: Option, + pub column_number: Option, + pub evidence: Option, + pub remediation: Vec, + pub references: Vec, + pub cwe_id: Option, + pub compliance_frameworks: Vec, +} + +/// Comprehensive security analysis report +#[derive(Debug, Serialize, Deserialize)] +pub struct SecurityReport { + pub analyzed_at: chrono::DateTime, + pub overall_score: f32, // 0-100, higher is better + pub risk_level: SecuritySeverity, + pub total_findings: usize, + pub findings_by_severity: HashMap, + pub findings_by_category: HashMap, + pub findings: Vec, + pub recommendations: Vec, + pub compliance_status: HashMap, +} + +/// Compliance framework status +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ComplianceStatus { + pub framework: String, + pub coverage: f32, // 0-100% + pub missing_controls: Vec, + pub recommendations: Vec, +} + +/// Base security analyzer trait +pub trait SecurityAnalyzer { + type Config; + type Error: std::error::Error; + + /// Analyze a project for security issues + fn analyze_project(&self, project_root: &std::path::Path) -> Result; + + /// Get the analyzer's configuration + fn config(&self) -> &Self::Config; + + /// Get supported file extensions for this analyzer + fn supported_extensions(&self) -> Vec<&'static str>; +} \ No newline at end of file diff --git a/src/analyzer/security/gitignore.rs b/src/analyzer/security/gitignore.rs new file mode 100644 index 00000000..da70a500 --- /dev/null +++ b/src/analyzer/security/gitignore.rs @@ -0,0 +1,531 @@ +//! # GitIgnore-Aware Security Analysis +//! +//! Comprehensive gitignore parsing and pattern matching for security analysis. +//! This module ensures that secret detection is gitignore-aware and can properly +//! assess whether sensitive files are appropriately protected. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::fs; +use log::{info, warn}; +use regex::Regex; + +/// GitIgnore pattern matcher for security analysis +pub struct GitIgnoreAnalyzer { + patterns: Vec, + project_root: PathBuf, + is_git_repo: bool, +} + +/// A parsed gitignore pattern with matching logic +#[derive(Debug, Clone)] +pub struct GitIgnorePattern { + pub original: String, + pub regex: Regex, + pub is_negation: bool, + pub is_directory_only: bool, + pub is_absolute: bool, // Starts with / + pub pattern_type: PatternType, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum PatternType { + /// Exact filename match (e.g., ".env") + Exact, + /// Wildcard pattern (e.g., "*.log") + Wildcard, + /// Directory pattern (e.g., "node_modules/") + Directory, + /// Path pattern (e.g., "config/*.env") + Path, +} + +/// Result of gitignore analysis for a file +#[derive(Debug, Clone)] +pub struct GitIgnoreStatus { + pub is_ignored: bool, + pub matched_pattern: Option, + pub is_tracked: bool, // Whether file is tracked by git + pub should_be_ignored: bool, // Whether file contains secrets and should be ignored + pub risk_level: GitIgnoreRisk, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum GitIgnoreRisk { + /// File is properly ignored and contains no secrets + Safe, + /// File contains secrets but is properly ignored + Protected, + /// File contains secrets and is NOT ignored (high risk) + Exposed, + /// File contains secrets, not ignored, and is tracked by git (critical risk) + Tracked, +} + +impl GitIgnoreAnalyzer { + pub fn new(project_root: &Path) -> Result { + let project_root = project_root.canonicalize()?; + let is_git_repo = project_root.join(".git").exists(); + + let patterns = if is_git_repo { + Self::parse_gitignore_files(&project_root)? + } else { + Self::create_default_patterns() + }; + + info!("Initialized GitIgnore analyzer with {} patterns for {}", + patterns.len(), project_root.display()); + + Ok(Self { + patterns, + project_root, + is_git_repo, + }) + } + + /// Parse all relevant .gitignore files + fn parse_gitignore_files(project_root: &Path) -> Result, std::io::Error> { + let mut patterns = Vec::new(); + + // Global gitignore patterns for common secret files + patterns.extend(Self::create_default_patterns()); + + // Parse project .gitignore + let gitignore_path = project_root.join(".gitignore"); + if gitignore_path.exists() { + let content = fs::read_to_string(&gitignore_path)?; + patterns.extend(Self::parse_gitignore_content(&content, project_root)?); + info!("Parsed {} patterns from .gitignore", patterns.len()); + } + + // TODO: Parse global gitignore (~/.gitignore_global) + // TODO: Parse .git/info/exclude + + Ok(patterns) + } + + /// Create default patterns for common secret files + fn create_default_patterns() -> Vec { + let default_patterns = [ + ".env", + ".env.local", + ".env.*.local", + ".env.production", + ".env.development", + ".env.staging", + ".env.test", + "*.pem", + "*.key", + "*.p12", + "*.pfx", + "id_rsa", + "id_dsa", + "id_ecdsa", + "id_ed25519", + ".aws/credentials", + ".ssh/", + "secrets/", + "private/", + ]; + + default_patterns.iter() + .filter_map(|pattern| Self::parse_pattern(pattern, &PathBuf::from(".")).ok()) + .collect() + } + + /// Parse gitignore content into patterns + fn parse_gitignore_content(content: &str, _root: &Path) -> Result, std::io::Error> { + let mut patterns = Vec::new(); + + for (line_num, line) in content.lines().enumerate() { + let line = line.trim(); + + // Skip empty lines and comments + if line.is_empty() || line.starts_with('#') { + continue; + } + + match Self::parse_pattern(line, &PathBuf::from(".")) { + Ok(pattern) => patterns.push(pattern), + Err(e) => { + warn!("Failed to parse gitignore pattern on line {}: '{}' - {}", line_num + 1, line, e); + } + } + } + + Ok(patterns) + } + + /// Parse a single gitignore pattern + fn parse_pattern(pattern: &str, _root: &Path) -> Result { + let original = pattern.to_string(); + let mut pattern = pattern.to_string(); + + // Handle negation + let is_negation = pattern.starts_with('!'); + if is_negation { + pattern = pattern[1..].to_string(); + } + + // Handle directory-only patterns + let is_directory_only = pattern.ends_with('/'); + if is_directory_only { + pattern.pop(); + } + + // Handle absolute patterns (starting with /) + let is_absolute = pattern.starts_with('/'); + if is_absolute { + pattern = pattern[1..].to_string(); + } + + // Determine pattern type + let pattern_type = if pattern.contains('/') { + PatternType::Path + } else if pattern.contains('*') || pattern.contains('?') { + PatternType::Wildcard + } else if is_directory_only { + PatternType::Directory + } else { + PatternType::Exact + }; + + // Convert to regex + let regex_pattern = Self::gitignore_to_regex(&pattern, is_absolute, &pattern_type)?; + let regex = Regex::new(®ex_pattern)?; + + Ok(GitIgnorePattern { + original, + regex, + is_negation, + is_directory_only, + is_absolute, + pattern_type, + }) + } + + /// Convert gitignore pattern to regex + fn gitignore_to_regex(pattern: &str, is_absolute: bool, pattern_type: &PatternType) -> Result { + let mut regex = String::new(); + + // Start anchor + if is_absolute { + regex.push_str("^"); + } else { + // Can match anywhere in the path + regex.push_str("(?:^|/)"); + } + + // Process the pattern + for ch in pattern.chars() { + match ch { + '*' => { + // Check if this is a double star (**) + if pattern.contains("**") { + regex.push_str(".*"); + } else { + regex.push_str("[^/]*"); + } + } + '?' => regex.push_str("[^/]"), + '.' => regex.push_str("\\."), + '^' | '$' | '(' | ')' | '[' | ']' | '{' | '}' | '+' | '|' | '\\' => { + regex.push('\\'); + regex.push(ch); + } + '/' => regex.push_str("/"), + _ => regex.push(ch), + } + } + + // Handle directory-only patterns + match pattern_type { + PatternType::Directory => { + regex.push_str("(?:/|$)"); + } + PatternType::Exact => { + regex.push_str("(?:/|$)"); + } + _ => { + regex.push_str("(?:/.*)?$"); + } + } + + Ok(regex) + } + + /// Check if a file path matches gitignore patterns + pub fn analyze_file(&self, file_path: &Path) -> GitIgnoreStatus { + let relative_path = match file_path.strip_prefix(&self.project_root) { + Ok(rel) => rel, + Err(_) => return GitIgnoreStatus { + is_ignored: false, + matched_pattern: None, + is_tracked: false, + should_be_ignored: false, + risk_level: GitIgnoreRisk::Safe, + }, + }; + + let path_str = relative_path.to_string_lossy(); + let file_name = file_path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + // Check against patterns + let mut is_ignored = false; + let mut matched_pattern = None; + + for pattern in &self.patterns { + if pattern.regex.is_match(&path_str) { + if pattern.is_negation { + is_ignored = false; + matched_pattern = None; + } else { + is_ignored = true; + matched_pattern = Some(pattern.original.clone()); + } + } + } + + // Check if file is tracked by git + let is_tracked = if self.is_git_repo { + self.check_git_tracked(file_path) + } else { + false + }; + + // Determine if file should be ignored (contains secrets) + let should_be_ignored = self.should_file_be_ignored(file_path, file_name); + + // Assess risk level + let risk_level = self.assess_risk(is_ignored, is_tracked, should_be_ignored); + + GitIgnoreStatus { + is_ignored, + matched_pattern, + is_tracked, + should_be_ignored, + risk_level, + } + } + + /// Check if file is tracked by git + fn check_git_tracked(&self, file_path: &Path) -> bool { + use std::process::Command; + + Command::new("git") + .args(&["ls-files", "--error-unmatch"]) + .arg(file_path) + .current_dir(&self.project_root) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) + } + + /// Check if a file should be ignored based on its name/path + fn should_file_be_ignored(&self, file_path: &Path, file_name: &str) -> bool { + // Common secret file patterns + let secret_indicators = [ + ".env", ".key", ".pem", ".p12", ".pfx", + "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519", + "credentials", "secrets", "private" + ]; + + let path_str = file_path.to_string_lossy().to_lowercase(); + let file_name_lower = file_name.to_lowercase(); + + secret_indicators.iter().any(|indicator| { + file_name_lower.contains(indicator) || path_str.contains(indicator) + }) + } + + /// Assess the risk level for a file + fn assess_risk(&self, is_ignored: bool, is_tracked: bool, should_be_ignored: bool) -> GitIgnoreRisk { + match (should_be_ignored, is_ignored, is_tracked) { + // File contains secrets + (true, true, _) => GitIgnoreRisk::Protected, // Ignored (good) + (true, false, true) => GitIgnoreRisk::Tracked, // Not ignored AND tracked (critical) + (true, false, false) => GitIgnoreRisk::Exposed, // Not ignored but not tracked (high risk) + // File doesn't contain secrets (or we think it doesn't) + (false, _, _) => GitIgnoreRisk::Safe, + } + } + + /// Get all files that should be analyzed for secrets + pub fn get_files_to_analyze(&self, extensions: &[&str]) -> Result, std::io::Error> { + let mut files = Vec::new(); + self.collect_files_recursive(&self.project_root, extensions, &mut files)?; + + // Filter files that are definitely ignored + let files_to_analyze: Vec = files.into_iter() + .filter(|file| { + let status = self.analyze_file(file); + // Analyze files that are either: + // 1. Not ignored (need to check if they should be) + // 2. Ignored but we want to verify they don't contain secrets anyway + !status.is_ignored || status.should_be_ignored + }) + .collect(); + + info!("Found {} files to analyze for secrets", files_to_analyze.len()); + Ok(files_to_analyze) + } + + /// Recursively collect files with given extensions + fn collect_files_recursive( + &self, + dir: &Path, + extensions: &[&str], + files: &mut Vec + ) -> Result<(), std::io::Error> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + // Skip obviously ignored directories + if let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) { + if matches!(dir_name, ".git" | "node_modules" | "target" | "build" | "dist" | ".next") { + continue; + } + } + + // Check if directory is ignored + let status = self.analyze_file(&path); + if !status.is_ignored { + self.collect_files_recursive(&path, extensions, files)?; + } + } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + if extensions.is_empty() || extensions.contains(&ext) { + files.push(path); + } + } else { + // Files without extensions might still be secret files + files.push(path); + } + } + + Ok(()) + } + + /// Generate recommendations for improving gitignore coverage + pub fn generate_gitignore_recommendations(&self, secret_files: &[PathBuf]) -> Vec { + let mut recommendations = Vec::new(); + let mut patterns_to_add = HashSet::new(); + + for file in secret_files { + let status = self.analyze_file(file); + + if status.risk_level == GitIgnoreRisk::Exposed || status.risk_level == GitIgnoreRisk::Tracked { + if let Some(file_name) = file.file_name().and_then(|n| n.to_str()) { + // Suggest specific patterns + if file_name.starts_with(".env") { + patterns_to_add.insert(".env*".to_string()); + } else if file_name.ends_with(".key") || file_name.ends_with(".pem") { + patterns_to_add.insert("*.key".to_string()); + patterns_to_add.insert("*.pem".to_string()); + } else { + patterns_to_add.insert(file_name.to_string()); + } + } + + if status.risk_level == GitIgnoreRisk::Tracked { + recommendations.push(format!( + "CRITICAL: '{}' contains secrets and is tracked by git! Remove from git history.", + file.display() + )); + } + } + } + + if !patterns_to_add.is_empty() { + recommendations.push("Add these patterns to your .gitignore:".to_string()); + for pattern in patterns_to_add { + recommendations.push(format!(" {}", pattern)); + } + } + + recommendations + } +} + +impl GitIgnoreStatus { + /// Get a human-readable description of the status + pub fn description(&self) -> String { + match self.risk_level { + GitIgnoreRisk::Safe => "File appears safe".to_string(), + GitIgnoreRisk::Protected => format!( + "File contains secrets but is protected (ignored by: {})", + self.matched_pattern.as_deref().unwrap_or("default pattern") + ), + GitIgnoreRisk::Exposed => "File contains secrets but is NOT in .gitignore!".to_string(), + GitIgnoreRisk::Tracked => "CRITICAL: File contains secrets and is tracked by git!".to_string(), + } + } + + /// Get recommended action for this file + pub fn recommended_action(&self) -> String { + match self.risk_level { + GitIgnoreRisk::Safe => "No action needed".to_string(), + GitIgnoreRisk::Protected => "Verify secrets are still necessary".to_string(), + GitIgnoreRisk::Exposed => "Add to .gitignore immediately".to_string(), + GitIgnoreRisk::Tracked => "Remove from git history and add to .gitignore".to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_gitignore_pattern_parsing() { + let patterns = vec![ + ".env", + "*.log", + "/config.json", + "secrets/", + "!important.env", + ]; + + for pattern_str in patterns { + let pattern = GitIgnoreAnalyzer::parse_pattern(pattern_str, &PathBuf::from(".")); + assert!(pattern.is_ok(), "Failed to parse pattern: {}", pattern_str); + } + } + + #[test] + fn test_pattern_matching() { + let temp_dir = TempDir::new().unwrap(); + let analyzer = GitIgnoreAnalyzer::new(temp_dir.path()).unwrap(); + + // Test exact pattern matching + let env_pattern = GitIgnoreAnalyzer::parse_pattern(".env", &PathBuf::from(".")).unwrap(); + assert!(env_pattern.regex.is_match(".env")); + assert!(env_pattern.regex.is_match("subdir/.env")); + assert!(!env_pattern.regex.is_match("not-env")); + } + + #[test] + fn test_nested_directory_matching() { + let temp_dir = TempDir::new().unwrap(); + let analyzer = GitIgnoreAnalyzer::new(temp_dir.path()).unwrap(); + + // Create a pattern for .env files + let env_pattern = GitIgnoreAnalyzer::parse_pattern(".env*", &PathBuf::from(".")).unwrap(); + + // Test various nested scenarios + let test_paths = [ + ".env", + "secrets/.env", + "config/production/.env.local", + "deeply/nested/folder/.env.production", + ]; + + for path in &test_paths { + assert!(env_pattern.regex.is_match(path), "Pattern should match: {}", path); + } + } +} \ No newline at end of file diff --git a/src/analyzer/security/javascript.rs b/src/analyzer/security/javascript.rs new file mode 100644 index 00000000..2febc26c --- /dev/null +++ b/src/analyzer/security/javascript.rs @@ -0,0 +1,1013 @@ +//! # JavaScript/TypeScript Security Analyzer +//! +//! Specialized security analyzer for JavaScript and TypeScript applications. +//! +//! This analyzer focuses on: +//! - Framework-specific secret patterns (React, Vue, Angular, etc.) +//! - Environment variable misuse +//! - Hardcoded API keys in configuration objects +//! - Client-side secret exposure patterns +//! - Common JS/TS anti-patterns + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::fs; +use regex::Regex; +use log::{debug, info}; + +use super::{SecurityError, SecurityFinding, SecuritySeverity, SecurityCategory, SecurityReport, SecurityAnalysisConfig, GitIgnoreAnalyzer, GitIgnoreRisk}; + +/// JavaScript/TypeScript specific security analyzer +pub struct JavaScriptSecurityAnalyzer { + config: SecurityAnalysisConfig, + js_patterns: Vec, + framework_patterns: HashMap>, + env_var_patterns: Vec, + gitignore_analyzer: Option, +} + +/// JavaScript-specific secret pattern +#[derive(Debug, Clone)] +pub struct JavaScriptSecretPattern { + pub id: String, + pub name: String, + pub pattern: Regex, + pub severity: SecuritySeverity, + pub description: String, + pub context_indicators: Vec, // Code context that increases confidence + pub false_positive_indicators: Vec, // Context that suggests false positive +} + +/// Framework-specific patterns +#[derive(Debug, Clone)] +pub struct FrameworkPattern { + pub pattern: Regex, + pub severity: SecuritySeverity, + pub description: String, + pub file_extensions: Vec, +} + +/// Environment variable patterns +#[derive(Debug, Clone)] +pub struct EnvVarPattern { + pub pattern: Regex, + pub severity: SecuritySeverity, + pub description: String, + pub public_prefixes: Vec, // Prefixes that indicate public env vars +} + +impl JavaScriptSecurityAnalyzer { + pub fn new() -> Result { + Self::with_config(SecurityAnalysisConfig::default()) + } + + pub fn with_config(config: SecurityAnalysisConfig) -> Result { + let js_patterns = Self::initialize_js_patterns()?; + let framework_patterns = Self::initialize_framework_patterns()?; + let env_var_patterns = Self::initialize_env_var_patterns()?; + + Ok(Self { + config, + js_patterns, + framework_patterns, + env_var_patterns, + gitignore_analyzer: None, // Will be initialized in analyze_project + }) + } + + /// Analyze a JavaScript/TypeScript project + pub fn analyze_project(&mut self, project_root: &Path) -> Result { + let mut findings = Vec::new(); + + // Initialize gitignore analyzer for comprehensive file protection assessment + let mut gitignore_analyzer = GitIgnoreAnalyzer::new(project_root) + .map_err(|e| SecurityError::AnalysisFailed(format!("Failed to initialize gitignore analyzer: {}", e)))?; + + info!("πŸ” Using gitignore-aware security analysis for {}", project_root.display()); + + // Get JS/TS files using gitignore-aware collection + let js_extensions = ["js", "jsx", "ts", "tsx", "vue", "svelte"]; + let js_files = gitignore_analyzer.get_files_to_analyze(&js_extensions) + .map_err(|e| SecurityError::Io(e))? + .into_iter() + .filter(|file| { + if let Some(ext) = file.extension().and_then(|e| e.to_str()) { + js_extensions.contains(&ext) + } else { + false + } + }) + .collect::>(); + + info!("Found {} JavaScript/TypeScript files to analyze (gitignore-filtered)", js_files.len()); + + // Analyze each file with gitignore context + for file_path in &js_files { + let gitignore_status = gitignore_analyzer.analyze_file(file_path); + let mut file_findings = self.analyze_js_file(file_path)?; + + // Enhance findings with gitignore risk assessment + for finding in &mut file_findings { + self.enhance_finding_with_gitignore_status(finding, &gitignore_status); + } + + findings.extend(file_findings); + } + + // Analyze package.json and other config files with gitignore awareness + findings.extend(self.analyze_config_files_with_gitignore(project_root, &mut gitignore_analyzer)?); + + // Comprehensive environment file analysis with gitignore risk assessment + findings.extend(self.analyze_env_files_with_gitignore(project_root, &mut gitignore_analyzer)?); + + // Generate gitignore recommendations for any secret files found + let secret_files: Vec = findings.iter() + .filter_map(|f| f.file_path.as_ref()) + .cloned() + .collect(); + + let gitignore_recommendations = gitignore_analyzer.generate_gitignore_recommendations(&secret_files); + + // Create report with enhanced recommendations + let mut report = SecurityReport::from_findings(findings); + report.recommendations.extend(gitignore_recommendations); + + Ok(report) + } + + /// Initialize JavaScript-specific secret patterns + fn initialize_js_patterns() -> Result, SecurityError> { + let patterns = vec![ + // Firebase config object + JavaScriptSecretPattern { + id: "js-firebase-config".to_string(), + name: "Firebase Configuration Object".to_string(), + pattern: Regex::new(r#"(?i)(?:const\s+|let\s+|var\s+)?firebaseConfig\s*[=:]\s*\{[^}]*apiKey\s*:\s*["']([^"']+)["'][^}]*\}"#)?, + severity: SecuritySeverity::Medium, + description: "Firebase configuration object with API key detected".to_string(), + context_indicators: vec!["initializeApp".to_string(), "firebase".to_string()], + false_positive_indicators: vec!["example".to_string(), "placeholder".to_string(), "your-api-key".to_string()], + }, + + // Stripe publishable key (less sensitive but should be noted) + JavaScriptSecretPattern { + id: "js-stripe-public-key".to_string(), + name: "Stripe Publishable Key".to_string(), + pattern: Regex::new(r#"(?i)pk_(?:test_|live_)[a-zA-Z0-9]{24,}"#)?, + severity: SecuritySeverity::Low, + description: "Stripe publishable key detected (public but should be environment variable)".to_string(), + context_indicators: vec!["stripe".to_string(), "payment".to_string()], + false_positive_indicators: vec![], + }, + + // Supabase anon key + JavaScriptSecretPattern { + id: "js-supabase-anon-key".to_string(), + name: "Supabase Anonymous Key".to_string(), + pattern: Regex::new(r#"(?i)(?:supabase|anon).*?["\']eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+["\']"#)?, + severity: SecuritySeverity::Medium, + description: "Supabase anonymous key detected".to_string(), + context_indicators: vec!["supabase".to_string(), "createClient".to_string()], + false_positive_indicators: vec!["example".to_string(), "placeholder".to_string()], + }, + + // Auth0 configuration + JavaScriptSecretPattern { + id: "js-auth0-config".to_string(), + name: "Auth0 Configuration".to_string(), + pattern: Regex::new(r#"(?i)(?:domain|clientId)\s*:\s*["']([a-zA-Z0-9.-]+\.auth0\.com|[a-zA-Z0-9]{32})["']"#)?, + severity: SecuritySeverity::Medium, + description: "Auth0 configuration detected".to_string(), + context_indicators: vec!["auth0".to_string(), "webAuth".to_string()], + false_positive_indicators: vec!["example".to_string(), "your-domain".to_string()], + }, + + // Process.env hardcoded values + JavaScriptSecretPattern { + id: "js-hardcoded-env".to_string(), + name: "Hardcoded process.env Assignment".to_string(), + pattern: Regex::new(r#"process\.env\.[A-Z_]+\s*=\s*["']([^"']+)["']"#)?, + severity: SecuritySeverity::High, + description: "Hardcoded assignment to process.env detected".to_string(), + context_indicators: vec![], + false_positive_indicators: vec!["development".to_string(), "test".to_string()], + }, + + // Clerk keys + JavaScriptSecretPattern { + id: "js-clerk-key".to_string(), + name: "Clerk API Key".to_string(), + pattern: Regex::new(r#"(?i)(?:clerk|pk_test_|pk_live_)[a-zA-Z0-9_-]{20,}"#)?, + severity: SecuritySeverity::Medium, + description: "Clerk API key detected".to_string(), + context_indicators: vec!["clerk".to_string(), "ClerkProvider".to_string()], + false_positive_indicators: vec![], + }, + + // Generic API key in object assignment + JavaScriptSecretPattern { + id: "js-api-key-object".to_string(), + name: "API Key in Object Assignment".to_string(), + pattern: Regex::new(r#"(?i)(?:apiKey|api_key|clientSecret|client_secret|accessToken|access_token|secretKey|secret_key)\s*:\s*["']([A-Za-z0-9_-]{20,})["']"#)?, + severity: SecuritySeverity::High, + description: "API key or secret assigned in object literal".to_string(), + context_indicators: vec!["fetch".to_string(), "axios".to_string(), "headers".to_string()], + false_positive_indicators: vec!["process.env".to_string(), "import.meta.env".to_string(), "placeholder".to_string()], + }, + + // Bearer tokens in fetch headers + JavaScriptSecretPattern { + id: "js-bearer-token".to_string(), + name: "Bearer Token in Code".to_string(), + pattern: Regex::new(r#"(?i)(?:authorization|bearer)\s*:\s*["'](?:bearer\s+)?([A-Za-z0-9_-]{20,})["']"#)?, + severity: SecuritySeverity::Critical, + description: "Bearer token hardcoded in authorization header".to_string(), + context_indicators: vec!["fetch".to_string(), "axios".to_string(), "headers".to_string()], + false_positive_indicators: vec!["${".to_string(), "process.env".to_string(), "import.meta.env".to_string()], + }, + + // Database connection strings + JavaScriptSecretPattern { + id: "js-database-url".to_string(), + name: "Database Connection URL".to_string(), + pattern: Regex::new(r#"(?i)(?:mongodb|postgres|mysql)://[^"'\s]+:[^"'\s]+@[^"'\s]+"#)?, + severity: SecuritySeverity::Critical, + description: "Database connection string with credentials detected".to_string(), + context_indicators: vec!["connect".to_string(), "mongoose".to_string(), "client".to_string()], + false_positive_indicators: vec!["localhost".to_string(), "example.com".to_string()], + }, + ]; + + Ok(patterns) + } + + /// Initialize framework-specific patterns + fn initialize_framework_patterns() -> Result>, SecurityError> { + let mut frameworks = HashMap::new(); + + // React patterns + frameworks.insert("react".to_string(), vec![ + FrameworkPattern { + pattern: Regex::new(r#"(?i)react_app_[a-z_]+\s*=\s*["']([^"']+)["']"#)?, + severity: SecuritySeverity::Medium, + description: "React environment variable potentially exposed in build".to_string(), + file_extensions: vec!["js".to_string(), "jsx".to_string(), "ts".to_string(), "tsx".to_string()], + }, + ]); + + // Next.js patterns + frameworks.insert("nextjs".to_string(), vec![ + FrameworkPattern { + pattern: Regex::new(r#"(?i)next_public_[a-z_]+\s*=\s*["']([^"']+)["']"#)?, + severity: SecuritySeverity::Low, + description: "Next.js public environment variable (ensure it should be public)".to_string(), + file_extensions: vec!["js".to_string(), "jsx".to_string(), "ts".to_string(), "tsx".to_string()], + }, + ]); + + // Vite patterns + frameworks.insert("vite".to_string(), vec![ + FrameworkPattern { + pattern: Regex::new(r#"(?i)vite_[a-z_]+\s*=\s*["']([^"']+)["']"#)?, + severity: SecuritySeverity::Medium, + description: "Vite environment variable potentially exposed in build".to_string(), + file_extensions: vec!["js".to_string(), "jsx".to_string(), "ts".to_string(), "tsx".to_string(), "vue".to_string()], + }, + ]); + + Ok(frameworks) + } + + /// Initialize environment variable patterns + fn initialize_env_var_patterns() -> Result, SecurityError> { + let patterns = vec![ + EnvVarPattern { + pattern: Regex::new(r#"process\.env\.([A-Z_]+)"#)?, + severity: SecuritySeverity::Info, + description: "Environment variable usage detected".to_string(), + public_prefixes: vec![ + "REACT_APP_".to_string(), + "NEXT_PUBLIC_".to_string(), + "VITE_".to_string(), + "VUE_APP_".to_string(), + "EXPO_PUBLIC_".to_string(), + "NUXT_PUBLIC_".to_string(), + ], + }, + EnvVarPattern { + pattern: Regex::new(r#"import\.meta\.env\.([A-Z_]+)"#)?, + severity: SecuritySeverity::Info, + description: "Vite environment variable usage detected".to_string(), + public_prefixes: vec!["VITE_".to_string()], + }, + ]; + + Ok(patterns) + } + + /// Collect all JavaScript/TypeScript files + fn collect_js_files(&self, project_root: &Path) -> Result, SecurityError> { + let extensions = ["js", "jsx", "ts", "tsx", "vue", "svelte"]; + let mut files = Vec::new(); + + fn collect_recursive(dir: &Path, extensions: &[&str], files: &mut Vec) -> Result<(), std::io::Error> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + // Skip common build/dependency directories + if let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) { + if matches!(dir_name, "node_modules" | ".git" | "build" | "dist" | ".next" | "coverage") { + continue; + } + } + collect_recursive(&path, extensions, files)?; + } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + if extensions.contains(&ext) { + files.push(path); + } + } + } + Ok(()) + } + + collect_recursive(project_root, &extensions, &mut files)?; + Ok(files) + } + + /// Analyze a single JavaScript/TypeScript file + fn analyze_js_file(&self, file_path: &Path) -> Result, SecurityError> { + let content = fs::read_to_string(file_path)?; + let mut findings = Vec::new(); + + // Check against JavaScript-specific patterns + for pattern in &self.js_patterns { + findings.extend(self.check_pattern_in_content(&content, pattern, file_path)?); + } + + // Check environment variable usage + findings.extend(self.check_env_var_usage(&content, file_path)?); + + Ok(findings) + } + + /// Check a specific pattern in file content + fn check_pattern_in_content( + &self, + content: &str, + pattern: &JavaScriptSecretPattern, + file_path: &Path, + ) -> Result, SecurityError> { + let mut findings = Vec::new(); + + for (line_num, line) in content.lines().enumerate() { + if let Some(captures) = pattern.pattern.captures(line) { + // Check for false positive indicators + if pattern.false_positive_indicators.iter().any(|indicator| { + line.to_lowercase().contains(&indicator.to_lowercase()) + }) { + debug!("Skipping potential false positive in {}: {}", file_path.display(), line.trim()); + continue; + } + + // Extract the secret value and position if captured + let (evidence, column_number) = if captures.len() > 1 { + if let Some(match_) = captures.get(1) { + (Some(match_.as_str().to_string()), Some(match_.start() + 1)) + } else { + (Some(line.trim().to_string()), None) + } + } else { + // For patterns without capture groups, use the full match + if let Some(match_) = captures.get(0) { + (Some(line.trim().to_string()), Some(match_.start() + 1)) + } else { + (Some(line.trim().to_string()), None) + } + }; + + // Check context for confidence scoring + let context_score = self.calculate_context_confidence(content, &pattern.context_indicators); + let adjusted_severity = self.adjust_severity_by_context(pattern.severity.clone(), context_score); + + findings.push(SecurityFinding { + id: format!("{}-{}", pattern.id, line_num), + title: format!("{} Detected", pattern.name), + description: format!("{} (Context confidence: {:.1})", pattern.description, context_score), + severity: adjusted_severity, + category: SecurityCategory::SecretsExposure, + file_path: Some(file_path.to_path_buf()), + line_number: Some(line_num + 1), + column_number, + evidence, + remediation: self.generate_js_remediation(&pattern.id), + references: vec![ + "https://owasp.org/www-project-top-ten/2021/A05_2021-Security_Misconfiguration/".to_string(), + "https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html".to_string(), + ], + cwe_id: Some("CWE-200".to_string()), + compliance_frameworks: vec!["SOC2".to_string(), "GDPR".to_string()], + }); + } + } + + Ok(findings) + } + + /// Check environment variable usage patterns with context-aware detection + fn check_env_var_usage(&self, content: &str, file_path: &Path) -> Result, SecurityError> { + let mut findings = Vec::new(); + + // Determine if this is likely server-side or client-side code + let is_server_side = self.is_server_side_file(file_path, content); + + for pattern in &self.env_var_patterns { + for (line_num, line) in content.lines().enumerate() { + if let Some(captures) = pattern.pattern.captures(line) { + if let Some(var_name) = captures.get(1) { + let var_name = var_name.as_str(); + + // Check if this is a public environment variable + let is_public = pattern.public_prefixes.iter().any(|prefix| var_name.starts_with(prefix)); + + // Context-aware detection: Only flag as problematic if: + // 1. It's a sensitive variable AND + // 2. It's in client-side code AND + // 3. It doesn't have a public prefix + if !is_public && self.is_sensitive_var_name(var_name) && !is_server_side { + // Extract column position from the pattern match + let column_number = captures.get(0) + .map(|m| m.start() + 1); + + findings.push(SecurityFinding { + id: format!("js-env-sensitive-{}", line_num), + title: "Sensitive Environment Variable in Client Code".to_string(), + description: format!("Environment variable '{}' appears sensitive and may be exposed to client in browser code", var_name), + severity: SecuritySeverity::High, + category: SecurityCategory::SecretsExposure, + file_path: Some(file_path.to_path_buf()), + line_number: Some(line_num + 1), + column_number, + evidence: Some(line.trim().to_string()), + remediation: vec![ + "Move sensitive environment variables to server-side code".to_string(), + "Use public environment variable prefixes only for non-sensitive data".to_string(), + "Consider using a backend API endpoint to handle sensitive operations".to_string(), + ], + references: vec![ + "https://nextjs.org/docs/basic-features/environment-variables".to_string(), + "https://vitejs.dev/guide/env-and-mode.html".to_string(), + ], + cwe_id: Some("CWE-200".to_string()), + compliance_frameworks: vec!["SOC2".to_string()], + }); + } + // For server-side code using environment variables, this is GOOD practice - don't flag it + } + } + } + } + + Ok(findings) + } + + /// Analyze configuration files (package.json, etc.) + fn analyze_config_files(&self, project_root: &Path) -> Result, SecurityError> { + let mut findings = Vec::new(); + + // Check package.json for exposed scripts or configs + let package_json = project_root.join("package.json"); + if package_json.exists() { + findings.extend(self.analyze_package_json(&package_json)?); + } + + Ok(findings) + } + + /// Analyze package.json for security issues + fn analyze_package_json(&self, package_json: &Path) -> Result, SecurityError> { + let mut findings = Vec::new(); + let content = fs::read_to_string(package_json)?; + + // Look for hardcoded secrets in scripts or config + if content.contains("REACT_APP_") || content.contains("NEXT_PUBLIC_") || content.contains("VITE_") { + for (line_num, line) in content.lines().enumerate() { + if line.contains("sk_") || line.contains("pk_live_") || line.contains("eyJ") { + findings.push(SecurityFinding { + id: format!("package-json-secret-{}", line_num), + title: "Potential Secret in package.json".to_string(), + description: "Potential API key or token found in package.json".to_string(), + severity: SecuritySeverity::High, + category: SecurityCategory::SecretsExposure, + file_path: Some(package_json.to_path_buf()), + line_number: Some(line_num + 1), + column_number: None, + evidence: Some(line.trim().to_string()), + remediation: vec![ + "Remove secrets from package.json".to_string(), + "Use environment variables instead".to_string(), + "Add package.json to .gitignore if it contains secrets (not recommended)".to_string(), + ], + references: vec![ + "https://docs.npmjs.com/cli/v8/configuring-npm/package-json".to_string(), + ], + cwe_id: Some("CWE-200".to_string()), + compliance_frameworks: vec!["SOC2".to_string()], + }); + } + } + } + + Ok(findings) + } + + /// Analyze environment files + fn analyze_env_files(&self, project_root: &Path) -> Result, SecurityError> { + let mut findings = Vec::new(); + + // Check for .env files that might be accidentally committed + let env_files = [".env", ".env.local", ".env.production", ".env.development"]; + + for env_file in &env_files { + // Skip template/example files + if self.is_template_file(env_file) { + debug!("Skipping template env file: {}", env_file); + continue; + } + + let env_path = project_root.join(env_file); + if env_path.exists() { + // Check if this file should be tracked by git + findings.push(SecurityFinding { + id: format!("env-file-{}", env_file.replace('.', "-")), + title: "Environment File Detected".to_string(), + description: format!("Environment file '{}' found - ensure it's properly protected", env_file), + severity: SecuritySeverity::Medium, + category: SecurityCategory::SecretsExposure, + file_path: Some(env_path), + line_number: None, + column_number: None, + evidence: None, + remediation: vec![ + "Ensure environment files are in .gitignore".to_string(), + "Use .env.example files for documentation".to_string(), + "Never commit actual environment files to version control".to_string(), + ], + references: vec![ + "https://github.com/motdotla/dotenv#should-i-commit-my-env-file".to_string(), + ], + cwe_id: Some("CWE-200".to_string()), + compliance_frameworks: vec!["SOC2".to_string()], + }); + } + } + + Ok(findings) + } + + /// Calculate confidence score based on context indicators + fn calculate_context_confidence(&self, content: &str, indicators: &[String]) -> f32 { + let total_indicators = indicators.len() as f32; + if total_indicators == 0.0 { + return 0.5; // Neutral confidence + } + + let found_indicators = indicators.iter() + .filter(|indicator| content.to_lowercase().contains(&indicator.to_lowercase())) + .count() as f32; + + found_indicators / total_indicators + } + + /// Adjust severity based on context confidence + fn adjust_severity_by_context(&self, base_severity: SecuritySeverity, confidence: f32) -> SecuritySeverity { + match base_severity { + SecuritySeverity::Critical => base_severity, // Keep critical as-is + SecuritySeverity::High => { + if confidence < 0.3 { + SecuritySeverity::Medium + } else { + base_severity + } + } + SecuritySeverity::Medium => { + if confidence > 0.7 { + SecuritySeverity::High + } else if confidence < 0.3 { + SecuritySeverity::Low + } else { + base_severity + } + } + _ => base_severity, + } + } + + /// Check if a variable name appears sensitive + fn is_sensitive_var_name(&self, var_name: &str) -> bool { + let sensitive_keywords = [ + "SECRET", "KEY", "TOKEN", "PASSWORD", "PASS", "AUTH", "API", + "PRIVATE", "CREDENTIAL", "CERT", "SSL", "TLS", "OAUTH", + "CLIENT_SECRET", "ACCESS_TOKEN", "REFRESH_TOKEN", + ]; + + let var_upper = var_name.to_uppercase(); + sensitive_keywords.iter().any(|keyword| var_upper.contains(keyword)) + } + + /// Determine if a JavaScript file is likely server-side or client-side + fn is_server_side_file(&self, file_path: &Path, content: &str) -> bool { + // Check file path indicators + let path_str = file_path.to_string_lossy().to_lowercase(); + let server_path_indicators = [ + "/server/", "/backend/", "/api/", "/routes/", "/controllers/", + "/middleware/", "/models/", "/services/", "/utils/", "/lib/", + "server.js", "server.ts", "index.js", "index.ts", "app.js", "app.ts", + "/pages/api/", "/app/api/", // Next.js API routes + "server-side", "backend", "node_modules", // Clear server indicators + ]; + + let client_path_indicators = [ + "/client/", "/frontend/", "/public/", "/static/", "/assets/", + "/components/", "/views/", "/pages/", "/src/components/", + "client.js", "client.ts", "main.js", "main.ts", "app.tsx", "index.html", + ]; + + // Strong server-side path indicators + if server_path_indicators.iter().any(|indicator| path_str.contains(indicator)) { + return true; + } + + // Strong client-side path indicators + if client_path_indicators.iter().any(|indicator| path_str.contains(indicator)) { + return false; + } + + // Check content for server-side indicators + let server_content_indicators = [ + "require(", "module.exports", "exports.", "__dirname", "__filename", + "process.env", "process.exit", "process.argv", "fs.readFile", "fs.writeFile", + "http.createServer", "express(", "app.listen", "app.use", "app.get", "app.post", + "import express", "import fs", "import path", "import http", "import https", + "cors(", "bodyParser", "middleware", "mongoose.connect", "sequelize", + "jwt.sign", "bcrypt", "crypto.createHash", "nodemailer", "socket.io", + "console.log", // While not exclusive, very common in server code + ]; + + let client_content_indicators = [ + "document.", "window.", "navigator.", "localStorage", "sessionStorage", + "addEventListener", "querySelector", "getElementById", "fetch(", + "XMLHttpRequest", "React.", "ReactDOM", "useState", "useEffect", + "Vue.", "Angular", "svelte", "alert(", "confirm(", "prompt(", + "location.href", "history.push", "router.push", "browser", + ]; + + let server_matches = server_content_indicators.iter() + .filter(|&indicator| content.contains(indicator)) + .count(); + + let client_matches = client_content_indicators.iter() + .filter(|&indicator| content.contains(indicator)) + .count(); + + // If we have server indicators and no clear client indicators, assume server-side + if server_matches > 0 && client_matches == 0 { + return true; + } + + // If we have client indicators and no server indicators, assume client-side + if client_matches > 0 && server_matches == 0 { + return false; + } + + // If mixed or unclear, use a heuristic + if server_matches > client_matches { + return true; + } + + // Default to client-side for mixed/unclear files (safer for security) + false + } + + /// Generate JavaScript-specific remediation advice + fn generate_js_remediation(&self, pattern_id: &str) -> Vec { + match pattern_id { + id if id.contains("firebase") => vec![ + "Move Firebase configuration to environment variables".to_string(), + "Use Firebase App Check for additional security".to_string(), + "Implement proper Firebase security rules".to_string(), + ], + id if id.contains("stripe") => vec![ + "Use environment variables for Stripe keys".to_string(), + "Ensure you're using publishable keys in client-side code".to_string(), + "Keep secret keys on the server side only".to_string(), + ], + id if id.contains("bearer") => vec![ + "Never hardcode bearer tokens in client-side code".to_string(), + "Use secure token storage mechanisms".to_string(), + "Implement token refresh flows".to_string(), + ], + _ => vec![ + "Move secrets to environment variables".to_string(), + "Use server-side API routes for sensitive operations".to_string(), + "Implement proper secret management practices".to_string(), + ], + } + } + + /// Enhance a security finding with gitignore risk assessment + fn enhance_finding_with_gitignore_status( + &self, + finding: &mut SecurityFinding, + gitignore_status: &super::gitignore::GitIgnoreStatus, + ) { + // Adjust severity based on gitignore risk + finding.severity = match gitignore_status.risk_level { + GitIgnoreRisk::Tracked => SecuritySeverity::Critical, // Always critical if tracked + GitIgnoreRisk::Exposed => { + // Upgrade severity if exposed + match &finding.severity { + SecuritySeverity::Medium => SecuritySeverity::High, + SecuritySeverity::Low => SecuritySeverity::Medium, + other => other.clone(), + } + } + GitIgnoreRisk::Protected => { + // Downgrade slightly if protected + match &finding.severity { + SecuritySeverity::Critical => SecuritySeverity::High, + SecuritySeverity::High => SecuritySeverity::Medium, + other => other.clone(), + } + } + GitIgnoreRisk::Safe => finding.severity.clone(), + }; + + // Add gitignore context to description + finding.description.push_str(&format!(" (GitIgnore: {})", gitignore_status.description())); + + // Add gitignore-specific remediation + let gitignore_action = gitignore_status.recommended_action(); + if gitignore_action != "No action needed" { + finding.remediation.insert(0, format!("πŸ”’ GitIgnore: {}", gitignore_action)); + } + + // Add git history warning for tracked files + if gitignore_status.risk_level == GitIgnoreRisk::Tracked { + finding.remediation.insert(1, "⚠️ CRITICAL: Remove this file from git history using git-filter-branch or BFG Repo-Cleaner".to_string()); + finding.remediation.insert(2, "πŸ”‘ Rotate any exposed secrets immediately".to_string()); + } + } + + /// Analyze configuration files with gitignore awareness + fn analyze_config_files_with_gitignore( + &self, + project_root: &Path, + gitignore_analyzer: &mut GitIgnoreAnalyzer, + ) -> Result, SecurityError> { + let mut findings = Vec::new(); + + // Check package.json with gitignore assessment + let package_json = project_root.join("package.json"); + if package_json.exists() { + let gitignore_status = gitignore_analyzer.analyze_file(&package_json); + let mut package_findings = self.analyze_package_json(&package_json)?; + + // Enhance findings with gitignore context + for finding in &mut package_findings { + self.enhance_finding_with_gitignore_status(finding, &gitignore_status); + } + + findings.extend(package_findings); + } + + // Check other common config files + let config_files = [ + "tsconfig.json", + "vite.config.js", + "vite.config.ts", + "next.config.js", + "next.config.ts", + "nuxt.config.js", + "nuxt.config.ts", + // Note: .env.example is now excluded as it's a template file + ]; + + for config_file in &config_files { + // Skip template/example files + if self.is_template_file(config_file) { + debug!("Skipping template config file: {}", config_file); + continue; + } + + let config_path = project_root.join(config_file); + if config_path.exists() { + let gitignore_status = gitignore_analyzer.analyze_file(&config_path); + + // Only analyze if file contains potential secrets or is not properly protected + if gitignore_status.should_be_ignored || !gitignore_status.is_ignored { + if let Ok(content) = fs::read_to_string(&config_path) { + // Basic secret pattern check for config files + if self.contains_potential_secrets(&content) { + let mut finding = SecurityFinding { + id: format!("config-file-{}", config_file.replace('.', "-")), + title: "Potential Secrets in Configuration File".to_string(), + description: format!("Configuration file '{}' may contain secrets", config_file), + severity: SecuritySeverity::Medium, + category: SecurityCategory::SecretsExposure, + file_path: Some(config_path.clone()), + line_number: None, + column_number: None, + evidence: None, + remediation: vec![ + "Review configuration file for hardcoded secrets".to_string(), + "Use environment variables for sensitive configuration".to_string(), + ], + references: vec![], + cwe_id: Some("CWE-200".to_string()), + compliance_frameworks: vec!["SOC2".to_string()], + }; + + self.enhance_finding_with_gitignore_status(&mut finding, &gitignore_status); + findings.push(finding); + } + } + } + } + } + + Ok(findings) + } + + /// Check if a file is a template/example file that should be excluded from security alerts + fn is_template_file(&self, file_name: &str) -> bool { + let template_indicators = [ + "sample", "example", "template", "template.env", "env.template", + "sample.env", "env.sample", "example.env", "env.example", + "examples", "samples", "templates", "demo", "test", + ".env.sample", ".env.example", ".env.template", ".env.demo", ".env.test" + ]; + + let file_name_lower = file_name.to_lowercase(); + + // Check for exact matches or contains patterns + template_indicators.iter().any(|indicator| { + file_name_lower == *indicator || + file_name_lower.contains(indicator) || + file_name_lower.ends_with(indicator) + }) + } + + /// Analyze environment files with comprehensive gitignore risk assessment + fn analyze_env_files_with_gitignore( + &self, + project_root: &Path, + gitignore_analyzer: &mut GitIgnoreAnalyzer, + ) -> Result, SecurityError> { + let mut findings = Vec::new(); + + // Get all potential environment files using gitignore analyzer + let env_files = gitignore_analyzer.get_files_to_analyze(&[]) + .map_err(|e| SecurityError::Io(e))? + .into_iter() + .filter(|file| { + if let Some(file_name) = file.file_name().and_then(|n| n.to_str()) { + // Exclude template/example files from security alerts + if self.is_template_file(file_name) { + debug!("Skipping template file: {}", file_name); + return false; + } + + file_name.starts_with(".env") || + file_name.contains("credentials") || + file_name.contains("secrets") || + file_name.contains("config") || + file_name.ends_with(".key") || + file_name.ends_with(".pem") + } else { + false + } + }) + .collect::>(); + + for env_file in env_files { + let gitignore_status = gitignore_analyzer.analyze_file(&env_file); + let relative_path = env_file.strip_prefix(project_root) + .unwrap_or(&env_file); + + // Create finding based on gitignore risk assessment + let (severity, title, description) = match gitignore_status.risk_level { + GitIgnoreRisk::Tracked => ( + SecuritySeverity::Critical, + "Secret File Tracked by Git".to_string(), + format!("Secret file '{}' is tracked by git and may expose credentials in version history", relative_path.display()), + ), + GitIgnoreRisk::Exposed => ( + SecuritySeverity::High, + "Secret File Not in GitIgnore".to_string(), + format!("Secret file '{}' exists but is not protected by .gitignore", relative_path.display()), + ), + GitIgnoreRisk::Protected => ( + SecuritySeverity::Info, + "Secret File Properly Protected".to_string(), + format!("Secret file '{}' is properly ignored but detected for verification", relative_path.display()), + ), + GitIgnoreRisk::Safe => continue, // Skip files that appear safe + }; + + let mut finding = SecurityFinding { + id: format!("env-file-{}", relative_path.to_string_lossy().replace('/', "-").replace('.', "-")), + title, + description, + severity, + category: SecurityCategory::SecretsExposure, + file_path: Some(env_file.clone()), + line_number: None, + column_number: None, + evidence: None, + remediation: vec![ + "Ensure sensitive files are in .gitignore".to_string(), + "Use .env.example files for documentation".to_string(), + "Never commit actual environment files to version control".to_string(), + ], + references: vec![ + "https://github.com/motdotla/dotenv#should-i-commit-my-env-file".to_string(), + ], + cwe_id: Some("CWE-200".to_string()), + compliance_frameworks: vec!["SOC2".to_string()], + }; + + self.enhance_finding_with_gitignore_status(&mut finding, &gitignore_status); + findings.push(finding); + } + + Ok(findings) + } + + /// Check if content contains potential secrets (basic patterns) + fn contains_potential_secrets(&self, content: &str) -> bool { + let secret_indicators = [ + "sk_", "pk_live_", "eyJ", "AKIA", "-----BEGIN", + "client_secret", "api_key", "access_token", + "private_key", "secret_key", "bearer", + ]; + + let content_lower = content.to_lowercase(); + secret_indicators.iter().any(|indicator| content_lower.contains(&indicator.to_lowercase())) + } +} + +impl SecurityReport { + /// Create a security report from a list of findings + pub fn from_findings(findings: Vec) -> Self { + let total_findings = findings.len(); + let mut findings_by_severity = HashMap::new(); + let mut findings_by_category = HashMap::new(); + + for finding in &findings { + *findings_by_severity.entry(finding.severity.clone()).or_insert(0) += 1; + *findings_by_category.entry(finding.category.clone()).or_insert(0) += 1; + } + + // Calculate overall score (simple implementation) + let score_penalty = findings.iter().map(|f| match f.severity { + SecuritySeverity::Critical => 25.0, + SecuritySeverity::High => 15.0, + SecuritySeverity::Medium => 8.0, + SecuritySeverity::Low => 3.0, + SecuritySeverity::Info => 1.0, + }).sum::(); + + let overall_score = (100.0 - score_penalty).max(0.0); + + // Determine risk level + let risk_level = if findings.iter().any(|f| f.severity == SecuritySeverity::Critical) { + SecuritySeverity::Critical + } else if findings.iter().any(|f| f.severity == SecuritySeverity::High) { + SecuritySeverity::High + } else if findings.iter().any(|f| f.severity == SecuritySeverity::Medium) { + SecuritySeverity::Medium + } else if !findings.is_empty() { + SecuritySeverity::Low + } else { + SecuritySeverity::Info + }; + + Self { + analyzed_at: chrono::Utc::now(), + overall_score, + risk_level, + total_findings, + findings_by_severity, + findings_by_category, + findings, + recommendations: vec![ + "Review all detected secrets and move them to environment variables".to_string(), + "Implement proper secret management practices".to_string(), + "Use framework-specific environment variable patterns correctly".to_string(), + ], + compliance_status: HashMap::new(), + } + } +} \ No newline at end of file diff --git a/src/analyzer/security/mod.rs b/src/analyzer/security/mod.rs new file mode 100644 index 00000000..d56cbab6 --- /dev/null +++ b/src/analyzer/security/mod.rs @@ -0,0 +1,77 @@ +//! # Security Analysis Module +//! +//! Modular security analysis with language-specific analyzers for better threat detection. +//! +//! This module provides a layered approach to security analysis: +//! - Core security patterns (generic) +//! - Language-specific analyzers (JS/TS, Python, etc.) +//! - Framework-specific detection +//! - Context-aware severity assessment + +use std::path::Path; +use thiserror::Error; + +pub mod core; +pub mod javascript; +pub mod patterns; +pub mod config; +pub mod gitignore; + +pub use core::{SecurityAnalyzer, SecurityReport, SecurityFinding, SecuritySeverity, SecurityCategory}; +pub use javascript::JavaScriptSecurityAnalyzer; +pub use patterns::SecretPatternManager; +pub use config::SecurityAnalysisConfig; +pub use gitignore::{GitIgnoreAnalyzer, GitIgnoreStatus, GitIgnoreRisk}; + +/// Modular security analyzer that delegates to language-specific analyzers +pub struct ModularSecurityAnalyzer { + javascript_analyzer: JavaScriptSecurityAnalyzer, + // TODO: Add other language analyzers + // python_analyzer: PythonSecurityAnalyzer, + // rust_analyzer: RustSecurityAnalyzer, +} + +impl ModularSecurityAnalyzer { + pub fn new() -> Result { + Ok(Self { + javascript_analyzer: JavaScriptSecurityAnalyzer::new()?, + }) + } + + pub fn with_config(config: SecurityAnalysisConfig) -> Result { + Ok(Self { + javascript_analyzer: JavaScriptSecurityAnalyzer::with_config(config.clone())?, + }) + } + + /// Analyze a project with appropriate language-specific analyzers + pub fn analyze_project(&mut self, project_root: &Path, languages: &[crate::analyzer::DetectedLanguage]) -> Result { + let mut all_findings = Vec::new(); + + // Analyze JavaScript/TypeScript files + if languages.iter().any(|lang| matches!(lang.name.as_str(), "JavaScript" | "TypeScript" | "JSX" | "TSX")) { + let js_report = self.javascript_analyzer.analyze_project(project_root)?; + all_findings.extend(js_report.findings); + } + + // TODO: Add other language analyzers based on detected languages + + // Combine results into a comprehensive report + Ok(SecurityReport::from_findings(all_findings)) + } +} + +#[derive(Debug, Error)] +pub enum SecurityError { + #[error("Security analysis failed: {0}")] + AnalysisFailed(String), + + #[error("Pattern compilation error: {0}")] + PatternError(#[from] regex::Error), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("JavaScript security analysis error: {0}")] + JavaScriptError(String), +} \ No newline at end of file diff --git a/src/analyzer/security/patterns.rs b/src/analyzer/security/patterns.rs new file mode 100644 index 00000000..8a00258a --- /dev/null +++ b/src/analyzer/security/patterns.rs @@ -0,0 +1,377 @@ +//! # Security Pattern Management +//! +//! Centralized management of security patterns for different tools and services. + +use std::collections::HashMap; +use regex::Regex; + +use super::{SecuritySeverity, SecurityCategory}; + +/// Manager for organizing security patterns by tool/service +pub struct SecretPatternManager { + patterns_by_tool: HashMap>, + generic_patterns: Vec, +} + +/// Tool-specific pattern (e.g., Firebase, Stripe, etc.) +#[derive(Debug, Clone)] +pub struct ToolPattern { + pub tool_name: String, + pub pattern_type: String, // e.g., "api_key", "config_object", "token" + pub pattern: Regex, + pub severity: SecuritySeverity, + pub description: String, + pub public_safe: bool, // Whether this type of key is safe to expose publicly + pub context_keywords: Vec, // Keywords that increase confidence + pub false_positive_keywords: Vec, // Keywords that suggest false positive +} + +/// Generic patterns that apply across tools +#[derive(Debug, Clone)] +pub struct GenericPattern { + pub id: String, + pub name: String, + pub pattern: Regex, + pub severity: SecuritySeverity, + pub category: SecurityCategory, + pub description: String, +} + +impl SecretPatternManager { + pub fn new() -> Result { + let patterns_by_tool = Self::initialize_tool_patterns()?; + let generic_patterns = Self::initialize_generic_patterns()?; + + Ok(Self { + patterns_by_tool, + generic_patterns, + }) + } + + /// Initialize patterns for specific tools/services + fn initialize_tool_patterns() -> Result>, regex::Error> { + let mut patterns = HashMap::new(); + + // Firebase patterns + patterns.insert("firebase".to_string(), vec![ + ToolPattern { + tool_name: "Firebase".to_string(), + pattern_type: "api_key".to_string(), + pattern: Regex::new(r#"(?i)(?:firebase.*)?apiKey\s*[:=]\s*["']([A-Za-z0-9_-]{39})["']"#)?, + severity: SecuritySeverity::Medium, // Firebase API keys are safe to expose + description: "Firebase API key (safe to expose publicly)".to_string(), + public_safe: true, + context_keywords: vec!["firebase".to_string(), "initializeApp".to_string(), "getApps".to_string()], + false_positive_keywords: vec!["example".to_string(), "placeholder".to_string(), "your-api-key".to_string()], + }, + ToolPattern { + tool_name: "Firebase".to_string(), + pattern_type: "service_account".to_string(), + pattern: Regex::new(r#"(?i)(?:type|client_email|private_key).*firebase.*service_account"#)?, + severity: SecuritySeverity::Critical, + description: "Firebase service account credentials (CRITICAL - never expose)".to_string(), + public_safe: false, + context_keywords: vec!["service_account".to_string(), "private_key".to_string(), "client_email".to_string()], + false_positive_keywords: vec![], + }, + ]); + + // Stripe patterns + patterns.insert("stripe".to_string(), vec![ + ToolPattern { + tool_name: "Stripe".to_string(), + pattern_type: "publishable_key".to_string(), + pattern: Regex::new(r#"pk_(?:test_|live_)[a-zA-Z0-9]{24,}"#)?, + severity: SecuritySeverity::Low, // Publishable keys are meant to be public + description: "Stripe publishable key (safe for client-side use)".to_string(), + public_safe: true, + context_keywords: vec!["stripe".to_string(), "publishable".to_string()], + false_positive_keywords: vec![], + }, + ToolPattern { + tool_name: "Stripe".to_string(), + pattern_type: "secret_key".to_string(), + pattern: Regex::new(r#"sk_(?:test_|live_)[a-zA-Z0-9]{24,}"#)?, + severity: SecuritySeverity::Critical, + description: "Stripe secret key (CRITICAL - server-side only)".to_string(), + public_safe: false, + context_keywords: vec!["stripe".to_string(), "secret".to_string()], + false_positive_keywords: vec![], + }, + ToolPattern { + tool_name: "Stripe".to_string(), + pattern_type: "webhook_secret".to_string(), + pattern: Regex::new(r#"whsec_[a-zA-Z0-9]{32,}"#)?, + severity: SecuritySeverity::High, + description: "Stripe webhook endpoint secret".to_string(), + public_safe: false, + context_keywords: vec!["webhook".to_string(), "endpoint".to_string()], + false_positive_keywords: vec![], + }, + ]); + + // Supabase patterns + patterns.insert("supabase".to_string(), vec![ + ToolPattern { + tool_name: "Supabase".to_string(), + pattern_type: "anon_key".to_string(), + pattern: Regex::new(r#"(?i)supabase.*anon.*["\']eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+["\']"#)?, + severity: SecuritySeverity::Medium, // Anon keys are meant for client-side + description: "Supabase anonymous key (safe for client-side use with RLS)".to_string(), + public_safe: true, + context_keywords: vec!["supabase".to_string(), "anon".to_string(), "createClient".to_string()], + false_positive_keywords: vec!["example".to_string(), "placeholder".to_string()], + }, + ToolPattern { + tool_name: "Supabase".to_string(), + pattern_type: "service_role_key".to_string(), + pattern: Regex::new(r#"(?i)supabase.*service.*role.*["\']eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+["\']"#)?, + severity: SecuritySeverity::Critical, + description: "Supabase service role key (CRITICAL - server-side only)".to_string(), + public_safe: false, + context_keywords: vec!["service".to_string(), "role".to_string(), "bypass".to_string()], + false_positive_keywords: vec![], + }, + ]); + + // Clerk patterns + patterns.insert("clerk".to_string(), vec![ + ToolPattern { + tool_name: "Clerk".to_string(), + pattern_type: "publishable_key".to_string(), + pattern: Regex::new(r#"pk_test_[a-zA-Z0-9_-]{60,}|pk_live_[a-zA-Z0-9_-]{60,}"#)?, + severity: SecuritySeverity::Low, + description: "Clerk publishable key (safe for client-side use)".to_string(), + public_safe: true, + context_keywords: vec!["clerk".to_string(), "publishable".to_string()], + false_positive_keywords: vec![], + }, + ToolPattern { + tool_name: "Clerk".to_string(), + pattern_type: "secret_key".to_string(), + pattern: Regex::new(r#"sk_test_[a-zA-Z0-9_-]{60,}|sk_live_[a-zA-Z0-9_-]{60,}"#)?, + severity: SecuritySeverity::Critical, + description: "Clerk secret key (CRITICAL - server-side only)".to_string(), + public_safe: false, + context_keywords: vec!["clerk".to_string(), "secret".to_string()], + false_positive_keywords: vec![], + }, + ]); + + // Auth0 patterns + patterns.insert("auth0".to_string(), vec![ + ToolPattern { + tool_name: "Auth0".to_string(), + pattern_type: "domain".to_string(), + pattern: Regex::new(r#"[a-zA-Z0-9-]+\.auth0\.com"#)?, + severity: SecuritySeverity::Low, + description: "Auth0 domain (safe to expose)".to_string(), + public_safe: true, + context_keywords: vec!["auth0".to_string(), "domain".to_string()], + false_positive_keywords: vec!["example".to_string(), "your-domain".to_string()], + }, + ToolPattern { + tool_name: "Auth0".to_string(), + pattern_type: "client_id".to_string(), + pattern: Regex::new(r#"(?i)(?:client_?id|clientId)\s*[:=]\s*["']([a-zA-Z0-9]{32})["']"#)?, + severity: SecuritySeverity::Low, + description: "Auth0 client ID (safe for client-side use)".to_string(), + public_safe: true, + context_keywords: vec!["auth0".to_string(), "client".to_string()], + false_positive_keywords: vec![], + }, + ToolPattern { + tool_name: "Auth0".to_string(), + pattern_type: "client_secret".to_string(), + pattern: Regex::new(r#"(?i)(?:client_?secret|clientSecret)\s*[:=]\s*["']([a-zA-Z0-9_-]{64})["']"#)?, + severity: SecuritySeverity::Critical, + description: "Auth0 client secret (CRITICAL - server-side only)".to_string(), + public_safe: false, + context_keywords: vec!["auth0".to_string(), "secret".to_string()], + false_positive_keywords: vec![], + }, + ]); + + // AWS patterns + patterns.insert("aws".to_string(), vec![ + ToolPattern { + tool_name: "AWS".to_string(), + pattern_type: "access_key".to_string(), + pattern: Regex::new(r#"AKIA[0-9A-Z]{16}"#)?, + severity: SecuritySeverity::Critical, + description: "AWS access key ID (CRITICAL)".to_string(), + public_safe: false, + context_keywords: vec!["aws".to_string(), "access".to_string(), "key".to_string()], + false_positive_keywords: vec![], + }, + ToolPattern { + tool_name: "AWS".to_string(), + pattern_type: "secret_key".to_string(), + pattern: Regex::new(r#"(?i)(?:aws[_-]?secret|secret[_-]?access[_-]?key)\s*[:=]\s*["']([A-Za-z0-9/+=]{40})["']"#)?, + severity: SecuritySeverity::Critical, + description: "AWS secret access key (CRITICAL)".to_string(), + public_safe: false, + context_keywords: vec!["aws".to_string(), "secret".to_string()], + false_positive_keywords: vec![], + }, + ]); + + // OpenAI patterns + patterns.insert("openai".to_string(), vec![ + ToolPattern { + tool_name: "OpenAI".to_string(), + pattern_type: "api_key".to_string(), + pattern: Regex::new(r#"sk-[A-Za-z0-9]{48}"#)?, + severity: SecuritySeverity::High, + description: "OpenAI API key".to_string(), + public_safe: false, + context_keywords: vec!["openai".to_string(), "gpt".to_string(), "api".to_string()], + false_positive_keywords: vec![], + }, + ]); + + // Vercel patterns + patterns.insert("vercel".to_string(), vec![ + ToolPattern { + tool_name: "Vercel".to_string(), + pattern_type: "token".to_string(), + pattern: Regex::new(r#"(?i)vercel.*token.*["\'][a-zA-Z0-9]{24,}["\']"#)?, + severity: SecuritySeverity::High, + description: "Vercel deployment token".to_string(), + public_safe: false, + context_keywords: vec!["vercel".to_string(), "deploy".to_string()], + false_positive_keywords: vec![], + }, + ]); + + // Netlify patterns + patterns.insert("netlify".to_string(), vec![ + ToolPattern { + tool_name: "Netlify".to_string(), + pattern_type: "access_token".to_string(), + pattern: Regex::new(r#"(?i)netlify.*token.*["\'][a-zA-Z0-9_-]{40,}["\']"#)?, + severity: SecuritySeverity::High, + description: "Netlify access token".to_string(), + public_safe: false, + context_keywords: vec!["netlify".to_string(), "deploy".to_string()], + false_positive_keywords: vec![], + }, + ]); + + Ok(patterns) + } + + /// Initialize generic patterns that apply across tools + fn initialize_generic_patterns() -> Result, regex::Error> { + let patterns = vec![ + GenericPattern { + id: "bearer-token".to_string(), + name: "Bearer Token".to_string(), + pattern: Regex::new(r#"(?i)(?:authorization|bearer)\s*[:=]\s*["'](?:bearer\s+)?([A-Za-z0-9_-]{20,})["']"#)?, + severity: SecuritySeverity::Critical, + category: SecurityCategory::SecretsExposure, + description: "Bearer token in authorization header".to_string(), + }, + GenericPattern { + id: "jwt-token".to_string(), + name: "JWT Token".to_string(), + pattern: Regex::new(r#"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"#)?, + severity: SecuritySeverity::Medium, + category: SecurityCategory::SecretsExposure, + description: "JSON Web Token detected".to_string(), + }, + GenericPattern { + id: "database-url".to_string(), + name: "Database Connection URL".to_string(), + pattern: Regex::new(r#"(?i)(?:mongodb|postgres|mysql)://[^"'\s]+:[^"'\s]+@[^"'\s]+"#)?, + severity: SecuritySeverity::Critical, + category: SecurityCategory::SecretsExposure, + description: "Database connection string with credentials".to_string(), + }, + GenericPattern { + id: "private-key".to_string(), + name: "Private Key".to_string(), + pattern: Regex::new(r#"-----BEGIN (?:RSA |OPENSSH |PGP )?PRIVATE KEY-----"#)?, + severity: SecuritySeverity::Critical, + category: SecurityCategory::SecretsExposure, + description: "Private key detected".to_string(), + }, + GenericPattern { + id: "generic-api-key".to_string(), + name: "Generic API Key".to_string(), + pattern: Regex::new(r#"(?i)(?:api[_-]?key|apikey)\s*[:=]\s*["']([A-Za-z0-9_-]{20,})["']"#)?, + severity: SecuritySeverity::High, + category: SecurityCategory::SecretsExposure, + description: "Generic API key pattern".to_string(), + }, + ]; + + Ok(patterns) + } + + /// Get patterns for a specific tool + pub fn get_tool_patterns(&self, tool: &str) -> Option<&Vec> { + self.patterns_by_tool.get(tool) + } + + /// Get all generic patterns + pub fn get_generic_patterns(&self) -> &Vec { + &self.generic_patterns + } + + /// Get all supported tools + pub fn get_supported_tools(&self) -> Vec { + self.patterns_by_tool.keys().cloned().collect() + } + + /// Get patterns for JavaScript/TypeScript frameworks + pub fn get_js_framework_patterns(&self) -> Vec<&ToolPattern> { + let js_tools = ["firebase", "stripe", "supabase", "clerk", "auth0", "vercel", "netlify"]; + js_tools.iter() + .filter_map(|tool| self.patterns_by_tool.get(*tool)) + .flat_map(|patterns| patterns.iter()) + .collect() + } +} + +impl Default for SecretPatternManager { + fn default() -> Self { + Self::new().expect("Failed to initialize security patterns") + } +} + +impl ToolPattern { + /// Check if this pattern should be treated as a high-confidence match given the context + pub fn assess_confidence(&self, file_content: &str, line_content: &str) -> f32 { + let mut confidence: f32 = 0.5; // Base confidence + + // Increase confidence for context keywords + for keyword in &self.context_keywords { + if file_content.to_lowercase().contains(&keyword.to_lowercase()) { + confidence += 0.2; + } + } + + // Decrease confidence for false positive indicators + for indicator in &self.false_positive_keywords { + if line_content.to_lowercase().contains(&indicator.to_lowercase()) { + confidence -= 0.3; + } + } + + confidence.clamp(0.0, 1.0) + } + + /// Get severity adjusted for public safety + pub fn effective_severity(&self) -> SecuritySeverity { + if self.public_safe { + match &self.severity { + SecuritySeverity::Critical => SecuritySeverity::Medium, + SecuritySeverity::High => SecuritySeverity::Low, + other => other.clone(), + } + } else { + self.severity.clone() + } + } +} \ No newline at end of file diff --git a/src/analyzer/security_analyzer.rs b/src/analyzer/security_analyzer.rs index ce3929b1..39bbed7f 100644 --- a/src/analyzer/security_analyzer.rs +++ b/src/analyzer/security_analyzer.rs @@ -15,12 +15,16 @@ use std::process::Command; use regex::Regex; use serde::{Deserialize, Serialize}; use thiserror::Error; -use log::{info, debug, warn}; +use log::{info, debug}; use rayon::prelude::*; use indicatif::{ProgressBar, ProgressStyle, MultiProgress}; use crate::analyzer::{ProjectAnalysis, DetectedLanguage, DetectedTechnology, EnvVar}; use crate::analyzer::dependency_parser::Language; +use crate::analyzer::security::{ + ModularSecurityAnalyzer, SecurityAnalysisConfig as NewSecurityAnalysisConfig +}; +use crate::analyzer::security::core::SecurityReport as NewSecurityReport; #[derive(Debug, Error)] pub enum SecurityError { @@ -84,6 +88,7 @@ pub struct SecurityFinding { pub category: SecurityCategory, pub file_path: Option, pub line_number: Option, + pub column_number: Option, pub evidence: Option, pub remediation: Vec, pub references: Vec, @@ -209,6 +214,38 @@ impl SecurityAnalyzer { }) } + /// Enhanced security analysis using the new modular approach + pub fn analyze_security_enhanced(&mut self, analysis: &ProjectAnalysis) -> Result { + let start_time = Instant::now(); + info!("Starting enhanced modular security analysis"); + + // Create modular analyzer with JavaScript-specific configuration if JS/TS is detected + let has_javascript = analysis.languages.iter() + .any(|lang| matches!(lang.name.as_str(), "JavaScript" | "TypeScript" | "JSX" | "TSX")); + + let config = if has_javascript { + NewSecurityAnalysisConfig::for_javascript() + } else { + NewSecurityAnalysisConfig::default() + }; + + let mut modular_analyzer = ModularSecurityAnalyzer::with_config(config) + .map_err(|e| SecurityError::AnalysisFailed(e.to_string()))?; + + // Use the modular analyzer + let enhanced_report = modular_analyzer.analyze_project(&analysis.project_root, &analysis.languages) + .map_err(|e| SecurityError::AnalysisFailed(e.to_string()))?; + + // For now, just return the enhanced report as-is + // TODO: Combine with existing findings if needed + + // Build final report + let duration = start_time.elapsed().as_secs_f32(); + info!("Enhanced security analysis completed in {:.1}s - Found {} issues", duration, enhanced_report.total_findings); + + Ok(enhanced_report) + } + /// Perform comprehensive security analysis with appropriate progress for verbosity level pub fn analyze_security(&mut self, analysis: &ProjectAnalysis) -> Result { let start_time = Instant::now(); @@ -599,9 +636,9 @@ impl SecurityAnalyzer { ("Stripe API Key", r"sk_live_[0-9a-zA-Z]{24}", SecuritySeverity::Critical), ("Stripe Publishable Key", r"pk_live_[0-9a-zA-Z]{24}", SecuritySeverity::Medium), - // Database URLs and Passwords - ("Database URL", r#"(?i)(database_url|db_url)["']?\s*[:=]\s*["']?[^"'\s]+"#, SecuritySeverity::High), - ("Password", r#"(?i)(password|passwd|pwd)["']?\s*[:=]\s*["']?[^"']{6,}"#, SecuritySeverity::Medium), + // Database URLs and Passwords - Enhanced to avoid env var false positives + ("Hardcoded Database URL", r#"(?i)(database_url|db_url)["']?\s*[:=]\s*["']?(postgresql|mysql|mongodb)://[^"'\s]+"#, SecuritySeverity::Critical), + ("Hardcoded Password", r#"(?i)(password|passwd|pwd)["']?\s*[:=]\s*["']?[^"']{6,}["']?"#, SecuritySeverity::High), ("JWT Secret", r#"(?i)(jwt[_-]?secret)["']?\s*[:=]\s*["']?[A-Za-z0-9_\-+/=]{20,}"#, SecuritySeverity::High), // Private Keys @@ -613,9 +650,14 @@ impl SecurityAnalyzer { ("Google Cloud Service Account", r#""type":\s*"service_account""#, SecuritySeverity::High), ("Azure Storage Key", r"DefaultEndpointsProtocol=https;AccountName=", SecuritySeverity::High), - // Generic patterns last (lowest priority) - ("Generic API Key", r#"(?i)(api[_-]?key|apikey)["']?\s*[:=]\s*["']?[A-Za-z0-9_\-]{20,}"#, SecuritySeverity::High), - ("Generic Secret", r#"(?i)(secret|token|key)["']?\s*[:=]\s*["']?[A-Za-z0-9_\-+/=]{24,}"#, SecuritySeverity::Medium), + // Client-side exposed environment variables (these are the real security issues) + ("Client-side Exposed Secret", r#"(?i)(REACT_APP_|NEXT_PUBLIC_|VUE_APP_|VITE_)[A-Z_]*(?:SECRET|KEY|TOKEN|PASSWORD|API)[A-Z_]*["']?\s*[:=]\s*["']?[A-Za-z0-9_\-+/=]{10,}"#, SecuritySeverity::High), + + // Hardcoded API keys (not environment variable access) + ("Hardcoded API Key", r#"(?i)(api[_-]?key|apikey)["']?\s*[:=]\s*["']?[A-Za-z0-9_\-]{20,}["']?"#, SecuritySeverity::High), + + // Generic secrets that are clearly hardcoded (not env var access) + ("Hardcoded Secret", r#"(?i)(secret|token)["']?\s*[:=]\s*["']?[A-Za-z0-9_\-+/=]{24,}["']?"#, SecuritySeverity::Medium), ]; patterns.into_iter() @@ -1035,6 +1077,7 @@ impl SecurityAnalyzer { category: SecurityCategory::SecretsExposure, file_path: None, line_number: None, + column_number: None, evidence: Some(format!("Variable: {} = {:?}", env_var.name, env_var.default_value)), remediation: vec![ "Remove default value for sensitive environment variables".to_string(), @@ -1042,7 +1085,7 @@ impl SecurityAnalyzer { "Document required environment variables separately".to_string(), ], references: vec![ - "https://owasp.org/www-project-top-ten/2017/A3_2017-Sensitive_Data_Exposure".to_string(), + "https://owasp.org/www-project-top-ten/2021/A05_2021-Security_Misconfiguration/".to_string(), ], cwe_id: Some("CWE-200".to_string()), compliance_frameworks: vec!["SOC2".to_string(), "GDPR".to_string()], @@ -1195,12 +1238,18 @@ impl SecurityAnalyzer { for (line_num, line) in content.lines().enumerate() { for pattern in &self.secret_patterns { - if let Some(_captures) = pattern.pattern.find(line) { + if let Some(match_) = pattern.pattern.find(line) { // Skip if it looks like a placeholder or example if self.is_likely_placeholder(line) { continue; } + // NEW: Skip if this is legitimate environment variable usage + if self.is_legitimate_env_var_usage(line, file_path) { + debug!("Skipping legitimate env var usage: {}", line.trim()); + continue; + } + // Determine severity based on git status let (severity, additional_remediation) = self.determine_secret_severity(file_path, pattern.severity.clone()); @@ -1241,6 +1290,7 @@ impl SecurityAnalyzer { category: SecurityCategory::SecretsExposure, file_path: Some(file_path.to_path_buf()), line_number: Some(line_num + 1), + column_number: Some(match_.start() + 1), // 1-indexed column position evidence: Some(format!("Line: {}", line.trim())), remediation, references: vec![ @@ -1256,6 +1306,180 @@ impl SecurityAnalyzer { Ok(findings) } + /// Check if a line represents legitimate environment variable usage (not a security issue) + fn is_legitimate_env_var_usage(&self, line: &str, file_path: &Path) -> bool { + let line_trimmed = line.trim(); + + // Check for common legitimate environment variable access patterns + let legitimate_env_patterns = [ + // Node.js/JavaScript patterns + r"process\.env\.[A-Z_]+", + r#"process\.env\[['""][A-Z_]+['"]\]"#, + + // Vite/Modern JS patterns + r"import\.meta\.env\.[A-Z_]+", + r#"import\.meta\.env\[['""][A-Z_]+['"]\]"#, + + // Python patterns + r#"os\.environ\.get\(["'][A-Z_]+["']\)"#, + r#"os\.environ\[["'][A-Z_]+["']\]"#, + r#"getenv\(["'][A-Z_]+["']\)"#, + + // Rust patterns + r#"env::var\("([A-Z_]+)"\)"#, + r#"std::env::var\("([A-Z_]+)"\)"#, + + // Go patterns + r#"os\.Getenv\(["'][A-Z_]+["']\)"#, + + // Java patterns + r#"System\.getenv\(["'][A-Z_]+["']\)"#, + + // Shell/Docker patterns + r"\$\{?[A-Z_]+\}?", + r"ENV [A-Z_]+", + + // Config file access patterns + r"config\.[a-z_]+\.[A-Z_]+", + r"settings\.[A-Z_]+", + r"env\.[A-Z_]+", + ]; + + // Check if the line matches any legitimate environment variable access pattern + for pattern_str in &legitimate_env_patterns { + if let Ok(pattern) = Regex::new(pattern_str) { + if pattern.is_match(line_trimmed) { + // Additional context checks to make sure this is really legitimate + + // Check if this is in a server-side context (not client-side) + if self.is_server_side_file(file_path) { + return true; + } + + // Check if this is NOT a client-side exposed variable + if !self.is_client_side_exposed_env_var(line_trimmed) { + return true; + } + } + } + } + + // Check for assignment vs access - assignments might be setting up environment variables + // which could be legitimate in certain contexts + if self.is_env_var_assignment_context(line_trimmed, file_path) { + return true; + } + + false + } + + /// Check if a file is likely server-side code (vs client-side) + fn is_server_side_file(&self, file_path: &Path) -> bool { + let path_str = file_path.to_string_lossy().to_lowercase(); + let file_name = file_path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_lowercase(); + + // Server-side indicators + let server_indicators = [ + "/server/", "/api/", "/backend/", "/src/app/api/", "/pages/api/", + "/routes/", "/controllers/", "/middleware/", "/models/", + "/lib/", "/utils/", "/services/", "/config/", + "server.js", "index.js", "app.js", "main.js", + ".env", "dockerfile", "docker-compose", + ]; + + // Client-side indicators (these should return false) + let client_indicators = [ + "/public/", "/static/", "/assets/", "/components/", "/pages/", + "/src/components/", "/src/pages/", "/client/", "/frontend/", + "index.html", ".html", "/dist/", "/build/", + "dist/", "build/", "public/", "static/", "assets/", + ]; + + // If it's clearly client-side, return false + if client_indicators.iter().any(|indicator| path_str.contains(indicator)) { + return false; + } + + // If it has server-side indicators, return true + if server_indicators.iter().any(|indicator| + path_str.contains(indicator) || file_name.contains(indicator) + ) { + return true; + } + + // Default to true for ambiguous cases (be conservative about flagging env var usage) + true + } + + /// Check if an environment variable is exposed to client-side (security issue) + fn is_client_side_exposed_env_var(&self, line: &str) -> bool { + let client_prefixes = [ + "REACT_APP_", "NEXT_PUBLIC_", "VUE_APP_", "VITE_", + "GATSBY_", "PUBLIC_", "NUXT_PUBLIC_", + ]; + + client_prefixes.iter().any(|prefix| line.contains(prefix)) + } + + /// Check if this is a legitimate environment variable assignment context + fn is_env_var_assignment_context(&self, line: &str, file_path: &Path) -> bool { + let path_str = file_path.to_string_lossy().to_lowercase(); + let file_name = file_path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_lowercase(); + + // Only very specific configuration files where env var assignments are expected + // Be more restrictive to avoid false positives + let env_config_files = [ + ".env", + "docker-compose.yml", "docker-compose.yaml", + ".env.example", ".env.sample", ".env.template", + ".env.local", ".env.development", ".env.production", ".env.staging", + ]; + + // Check for exact filename matches for .env files (most common legitimate case) + if env_config_files.iter().any(|pattern| file_name == *pattern) { + return true; + } + + // Docker files are also legitimate for environment variable assignment + if file_name.starts_with("dockerfile") || file_name == "dockerfile" { + return true; + } + + // Shell scripts or CI/CD files + if file_name.ends_with(".sh") || + file_name.ends_with(".bash") || + path_str.contains(".github/workflows/") || + path_str.contains(".gitlab-ci") { + return true; + } + + // Lines that are clearly setting up environment variables for child processes + // Only match very specific patterns that indicate legitimate environment setup + let setup_patterns = [ + r"export [A-Z_]+=", // Shell export + r"ENV [A-Z_]+=", // Dockerfile ENV + r"^\s*environment:\s*$", // Docker Compose environment section header + r"^\s*env:\s*$", // Kubernetes env section header + r"process\.env\.[A-Z_]+ =", // Explicitly setting process.env (rare but legitimate) + ]; + + for pattern_str in &setup_patterns { + if let Ok(pattern) = Regex::new(pattern_str) { + if pattern.is_match(line) { + return true; + } + } + } + + false + } + fn is_likely_placeholder(&self, line: &str) -> bool { let placeholder_indicators = [ "example", "placeholder", "your_", "insert_", "replace_", @@ -1559,8 +1783,6 @@ impl SecurityAnalyzer { recommendations.push("Address critical security findings immediately".to_string()); } - // Add more generic recommendations... - recommendations } } @@ -1584,6 +1806,7 @@ mod tests { category: SecurityCategory::SecretsExposure, file_path: None, line_number: None, + column_number: None, evidence: None, remediation: vec![], references: vec![], @@ -1732,4 +1955,149 @@ mod tests { assert!(!analyzer.matches_common_env_patterns("config.json")); assert!(!analyzer.matches_common_env_patterns("package.json")); } + + #[test] + fn test_legitimate_env_var_usage() { + let analyzer = SecurityAnalyzer::new().unwrap(); + + // Create mock file paths + let server_file = Path::new("src/server/config.js"); + let client_file = Path::new("src/components/MyComponent.js"); + + // Test legitimate server-side environment variable usage (should NOT be flagged) + assert!(analyzer.is_legitimate_env_var_usage("const apiKey = process.env.RESEND_API_KEY;", server_file)); + assert!(analyzer.is_legitimate_env_var_usage("const dbUrl = process.env.DATABASE_URL;", server_file)); + assert!(analyzer.is_legitimate_env_var_usage("api_key = os.environ.get('API_KEY')", server_file)); + assert!(analyzer.is_legitimate_env_var_usage("let secret = env::var(\"JWT_SECRET\")?;", server_file)); + + // Test client-side environment variable usage (legitimate if not exposed) + assert!(analyzer.is_legitimate_env_var_usage("const apiUrl = process.env.API_URL;", client_file)); + + // Test client-side exposed variables (these ARE client-side exposed - security issues) + assert!(analyzer.is_client_side_exposed_env_var("process.env.REACT_APP_SECRET_KEY")); + assert!(analyzer.is_client_side_exposed_env_var("process.env.NEXT_PUBLIC_API_SECRET")); + + // Test hardcoded secrets (should NOT be legitimate) + assert!(!analyzer.is_legitimate_env_var_usage("const apiKey = 'sk-1234567890abcdef';", server_file)); + assert!(!analyzer.is_legitimate_env_var_usage("password = 'hardcoded123'", server_file)); + } + + #[test] + fn test_server_vs_client_side_detection() { + let analyzer = SecurityAnalyzer::new().unwrap(); + + // Server-side files + assert!(analyzer.is_server_side_file(Path::new("src/server/app.js"))); + assert!(analyzer.is_server_side_file(Path::new("src/api/users.js"))); + assert!(analyzer.is_server_side_file(Path::new("pages/api/auth.js"))); + assert!(analyzer.is_server_side_file(Path::new("src/lib/database.js"))); + assert!(analyzer.is_server_side_file(Path::new(".env"))); + assert!(analyzer.is_server_side_file(Path::new("server.js"))); + + // Client-side files + assert!(!analyzer.is_server_side_file(Path::new("src/components/Button.jsx"))); + assert!(!analyzer.is_server_side_file(Path::new("public/index.html"))); + assert!(!analyzer.is_server_side_file(Path::new("src/pages/home.js"))); + assert!(!analyzer.is_server_side_file(Path::new("dist/bundle.js"))); + + // Ambiguous files (default to server-side for conservative detection) + assert!(analyzer.is_server_side_file(Path::new("src/utils/helper.js"))); + assert!(analyzer.is_server_side_file(Path::new("config/settings.js"))); + } + + #[test] + fn test_client_side_exposed_env_vars() { + let analyzer = SecurityAnalyzer::new().unwrap(); + + // These should be flagged as client-side exposed (security issues) + assert!(analyzer.is_client_side_exposed_env_var("process.env.REACT_APP_SECRET")); + assert!(analyzer.is_client_side_exposed_env_var("import.meta.env.VITE_API_KEY")); + assert!(analyzer.is_client_side_exposed_env_var("process.env.NEXT_PUBLIC_SECRET")); + assert!(analyzer.is_client_side_exposed_env_var("process.env.VUE_APP_TOKEN")); + + // These should NOT be flagged as client-side exposed + assert!(!analyzer.is_client_side_exposed_env_var("process.env.DATABASE_URL")); + assert!(!analyzer.is_client_side_exposed_env_var("process.env.JWT_SECRET")); + assert!(!analyzer.is_client_side_exposed_env_var("process.env.API_KEY")); + } + + #[test] + fn test_env_var_assignment_context() { + let analyzer = SecurityAnalyzer::new().unwrap(); + + // Configuration files where assignments are legitimate + assert!(analyzer.is_env_var_assignment_context("API_KEY=sk-test123", Path::new(".env"))); + assert!(analyzer.is_env_var_assignment_context("DATABASE_URL=postgres://", Path::new("docker-compose.yml"))); + assert!(analyzer.is_env_var_assignment_context("export SECRET=test", Path::new("setup.sh"))); + + // Regular source files where assignments might be suspicious + assert!(!analyzer.is_env_var_assignment_context("const secret = 'hardcoded'", Path::new("src/app.js"))); + } + + #[test] + fn test_enhanced_secret_patterns() { + let analyzer = SecurityAnalyzer::new().unwrap(); + + // Test that hardcoded secrets are still detected + let hardcoded_patterns = [ + "apikey = 'sk-1234567890abcdef1234567890abcdef12345678'", + "const secret = 'my-super-secret-token-12345678901234567890'", + "password = 'hardcoded123456'", + ]; + + for pattern in &hardcoded_patterns { + let has_secret = analyzer.secret_patterns.iter().any(|sp| sp.pattern.is_match(pattern)); + assert!(has_secret, "Should detect hardcoded secret in: {}", pattern); + } + + // Test that legitimate env var usage is NOT detected as secret + let legitimate_patterns = [ + "const apiKey = process.env.API_KEY;", + "const dbUrl = process.env.DATABASE_URL || 'fallback';", + "api_key = os.environ.get('API_KEY')", + "let secret = env::var(\"JWT_SECRET\")?;", + ]; + + for pattern in &legitimate_patterns { + // These should either not match any secret pattern, or be filtered out by context detection + let matches_old_generic_pattern = pattern.to_lowercase().contains("secret") || + pattern.to_lowercase().contains("key"); + + // Our new patterns should be more specific and not match env var access + let matches_new_patterns = analyzer.secret_patterns.iter() + .filter(|sp| sp.name.contains("Hardcoded")) + .any(|sp| sp.pattern.is_match(pattern)); + + assert!(!matches_new_patterns, "Should NOT detect legitimate env var usage as hardcoded secret: {}", pattern); + } + } + + #[test] + fn test_context_aware_false_positive_reduction() { + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let server_file = temp_dir.path().join("src/server/config.js"); + + // Create directory structure + std::fs::create_dir_all(server_file.parent().unwrap()).unwrap(); + + // Write a file with legitimate environment variable usage + let content = r#" +const config = { + apiKey: process.env.RESEND_API_KEY, + databaseUrl: process.env.DATABASE_URL, + jwtSecret: process.env.JWT_SECRET, + port: process.env.PORT || 3000 +}; +"#; + + std::fs::write(&server_file, content).unwrap(); + + let analyzer = SecurityAnalyzer::new().unwrap(); + let findings = analyzer.analyze_file_for_secrets(&server_file).unwrap(); + + // Should have zero findings because all are legitimate env var usage + assert_eq!(findings.len(), 0, "Should not flag legitimate environment variable usage as security issues"); + } } diff --git a/src/main.rs b/src/main.rs index aa6c9ce3..272b700d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,8 @@ use syncable_cli::{ analyzer::{ self, vulnerability_checker::VulnerabilitySeverity, DetectedTechnology, TechnologyCategory, LibraryType, analyze_monorepo, ProjectCategory, + // Import new modular security types + security::SecuritySeverity, }, cli::{Cli, Commands, ToolsCommand, OutputFormat, SeverityThreshold, DisplayFormat}, config, @@ -1157,10 +1159,10 @@ fn handle_security( // Step 8: Generating Report progress.set_message("Generating security report..."); progress.set_position(100); - let security_report = security_analyzer.analyze_security(&project_analysis) + let security_report = security_analyzer.analyze_security_enhanced(&project_analysis) .map_err(|e| syncable_cli::error::IaCGeneratorError::Analysis( syncable_cli::error::AnalysisError::InvalidStructure( - format!("Security analysis failed: {}", e) + format!("Enhanced security analysis failed: {}", e) ) ))?; @@ -1169,119 +1171,244 @@ fn handle_security( // Format output in the beautiful style requested let output_string = match format { OutputFormat::Table => { + use syncable_cli::analyzer::display::BoxDrawer; + use colored::*; + let mut output = String::new(); - // Beautiful Header - output.push_str("\nπŸ›‘οΈ Security Analysis Results\n"); - output.push_str(&format!("{}\n", "=".repeat(60))); + // Header + output.push_str(&format!("\n{}\n", "πŸ›‘οΈ Security Analysis Results".bright_white().bold())); + output.push_str(&format!("{}\n", "═".repeat(80).bright_blue())); - // Security Summary - output.push_str("\nπŸ“Š SECURITY SUMMARY\n"); - output.push_str(&format!("βœ… Security Score: {:.1}/100\n", security_report.overall_score)); + // Security Score Box + let mut score_box = BoxDrawer::new("Security Summary"); + score_box.add_line("Overall Score:", &format!("{:.0}/100", security_report.overall_score).bright_yellow(), true); + score_box.add_line("Risk Level:", &format!("{:?}", security_report.risk_level).color(match security_report.risk_level { + SecuritySeverity::Critical => "bright_red", + SecuritySeverity::High => "red", + SecuritySeverity::Medium => "yellow", + SecuritySeverity::Low => "green", + SecuritySeverity::Info => "blue", + }), true); + score_box.add_line("Total Findings:", &security_report.total_findings.to_string().cyan(), true); - // Analysis Scope - only show what's actually implemented - output.push_str("\nπŸ” ANALYSIS SCOPE\n"); + // Analysis scope let config_files = security_report.findings.iter() .filter_map(|f| f.file_path.as_ref()) .collect::>() .len(); - let code_files = security_report.findings.iter() - .filter(|f| matches!(f.category, syncable_cli::analyzer::SecurityCategory::CodeSecurityPattern)) - .filter_map(|f| f.file_path.as_ref()) - .collect::>() - .len(); - - output.push_str(&format!("βœ… Secret Detection ({} files analyzed)\n", config_files.max(1))); - output.push_str(&format!("βœ… Environment Variables ({} variables checked)\n", project_analysis.environment_variables.len())); - if code_files > 0 { - output.push_str(&format!("βœ… Code Security Patterns ({} files analyzed)\n", code_files)); - } else { - output.push_str("ℹ️ Code Security Patterns (no applicable files found)\n"); - } - output.push_str("🚧 Infrastructure Security (coming soon)\n"); - output.push_str("🚧 Compliance Frameworks (coming soon)\n"); - - // Findings by Category - output.push_str("\n🎯 FINDINGS BY CATEGORY\n"); + score_box.add_line("Files Analyzed:", &config_files.max(1).to_string().green(), true); + score_box.add_line("Env Variables:", &project_analysis.environment_variables.len().to_string().green(), true); - // Count findings by our categories - let mut secret_findings = 0; - let mut code_findings = 0; - let mut infrastructure_findings = 0; - let mut compliance_findings = 0; + output.push_str(&format!("\n{}\n", score_box.draw())); - for finding in &security_report.findings { - match finding.category { - syncable_cli::analyzer::SecurityCategory::SecretsExposure => secret_findings += 1, - syncable_cli::analyzer::SecurityCategory::CodeSecurityPattern | - syncable_cli::analyzer::SecurityCategory::AuthenticationSecurity | - syncable_cli::analyzer::SecurityCategory::DataProtection => code_findings += 1, - syncable_cli::analyzer::SecurityCategory::InfrastructureSecurity | - syncable_cli::analyzer::SecurityCategory::NetworkSecurity | - syncable_cli::analyzer::SecurityCategory::InsecureConfiguration => infrastructure_findings += 1, - syncable_cli::analyzer::SecurityCategory::Compliance => compliance_findings += 1, - } - } - - output.push_str(&format!("πŸ” Secret Detection: {} findings\n", secret_findings)); - output.push_str(&format!("πŸ”’ Code Security: {} finding{}\n", code_findings, if code_findings == 1 { "" } else { "s" })); - output.push_str(&format!("πŸ—οΈ Infrastructure: {} findings\n", infrastructure_findings)); - output.push_str(&format!("πŸ“‹ Compliance: {} finding{}\n", compliance_findings, if compliance_findings == 1 { "" } else { "s" })); - - // Recommendations - if !security_report.recommendations.is_empty() { - output.push_str("\nπŸ’‘ RECOMMENDATIONS\n"); - for recommendation in &security_report.recommendations { - output.push_str(&format!("β€’ {}\n", recommendation)); - } - } else { - // Add some default recommendations based on the analysis - output.push_str("\nπŸ’‘ RECOMMENDATIONS\n"); - output.push_str("β€’ Enable dependency vulnerability scanning in CI/CD\n"); - output.push_str("β€’ Consider implementing rate limiting for API endpoints\n"); - output.push_str("β€’ Review environment variable security practices\n"); - } - - // If there are actual findings, show them in detail + // Findings in Card Format if !security_report.findings.is_empty() { - output.push_str(&format!("\n{}\n", "=".repeat(60))); - output.push_str("πŸ” DETAILED FINDINGS\n\n"); + // Get terminal width to determine optimal display width + let terminal_width = if let Some((width, _)) = term_size::dimensions() { + width.saturating_sub(10) // Leave some margin + } else { + 120 // Fallback width + }; + + let mut findings_box = BoxDrawer::new("Security Findings"); for (i, finding) in security_report.findings.iter().enumerate() { - let severity_emoji = match finding.severity { - syncable_cli::analyzer::SecuritySeverity::Critical => "🚨", - syncable_cli::analyzer::SecuritySeverity::High => "⚠️ ", - syncable_cli::analyzer::SecuritySeverity::Medium => "⚑", - syncable_cli::analyzer::SecuritySeverity::Low => "ℹ️ ", - syncable_cli::analyzer::SecuritySeverity::Info => "πŸ’‘", + let severity_color = match finding.severity { + SecuritySeverity::Critical => "bright_red", + SecuritySeverity::High => "red", + SecuritySeverity::Medium => "yellow", + SecuritySeverity::Low => "blue", + SecuritySeverity::Info => "green", }; - output.push_str(&format!("{}. {} [{}] {}\n", i + 1, severity_emoji, finding.id, finding.title)); - output.push_str(&format!(" πŸ“ {}\n", finding.description)); - - if let Some(file) = &finding.file_path { - output.push_str(&format!(" πŸ“ File: {}", file.display())); - if let Some(line) = finding.line_number { - output.push_str(&format!(" (line {})", line)); + // Extract relative file path from project root + let file_display = if let Some(file_path) = &finding.file_path { + // Canonicalize both paths to handle symlinks and resolve properly + let canonical_file = file_path.canonicalize().unwrap_or_else(|_| file_path.clone()); + let canonical_project = path.canonicalize().unwrap_or_else(|_| path.clone()); + + // Try to calculate relative path from project root + if let Ok(relative_path) = canonical_file.strip_prefix(&canonical_project) { + format!("./{}", relative_path.display()) + } else { + // Fallback: try to find any common ancestor or use absolute path + let path_str = file_path.to_string_lossy(); + if path_str.starts_with('/') { + // For absolute paths, try to extract meaningful relative portion + if let Some(project_name) = path.file_name().and_then(|n| n.to_str()) { + if let Some(project_idx) = path_str.rfind(project_name) { + let relative_part = &path_str[project_idx + project_name.len()..]; + if relative_part.starts_with('/') { + format!(".{}", relative_part) + } else if !relative_part.is_empty() { + format!("./{}", relative_part) + } else { + format!("./{}", file_path.file_name().unwrap_or_default().to_string_lossy()) + } + } else { + // Last resort: show the full path + path_str.to_string() + } + } else { + // Show full path if we can't determine project context + path_str.to_string() + } + } else { + // For relative paths that don't strip properly, use as-is + if path_str.starts_with("./") { + path_str.to_string() + } else { + format!("./{}", path_str) + } + } } - output.push_str("\n"); - } + } else { + "N/A".to_string() + }; - if let Some(evidence) = &finding.evidence { - output.push_str(&format!(" πŸ” Evidence: {}\n", evidence)); - } + // Parse gitignore status from description (clean colored text) + let gitignore_status = if finding.description.contains("is tracked by git") { + "TRACKED".bright_red().bold() + } else if finding.description.contains("is NOT in .gitignore") { + "EXPOSED".yellow().bold() + } else if finding.description.contains("is protected") || finding.description.contains("properly ignored") { + "SAFE".bright_green().bold() + } else if finding.description.contains("appears safe") { + "OK".bright_blue().bold() + } else { + "UNKNOWN".dimmed() + }; + + // Determine finding type + let finding_type = if finding.title.contains("Environment Variable") { + "ENV VAR" + } else if finding.title.contains("Secret File") { + "SECRET FILE" + } else if finding.title.contains("API Key") || finding.title.contains("Stripe") || finding.title.contains("Firebase") { + "API KEY" + } else if finding.title.contains("Configuration") { + "CONFIG" + } else { + "OTHER" + }; + + // Format position as "line:column" or just "line" if no column info + let position_display = match (finding.line_number, finding.column_number) { + (Some(line), Some(col)) => format!("{}:{}", line, col), + (Some(line), None) => format!("{}", line), + _ => "β€”".to_string(), + }; + + // Card format: File path with intelligent display based on terminal width + let box_margin = 6; // Account for box borders and padding + let available_width = terminal_width.saturating_sub(box_margin); + let max_path_width = available_width.saturating_sub(20); // Leave space for numbering and spacing - if !finding.remediation.is_empty() { - output.push_str(" πŸ”§ Fix:\n"); - for remediation in &finding.remediation { - output.push_str(&format!(" β€’ {}\n", remediation)); + if file_display.len() + 3 <= max_path_width { + // Path fits on one line with numbering + findings_box.add_value_only(&format!("{}. {}", + format!("{}", i + 1).bright_white().bold(), + file_display.cyan().bold() + )); + } else if file_display.len() <= available_width.saturating_sub(4) { + // Path fits on its own line with indentation + findings_box.add_value_only(&format!("{}.", + format!("{}", i + 1).bright_white().bold() + )); + findings_box.add_value_only(&format!(" {}", + file_display.cyan().bold() + )); + } else { + // Path is extremely long - use smart wrapping + findings_box.add_value_only(&format!("{}.", + format!("{}", i + 1).bright_white().bold() + )); + + // Smart path wrapping - prefer breaking at directory separators + let wrap_width = available_width.saturating_sub(4); + let mut remaining = file_display.as_str(); + let mut first_line = true; + + while !remaining.is_empty() { + let prefix = if first_line { " " } else { " " }; + let line_width = wrap_width.saturating_sub(prefix.len()); + + if remaining.len() <= line_width { + // Last chunk fits entirely + findings_box.add_value_only(&format!("{}{}", + prefix, remaining.cyan().bold() + )); + break; + } else { + // Find a good break point (prefer directory separator) + let chunk = &remaining[..line_width]; + let break_point = chunk.rfind('/').unwrap_or(line_width.saturating_sub(1)); + + findings_box.add_value_only(&format!("{}{}", + prefix, chunk[..break_point].cyan().bold() + )); + remaining = &remaining[break_point..]; + if remaining.starts_with('/') { + remaining = &remaining[1..]; // Skip the separator + } + } + first_line = false; } } - output.push_str("\n"); + findings_box.add_value_only(&format!(" {} {} | {} {} | {} {} | {} {}", + "Type:".dimmed(), + finding_type.yellow(), + "Severity:".dimmed(), + format!("{:?}", finding.severity).color(severity_color).bold(), + "Position:".dimmed(), + position_display.bright_cyan(), + "Status:".dimmed(), + gitignore_status + )); + + // Add spacing between findings (except for the last one) + if i < security_report.findings.len() - 1 { + findings_box.add_value_only(""); + } } + + output.push_str(&format!("\n{}\n", findings_box.draw())); + + // GitIgnore Status Legend + let mut legend_box = BoxDrawer::new("Git Status Legend"); + legend_box.add_line(&"TRACKED:".bright_red().bold().to_string(), "File is tracked by git - CRITICAL RISK", false); + legend_box.add_line(&"EXPOSED:".yellow().bold().to_string(), "File contains secrets but not in .gitignore", false); + legend_box.add_line(&"SAFE:".bright_green().bold().to_string(), "File is properly ignored by .gitignore", false); + legend_box.add_line(&"OK:".bright_blue().bold().to_string(), "File appears safe for version control", false); + output.push_str(&format!("\n{}\n", legend_box.draw())); + } else { + let mut no_findings_box = BoxDrawer::new("Security Status"); + no_findings_box.add_value_only(&"βœ… No security issues detected".green()); + no_findings_box.add_value_only("πŸ’‘ Regular security scanning recommended"); + output.push_str(&format!("\n{}\n", no_findings_box.draw())); } + // Recommendations Box + let mut rec_box = BoxDrawer::new("Key Recommendations"); + if !security_report.recommendations.is_empty() { + for (i, rec) in security_report.recommendations.iter().take(5).enumerate() { + // Clean up recommendation text + let clean_rec = rec.replace("Add these patterns to your .gitignore:", "Add to .gitignore:"); + rec_box.add_value_only(&format!("{}. {}", i + 1, clean_rec)); + } + if security_report.recommendations.len() > 5 { + rec_box.add_value_only(&format!("... and {} more recommendations", + security_report.recommendations.len() - 5).dimmed()); + } + } else { + rec_box.add_value_only("βœ… No immediate security concerns detected"); + rec_box.add_value_only("πŸ’‘ Consider implementing dependency scanning"); + rec_box.add_value_only("πŸ’‘ Review environment variable security practices"); + } + output.push_str(&format!("\n{}\n", rec_box.draw())); + output } OutputFormat::Json => { @@ -1300,10 +1427,10 @@ fn handle_security( // Exit with error code if requested and findings exist if fail_on_findings && security_report.total_findings > 0 { let critical_count = security_report.findings_by_severity - .get(&syncable_cli::analyzer::SecuritySeverity::Critical) + .get(&SecuritySeverity::Critical) .unwrap_or(&0); let high_count = security_report.findings_by_severity - .get(&syncable_cli::analyzer::SecuritySeverity::High) + .get(&SecuritySeverity::High) .unwrap_or(&0); if *critical_count > 0 { @@ -1328,7 +1455,7 @@ async fn handle_tools(command: ToolsCommand) -> syncable_cli::Result<()> { match command { ToolsCommand::Status { format, languages } => { - let mut installer = ToolInstaller::new(); + let installer = ToolInstaller::new(); // Determine which languages to check let langs_to_check = if let Some(lang_names) = languages { @@ -1504,7 +1631,7 @@ async fn handle_tools(command: ToolsCommand) -> syncable_cli::Result<()> { } ToolsCommand::Verify { languages, verbose } => { - let mut installer = ToolInstaller::new(); + let installer = ToolInstaller::new(); // Determine which languages to verify let langs_to_verify = if let Some(lang_names) = languages {