diff --git a/.greengate.toml b/.greengate.toml index 11b3874..2ea7c8d 100644 --- a/.greengate.toml +++ b/.greengate.toml @@ -61,3 +61,18 @@ target_dir = "." # current = "output/current.perf" # baseline = "output/baseline.perf" # threshold = 15.0 # % mean-time regression allowed + +# ── Supply chain protection ─────────────────────────────────────────────────── +# Drives `greengate watch-install install`. +# block_phantom_scripts — exit non-zero if a postinstall script creates and +# then deletes a file inside node_modules/ (dropper signature). +# enforce_sandbox — also flag new executables dropped in the project root +# that were not present before the install began. +# allow_postinstall — packages whose postinstall scripts legitimately create +# temp files (e.g. native build tools that compile .node addons). +# Findings from these are reported as warnings, not errors. +# +# [supply_chain] +# block_phantom_scripts = true +# enforce_sandbox = true +# allow_postinstall = ["esbuild", "prisma", "@swc/core"] diff --git a/Cargo.lock b/Cargo.lock index fdeccee..3588dc4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -325,7 +325,7 @@ dependencies = [ [[package]] name = "greengate" -version = "0.2.12" +version = "0.3.0" dependencies = [ "anyhow", "clap", @@ -761,9 +761,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "ring", "rustls-pki-types", diff --git a/Cargo.toml b/Cargo.toml index 50ca04e..4706eeb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "greengate" -version = "0.2.12" +version = "0.3.0" edition = "2024" [dependencies] diff --git a/README.md b/README.md index b14b241..9182d89 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,13 @@ | Command | Purpose | |---|---| +| `greengate watch-install` | **Supply-chain protection** — wraps npm/yarn/pnpm/bun and halts on phantom postinstall droppers | | `greengate scan` | Secrets, PII & AST-based SAST for JS/TS/Python/Go | +| `greengate audit` | OSV dependency vulnerability audit | | `greengate review` | PR Complexity Score + new-code coverage gaps | | `greengate lint` | Kubernetes manifest linting | | `greengate docker-lint` | Dockerfile best-practice checks | | `greengate coverage` | LCOV / Cobertura coverage threshold gate | -| `greengate audit` | OSV dependency vulnerability audit | | `greengate lighthouse` | PageSpeed Insights performance gate | | `greengate reassure` | React component render regression gate | | `greengate sbom` | CycloneDX 1.5 SBOM generation | @@ -68,18 +69,21 @@ cargo install --git https://github.com/thinkgrid-labs/greengate ## Quick start ```bash +# Supply-chain safe install — detects postinstall droppers in real time +greengate watch-install npm ci + # Scan for secrets and run SAST greengate scan +# Audit dependencies for known CVEs +greengate audit + # Analyze a PR: complexity score + new-code coverage gaps greengate review --base main --coverage-file coverage/lcov.info # Enforce 80% minimum coverage greengate coverage --file coverage/lcov.info --min 80 -# Audit dependencies for known CVEs -greengate audit - # Lint Kubernetes manifests greengate lint --dir ./k8s @@ -100,11 +104,18 @@ greengate run curl -sL https://github.com/thinkgrid-labs/greengate/releases/latest/download/greengate-linux-amd64 \ -o /usr/local/bin/greengate && chmod +x /usr/local/bin/greengate +# Replaces plain `npm ci` — halts if a postinstall script drops and deletes a binary +- name: Supply-chain safe install + run: greengate watch-install npm ci + - name: Secret, PII & SAST scan run: greengate scan --annotate env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +- name: Dependency audit (OSV) + run: greengate audit + - name: PR review (complexity + coverage gaps) if: github.event_name == 'pull_request' run: | @@ -120,9 +131,6 @@ greengate run - name: Coverage gate run: greengate coverage --file coverage/lcov.info --min 80 - -- name: Dependency audit - run: greengate audit ``` > See [CI/CD Integration](https://thinkgrid-labs.github.io/greengate/guide/ci-integration) for full GitHub Actions, GitLab CI, Bitbucket, and CircleCI examples. @@ -134,6 +142,11 @@ greengate run Create `.greengate.toml` in your repo root. All fields are optional: ```toml +[supply_chain] +block_phantom_scripts = true +enforce_sandbox = true +allow_postinstall = ["esbuild", "prisma", "@swc/core"] + [scan] exclude_patterns = ["tests/**", "*.test.ts", "vendor/**"] entropy = true @@ -162,8 +175,8 @@ Full guides, command references, and CI examples live in the **[docs site](https - [Getting Started](https://thinkgrid-labs.github.io/greengate/guide/getting-started) - [CI/CD Integration](https://thinkgrid-labs.github.io/greengate/guide/ci-integration) - [Use Cases](https://thinkgrid-labs.github.io/greengate/guide/use-cases) -- **Commands:** [scan](https://thinkgrid-labs.github.io/greengate/commands/scan) · [review](https://thinkgrid-labs.github.io/greengate/commands/review) · [coverage](https://thinkgrid-labs.github.io/greengate/commands/coverage) · [audit](https://thinkgrid-labs.github.io/greengate/commands/audit) · [lint](https://thinkgrid-labs.github.io/greengate/commands/lint) · [docker-lint](https://thinkgrid-labs.github.io/greengate/commands/docker-lint) · [lighthouse](https://thinkgrid-labs.github.io/greengate/commands/lighthouse) · [reassure](https://thinkgrid-labs.github.io/greengate/commands/reassure) · [sbom](https://thinkgrid-labs.github.io/greengate/commands/sbom) · [run](https://thinkgrid-labs.github.io/greengate/commands/run) -- **Reference:** [Config](https://thinkgrid-labs.github.io/greengate/reference/config) · [Secret Patterns](https://thinkgrid-labs.github.io/greengate/reference/secret-patterns) · [SAST Rules](https://thinkgrid-labs.github.io/greengate/reference/sast-rules) · [Output Formats](https://thinkgrid-labs.github.io/greengate/reference/output-formats) · [Exit Codes](https://thinkgrid-labs.github.io/greengate/reference/exit-codes) +- **Commands:** [watch-install](https://thinkgrid-labs.github.io/greengate/commands/watch-install) · [scan](https://thinkgrid-labs.github.io/greengate/commands/scan) · [audit](https://thinkgrid-labs.github.io/greengate/commands/audit) · [review](https://thinkgrid-labs.github.io/greengate/commands/review) · [coverage](https://thinkgrid-labs.github.io/greengate/commands/coverage) · [lint](https://thinkgrid-labs.github.io/greengate/commands/lint) · [docker-lint](https://thinkgrid-labs.github.io/greengate/commands/docker-lint) · [lighthouse](https://thinkgrid-labs.github.io/greengate/commands/lighthouse) · [reassure](https://thinkgrid-labs.github.io/greengate/commands/reassure) · [sbom](https://thinkgrid-labs.github.io/greengate/commands/sbom) · [run](https://thinkgrid-labs.github.io/greengate/commands/run) +- **Reference:** [Config](https://thinkgrid-labs.github.io/greengate/reference/config) · [Secret Patterns](https://thinkgrid-labs.github.io/greengate/reference/secret-patterns) · [SAST Rules](https://thinkgrid-labs.github.io/greengate/reference/sast-rules) · [Output Formats](https://thinkgrid-labs.github.io/greengate/reference/output-formats) · [Exit Codes](https://thinkgrid-labs.github.io/greengate/reference/exit-codes) · [Roadmap](https://thinkgrid-labs.github.io/greengate/reference/roadmap) --- diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index b31b5e7..08f5def 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -2,7 +2,7 @@ import { defineConfig } from 'vitepress' export default defineConfig({ title: 'GreenGate', - description: 'Rust DevOps CLI: secret scanning, AST-based SAST, Kubernetes linting, coverage gates, dependency auditing, and web performance — single zero-dependency binary.', + description: 'Rust DevOps CLI: supply-chain protection, secret scanning, AST-based SAST, Kubernetes linting, coverage gates, dependency auditing, and web performance — single zero-dependency binary.', base: '/greengate/', head: [ @@ -36,11 +36,12 @@ export default defineConfig({ { text: 'Commands', items: [ + { text: '🔒 watch-install', link: '/commands/watch-install' }, { text: 'scan', link: '/commands/scan' }, + { text: 'audit', link: '/commands/audit' }, { text: 'lint', link: '/commands/lint' }, { text: 'docker-lint', link: '/commands/docker-lint' }, { text: 'coverage', link: '/commands/coverage' }, - { text: 'audit', link: '/commands/audit' }, { text: 'install-hooks', link: '/commands/install-hooks' }, { text: 'lighthouse', link: '/commands/lighthouse' }, { text: 'reassure', link: '/commands/reassure' }, @@ -57,6 +58,7 @@ export default defineConfig({ { text: 'Exit Codes', link: '/reference/exit-codes' }, { text: 'Output Formats', link: '/reference/output-formats' }, { text: 'Limitations', link: '/reference/limitations' }, + { text: 'Roadmap', link: '/reference/roadmap' }, ], }, ], diff --git a/docs/commands/watch-install.md b/docs/commands/watch-install.md new file mode 100644 index 0000000..19049d4 --- /dev/null +++ b/docs/commands/watch-install.md @@ -0,0 +1,166 @@ +# watch-install + +> **Supply-chain protection for npm, yarn, pnpm, and bun installs.** + +`greengate watch-install` wraps your package manager and monitors `node_modules/` in real time during the install. If a postinstall script creates a file and then deletes it before the install finishes — the classic dropper signature used in attacks like the [2025 axios compromise](#background) — the install is halted and the offending package is named. + +--- + +## Usage + +```bash +greengate watch-install [ARGS...] +``` + +All arguments after the package manager name are forwarded verbatim: + +```bash +# Drop-in for npm install +greengate watch-install npm install + +# Frozen lockfile (CI) +greengate watch-install npm ci + +# pnpm with flags +greengate watch-install pnpm install --frozen-lockfile + +# yarn +greengate watch-install yarn install --immutable + +# bun +greengate watch-install bun install +``` + +--- + +## Flags + +| Flag | Default | Description | +|---|---|---| +| `--no-fail` | — | Report findings to stderr but exit 0. Useful for audit-only pipelines that are not yet blocking. | + +--- + +## What it detects + +### 1. Phantom files (`PHANTOM`) + +A file is created inside `node_modules/` during a postinstall script and deleted before the install completes. This is the primary dropper signature: + +``` +postinstall → write binary to disk → execute → unlink to hide evidence +``` + +GreenGate polls `node_modules/` every 250 ms while the package manager runs. Any file that appears in one poll and disappears in a later poll is flagged. + +### 2. Executable drops (`EXEC_DROP`) + +A new executable file (one with the execute bit set on Unix, or a `.exe/.bat/.cmd/.ps1` extension on Windows) appears in the project root after the install completes that was not there before. Legitimate package managers never place executables outside `node_modules/`. + +--- + +## Example output + +**Phantom detected:** + +``` +🚨 greengate watch-install: 1 suspicious event(s) detected: + + [PHANTOM ] evil-pkg + path: node_modules/evil-pkg/.postinstall + + Tip: if this package is a known native build tool (e.g. esbuild, swc), + add it to [supply_chain] allow_postinstall in .greengate.toml to suppress. + +Error: watch-install: 1 blocking event(s) detected — halting. +``` + +**Clean install:** + +``` +✅ watch-install: clean — no phantom files or executable drops detected. +``` + +--- + +## Configuration + +All options live under `[supply_chain]` in `.greengate.toml`: + +```toml +[supply_chain] +# Halt the install when a phantom or exec-drop is detected (default: true). +block_phantom_scripts = true + +# Also monitor the project root for new executables (default: true). +enforce_sandbox = true + +# Packages whose postinstall scripts legitimately create temp files. +# Native build tools (esbuild, @swc/core, prisma) compile .node addons +# and may create intermediate files during the build. List them here to +# downgrade their findings to warnings instead of errors. +allow_postinstall = ["esbuild", "prisma", "@swc/core"] +``` + +### allow_postinstall behaviour + +Packages on the allowlist still appear in the output with `[allowlisted — warning only]` so you have a full audit trail, but they do not cause `block_phantom_scripts` to trigger a non-zero exit. + +--- + +## CI usage + +Replace your existing `npm install` / `npm ci` step with `greengate watch-install`: + +```yaml +- name: Install GreenGate + run: | + curl -sL https://github.com/thinkgrid-labs/greengate/releases/latest/download/greengate-linux-amd64 \ + -o /usr/local/bin/greengate && chmod +x /usr/local/bin/greengate + +- name: Supply-chain safe install + run: greengate watch-install npm ci +``` + +For teams not yet ready to block on findings, start in audit-only mode: + +```yaml +- name: Supply-chain audit (non-blocking) + run: greengate watch-install --no-fail npm ci +``` + +--- + +## Layered defence with `audit` + +`watch-install` and `greengate audit` are complementary, not redundant: + +| Tool | When it runs | What it catches | +|---|---|---| +| `greengate audit` | Pre/post install | Known CVEs in OSV database for your lock file | +| `greengate watch-install` | During install | Runtime dropper behaviour that CVE databases cannot see | + +Run both: + +```yaml +- run: greengate watch-install npm ci # catches runtime behaviour +- run: greengate audit # catches known CVEs +``` + +--- + +## Background + +In early 2025, the [axios](https://github.com/axios/axios) npm package was the subject of a supply-chain compromise discussion where attackers targeted postinstall hooks to execute and then self-delete malicious payloads. This attack pattern — write, execute, unlink — leaves no trace in `node_modules/` after the install finishes, making it invisible to static scanners and lock-file diffing tools. + +`watch-install` catches it because the file system events happen _during_ the install window, not after. + +--- + +## Limitations + +- **Pure network exfiltration** — if a postinstall script sends data over the network without writing any file, there is no filesystem event to observe. Pair with network egress controls in CI for defence in depth. +- **Windows** — phantom detection works on Windows via `std::fs` polling, but exec-drop detection uses file-extension heuristics (`.exe`, `.bat`, `.cmd`, `.ps1`) rather than the execute bit. +- **Very fast droppers** — files created and deleted within a single 250 ms poll window may be missed. This is the theoretical lower bound; real-world payloads take longer to download and execute. + +See also: [Roadmap](/reference/roadmap) for planned sandbox-level isolation (`greengate sandbox-install`). diff --git a/docs/guide/ci-integration.md b/docs/guide/ci-integration.md index 48f0642..85be1e2 100644 --- a/docs/guide/ci-integration.md +++ b/docs/guide/ci-integration.md @@ -27,11 +27,18 @@ jobs: -o /usr/local/bin/greengate chmod +x /usr/local/bin/greengate + # Replaces plain `npm ci` — halts if a postinstall script drops and deletes a binary + - name: Supply-chain safe install + run: greengate watch-install npm ci + - name: Secret, PII & SAST Scan env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: greengate scan --annotate + - name: Dependency Audit (OSV) + run: greengate audit + - name: PR Review (Complexity + Coverage Gaps) if: github.event_name == 'pull_request' env: @@ -51,9 +58,6 @@ jobs: - name: Coverage Gate run: greengate coverage --file coverage/lcov.info --min 80 - - - name: Dependency Audit - run: greengate audit ``` ## GitHub Actions — SARIF upload (alternative) diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 9cd220a..4da69e8 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -41,15 +41,21 @@ cargo install --git https://github.com/thinkgrid-labs/greengate ```bash greengate --version -# greengate 0.2.6 +# greengate 0.3.0 ``` ## Quick Start ```bash +# Supply-chain safe install — monitors for postinstall droppers in real time +greengate watch-install npm ci + # Scan for secrets and run SAST on JS/TS files greengate scan +# Audit dependencies for known CVEs +greengate audit + # Analyze a PR: Complexity Score + new-code coverage gaps greengate review --base main --coverage-file coverage/lcov.info @@ -59,9 +65,6 @@ greengate lint --dir ./k8s # Enforce 80% minimum test coverage greengate coverage --file coverage/lcov.info --min 80 -# Audit dependencies for known CVEs -greengate audit - # Install as a git pre-commit hook greengate install-hooks @@ -76,13 +79,14 @@ greengate reassure | Problem | Command | |---|---| +| Postinstall dropper attacking your npm install | `greengate watch-install` | +| Vulnerable dependencies shipping to production | `greengate audit` | | Hardcoded secrets pushed to git | `greengate scan` | | XSS, eval, command injection in JS/TS | `greengate scan` (SAST) | | PR too complex — hard to estimate review time | `greengate review` | | New code added without test coverage | `greengate review --coverage-file lcov.info` | | Kubernetes manifests missing resource limits | `greengate lint` | | Test coverage silently dropping | `greengate coverage` | -| Vulnerable dependencies shipping to production | `greengate audit` | | Secrets committed before anyone notices | `greengate install-hooks` | | Lighthouse score regressing between deploys | `greengate lighthouse` | | React component render performance regressing | `greengate reassure` | diff --git a/docs/guide/use-cases.md b/docs/guide/use-cases.md index d3ed4cc..e2a6292 100644 --- a/docs/guide/use-cases.md +++ b/docs/guide/use-cases.md @@ -4,7 +4,56 @@ Real-world examples of how greengate fits into different team workflows. --- -## 1. SaaS startup — post-deploy Lighthouse health check +## 1. Supply-chain attack defence — protecting npm installs + +> **Context:** In early 2025, attackers targeting the axios npm ecosystem demonstrated the "phantom dropper" attack pattern: a malicious `postinstall` script downloads a binary, executes it to exfiltrate secrets or install persistence, then deletes the binary before `npm install` finishes. The installed `node_modules/` looks identical to a clean install. Lock-file diffing and CVE scanners cannot detect it because no known vulnerability is involved — just a malicious script that leaves no trace. + +**What `greengate watch-install` catches:** + +GreenGate monitors `node_modules/` every 250 ms while the package manager runs. Any file that is created and then deleted within the install window is flagged as a `PHANTOM`. The install is halted before the CI pipeline continues. + +**Drop-in replacement for `npm ci`:** + +```yaml +- name: Install GreenGate + run: | + curl -sL https://github.com/thinkgrid-labs/greengate/releases/latest/download/greengate-linux-amd64 \ + -o /usr/local/bin/greengate && chmod +x /usr/local/bin/greengate + +- name: Supply-chain safe install + run: greengate watch-install npm ci +``` + +**With config for native build tools** (esbuild, prisma, swc legitimately create temp files): + +```toml +# .greengate.toml +[supply_chain] +block_phantom_scripts = true +enforce_sandbox = true +allow_postinstall = ["esbuild", "prisma", "@swc/core"] +``` + +**Layered with `greengate audit`** for defence in depth: + +```yaml +- run: greengate watch-install npm ci # catches runtime dropper behaviour +- run: greengate audit # catches known CVEs in your lock file +``` + +| Layer | What it catches | +|---|---| +| `watch-install` | Postinstall scripts that write+execute+delete a binary | +| `watch-install` | New executables dropped in the project root | +| `audit` | Packages with known CVEs in the OSV database | + +Neither replaces the other. A compromised package can be zero-day (not in OSV yet) but still exhibit dropper behaviour on the filesystem. + +**What it does not catch:** Pure network exfiltration with no file written to disk. For that, combine with outbound network egress controls in your CI runner. + +--- + +## 3. SaaS startup — post-deploy Lighthouse health check **Situation:** You ship helpdeck-landing to production on every merge to `main`. You want to know immediately if a deploy breaks your Lighthouse scores. @@ -42,7 +91,7 @@ jobs: --- -## 2. SaaS startup — pre-merge gate with staging environment +## 4. SaaS startup — pre-merge gate with staging environment **Situation:** You have a staging server. You want to block merges if a PR degrades performance _before_ it hits production. @@ -87,7 +136,7 @@ jobs: --- -## 3. Preventing secrets from being committed +## 4. Preventing secrets from being committed **Situation:** A developer accidentally hardcodes an API key and pushes it. You want to catch this before it ever reaches the remote. @@ -110,7 +159,7 @@ This installs a pre-commit hook that scans staged files on every `git commit`. I --- -## 4. Kubernetes team — manifest quality gate +## 5. Kubernetes team — manifest quality gate **Situation:** Your team ships microservices with Kubernetes manifests. You want to block deployments that are missing resource limits, health probes, or use the `latest` image tag. @@ -134,7 +183,7 @@ greengate lint # reads config automatically --- -## 5. Full security pipeline — Next.js / React app +## 6. Full security pipeline — Next.js / React app **Situation:** You run a React frontend with a Node.js backend. You want secrets, SAST, dependency CVEs, and coverage all gated in one pipeline. @@ -164,7 +213,7 @@ greengate lint # reads config automatically --- -## 6. Solo developer — minimal setup +## 7. Solo developer — minimal setup **Situation:** You're shipping solo, no staging environment. You want the basics without overhead. @@ -203,7 +252,7 @@ greengate install-hooks # catch secrets before they leave your machine --- -## 7. Engineering team — PR review intelligence gate +## 8. Engineering team — PR review intelligence gate **Situation:** PRs are going out with untested new code and no consistent estimate of review effort. You want instant feedback on every PR: exactly which newly added lines lack test coverage, and an estimated review time so reviewers can plan their load. diff --git a/docs/reference/roadmap.md b/docs/reference/roadmap.md new file mode 100644 index 0000000..3125044 --- /dev/null +++ b/docs/reference/roadmap.md @@ -0,0 +1,75 @@ +# Roadmap + +This page tracks planned features and the reasoning behind their prioritisation. + +--- + +## Shipped + +### v0.2.x — Supply chain: `watch-install` + +`greengate watch-install` wraps any package manager (`npm`, `yarn`, `pnpm`, `bun`) and monitors `node_modules/` in real time during the install. It detects the two most common runtime attack signatures: + +- **Phantom files** — postinstall scripts that write a binary, execute it, and delete it before the install finishes (dropper pattern, as seen in the 2025 axios-ecosystem compromise) +- **Executable drops** — new executable files placed in the project root that were not present before the install began + +Controlled via `[supply_chain]` in `.greengate.toml`. See the [watch-install command reference](/commands/watch-install) for full details. + +--- + +## Planned + +### Feature 2 — `sandbox-install` (Zero-Trust Package Runner) + +**Status:** Planned — implementation deferred pending architectural decision. + +**What it does:** + +`greengate sandbox-install` would go one level deeper than `watch-install`. Instead of observing what a package manager does on the host filesystem, it would run the entire install inside an isolated container, then extract only the verified output: + +1. Pull a minimal `node:alpine` image via the Docker API +2. Mount the project's `package.json` / lock file read-only +3. Run `npm ci` (or equivalent) inside the container with `--network=none` (no outbound network access during install) +4. Cryptographically hash the container's `node_modules/` output and compare it against the expected dependency tree from the lock file +5. Extract only the verified `node_modules/` to the host + +This provides a stronger guarantee than `watch-install` because: + +| | `watch-install` | `sandbox-install` | +|---|---|---| +| Phantom file detection | Yes | Yes (no host filesystem to write to) | +| Network exfiltration during install | No | Yes — `--network=none` blocks it | +| Host process isolation | No | Yes — install never runs on host | +| Requires Docker | No | Yes | + +**Why it is deferred:** + +The primary implementation dependency, `bollard` (the Rust Docker API crate), is fully async and requires a `tokio` runtime. GreenGate is currently synchronous (`rayon`-based). Adding `tokio` is a non-trivial architectural change and binary size increase that needs careful consideration before v1.0. + +Additionally, `sandbox-install` requires Docker to be running on the host — which breaks GreenGate's zero-runtime-dependency guarantee for that command. The plan is to make it gracefully fail with a clear error when Docker is not present, rather than requiring it globally. + +**Tracking:** Contributions welcome. See [CONTRIBUTING.md](https://github.com/ThinkGrid-Labs/greengate/blob/main/CONTRIBUTING.md) for architecture guidance. + +--- + +### Feature 3 — SBOM-based install verification + +Cross-reference the post-install `node_modules/` tree against the project's CycloneDX SBOM (`greengate sbom`) to detect packages that installed without appearing in the declared dependency graph. Complements `watch-install` for detecting dependency confusion attacks. + +--- + +### Feature 4 — `scan` improvements + +- **Python taint tracking** — extend the existing JS/TS taint engine to Python (Flask/Django request sources → SQL/command injection sinks) +- **Go taint tracking** — similar, targeting `net/http` request sources +- **Rust SAST** — `unsafe` block detection, `std::process::Command` with unsanitised input + +--- + +## Not planned + +| Feature | Reason | +|---|---| +| Native Windows exec-drop detection (beyond extension heuristics) | Requires PE parsing or Windows API calls — out of scope for a CLI tool | +| CI/CD platform plugins (GitHub Action, GitLab Component) | Tracked separately from the core binary | +| Web UI / dashboard | Out of scope — GreenGate is intentionally a CLI/CI tool | diff --git a/src/main.rs b/src/main.rs index 01059b4..0f49fdb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -137,6 +137,25 @@ enum Commands { #[arg(short, long)] output: Option, }, + /// Wraps a package manager install and monitors for phantom dependencies + /// (postinstall scripts that drop and delete binaries) and executable drops. + /// Example: greengate watch-install npm install + /// greengate watch-install pnpm install --frozen-lockfile + WatchInstall { + /// Package manager to wrap: npm, yarn, pnpm, or bun + #[arg(value_name = "PACKAGE_MANAGER")] + package_manager: String, + /// Arguments forwarded verbatim to the package manager + #[arg( + trailing_var_arg = true, + allow_hyphen_values = true, + value_name = "ARGS" + )] + args: Vec, + /// Report findings but do not exit non-zero (overrides config block_phantom_scripts) + #[arg(long)] + no_fail: bool, + }, /// Analyzes a PR diff: outputs a Complexity Score and new-code coverage gaps Review { /// Diff base ref: commit, branch, or tag (default: HEAD~1) @@ -344,6 +363,19 @@ fn main() -> anyhow::Result<()> { Commands::Sbom { output } => { modules::sbom::run_sbom(output.as_deref())?; } + Commands::WatchInstall { + package_manager, + args, + no_fail, + } => { + modules::watch_install::run_watch_install(modules::watch_install::WatchInstallOpts { + package_manager, + args, + block_phantom_scripts: !no_fail && cfg.supply_chain.block_phantom_scripts, + enforce_sandbox: cfg.supply_chain.enforce_sandbox, + allow_postinstall: cfg.supply_chain.allow_postinstall, + })?; + } Commands::Review { base, staged, diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 9ae44f0..e75e230 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -16,3 +16,4 @@ pub mod sbom; pub mod scanner; pub mod taint; pub mod watch; +pub mod watch_install; diff --git a/src/modules/watch_install.rs b/src/modules/watch_install.rs new file mode 100644 index 0000000..ed16ad7 --- /dev/null +++ b/src/modules/watch_install.rs @@ -0,0 +1,583 @@ +//! `greengate watch-install` — wraps a package-manager install command and +//! monitors `node_modules/` in real-time for two supply-chain attack signatures: +//! +//! 1. **Phantom files** — a file is created inside `node_modules/` during a +//! postinstall script and then deleted before the install finishes. This is +//! the textbook dropper pattern (write binary → execute → unlink to hide +//! evidence). +//! +//! 2. **Executable drops** — a new executable file appears in the project root +//! that was not present before the install began. Legitimate package managers +//! never place executables outside `node_modules/`. +//! +//! Implementation uses a 250 ms polling thread — zero extra dependencies. + +use crate::utils::terminal; +use anyhow::Result; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime}; + +// ── Public types ────────────────────────────────────────────────────────────── + +pub struct WatchInstallOpts { + /// "npm", "yarn", "pnpm", or "bun" + pub package_manager: String, + /// Arguments forwarded verbatim to the package manager + pub args: Vec, + /// Exit non-zero if any phantom file or executable drop is detected + pub block_phantom_scripts: bool, + /// Also monitor the project root for unexpected executable drops + pub enforce_sandbox: bool, + /// Package names whose postinstall scripts may legitimately create temp files + pub allow_postinstall: Vec, +} + +#[derive(Debug)] +pub struct PhantomFinding { + pub path: PathBuf, + pub package: String, + pub kind: FindingKind, +} + +#[derive(Debug)] +pub enum FindingKind { + /// Created inside node_modules/ during install, then deleted before it finished + EphemeralFile, + /// New executable file persisted in the project root after install completed + ExecutableDrop, +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +pub fn run_watch_install(opts: WatchInstallOpts) -> Result<()> { + terminal::info(&format!( + "watch-install: wrapping `{} {}`", + opts.package_manager, + opts.args.join(" "), + )); + + // 1. Pre-install snapshots — taken before the child process starts so we + // have a clean baseline to diff against. + let pre_modules = snapshot_dir("node_modules"); + let pre_root = snapshot_root_executables("."); + + // 2. Shared state updated by the polling thread. + let state = Arc::new(Mutex::new(WatchState::new(pre_modules))); + let done = Arc::new(AtomicBool::new(false)); + + // 3. Background polling thread — checks node_modules/ every 250 ms. + // Started *before* the child process so we don't miss early events. + let state_clone = Arc::clone(&state); + let done_clone = Arc::clone(&done); + let poll_thread = std::thread::spawn(move || { + while !done_clone.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(250)); + let current = snapshot_dir("node_modules"); + state_clone.lock().unwrap().update(current); + } + // One final scan after the child exits to catch any last-moment events. + let current = snapshot_dir("node_modules"); + state_clone.lock().unwrap().update(current); + }); + + // 4. Run the wrapped package manager and inherit its stdio so the developer + // sees normal install output. + let pm_status = std::process::Command::new(&opts.package_manager) + .args(&opts.args) + .status(); + + // 5. Signal the polling thread to stop and wait for it to finish its + // final scan before we read the accumulated findings. + done.store(true, Ordering::Relaxed); + let _ = poll_thread.join(); + + // 6. Check whether the package manager itself reported an error. + match &pm_status { + Err(e) => { + return Err(anyhow::anyhow!( + "Failed to launch `{}`: {}. Is it installed and on PATH?", + opts.package_manager, + e + )); + } + Ok(status) if !status.success() => { + terminal::warn(&format!( + "`{}` exited with non-zero status — install may be incomplete.", + opts.package_manager + )); + } + _ => {} + } + + // 7. Collect all findings from the watcher (allowlisted findings are retained + // so they can be shown as warnings, but they do not trigger a failure). + let mut watch_state = state.lock().unwrap(); + let mut findings: Vec = watch_state.phantoms(); + + // 8. Detect new executables in the project root (exec-drop detection). + if opts.enforce_sandbox { + let post_root = snapshot_root_executables("."); + for path in post_root.keys() { + if pre_root.contains_key(path) { + continue; // existed before install + } + // Ignore generated lock files and the node_modules directory itself. + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if matches!( + name, + "node_modules" + | "package-lock.json" + | "yarn.lock" + | "pnpm-lock.yaml" + | "bun.lockb" + ) { + continue; + } + if is_executable(path) { + findings.push(PhantomFinding { + package: "".to_string(), + path: path.clone(), + kind: FindingKind::ExecutableDrop, + }); + } + } + } + + // 9. Emit all findings; allowlisted ones are shown as warnings only. + report_findings(&findings, &opts.allow_postinstall); + + // Fail only on findings that are NOT covered by allow_postinstall. + let blocking_count = findings + .iter() + .filter(|f| !is_allowlisted(&f.package, &opts.allow_postinstall)) + .count(); + + if blocking_count > 0 && opts.block_phantom_scripts { + return Err(anyhow::anyhow!( + "watch-install: {} blocking event(s) detected — halting.", + blocking_count + )); + } + + if findings.is_empty() { + terminal::success("watch-install: clean — no phantom files or executable drops detected."); + } + + Ok(()) +} + +// ── Watch state ─────────────────────────────────────────────────────────────── + +/// Tracks `node_modules/` across polls to identify phantom files. +struct WatchState { + /// Files present in the most recent poll. + prev: HashMap, + /// Files that existed *before* the install started — not flagged if deleted. + pre_install: HashSet, + /// Files that appeared for the first time *after* the install started. + created_since_start: HashSet, + /// Accumulated findings (create→delete pairs). + phantom_list: Vec, +} + +impl WatchState { + fn new(initial: HashMap) -> Self { + let pre_install: HashSet = initial.keys().cloned().collect(); + Self { + prev: initial, + pre_install, + created_since_start: HashSet::new(), + phantom_list: Vec::new(), + } + } + + fn update(&mut self, current: HashMap) { + // Borrow prev/current as sets of path references for set arithmetic. + let current_paths: HashSet<&PathBuf> = current.keys().collect(); + let prev_paths: HashSet<&PathBuf> = self.prev.keys().collect(); + + // Files that appeared this poll and were not present before install. + for path in current_paths.difference(&prev_paths) { + if !self.pre_install.contains(*path) { + self.created_since_start.insert((*path).clone()); + } + } + + // Files that disappeared this poll — phantom if created after install started. + for path in prev_paths.difference(¤t_paths) { + if self.created_since_start.remove(*path) { + self.phantom_list.push(PhantomFinding { + package: package_from_path(path), + path: (*path).clone(), + kind: FindingKind::EphemeralFile, + }); + } + } + + self.prev = current; + } + + /// Drain and return the accumulated phantom findings. + fn phantoms(&mut self) -> Vec { + std::mem::take(&mut self.phantom_list) + } +} + +// ── Filesystem helpers ──────────────────────────────────────────────────────── + +/// Returns `path → mtime` for every file under `dir`, recursively. +/// Returns an empty map if `dir` does not exist. +fn snapshot_dir(dir: &str) -> HashMap { + let mut map = HashMap::new(); + let root = Path::new(dir); + if root.exists() { + walk_dir(root, &mut map); + } + map +} + +fn walk_dir(dir: &Path, map: &mut HashMap) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(ft) = entry.file_type() else { continue }; + if ft.is_dir() { + walk_dir(&path, map); + } else if ft.is_file() + && let Ok(meta) = entry.metadata() + && let Ok(mtime) = meta.modified() + { + map.insert(path, mtime); + } + } +} + +/// Returns `path → mtime` for files directly in `dir` (non-recursive). +/// Used for project-root exec-drop detection. +fn snapshot_root_executables(dir: &str) -> HashMap { + let mut map = HashMap::new(); + let Ok(entries) = std::fs::read_dir(dir) else { + return map; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() + && let Ok(meta) = std::fs::metadata(&path) + && let Ok(mtime) = meta.modified() + { + map.insert(path, mtime); + } + } + map +} + +/// Extracts the npm package name from a `node_modules/…` path. +/// +/// Examples: +/// - `node_modules/axios/scripts/evil.sh` → `axios` +/// - `node_modules/@scope/pkg/bin/run` → `@scope/pkg` +/// - any other path → `` +fn package_from_path(path: &Path) -> String { + let components: Vec<_> = path.components().collect(); + let Some(pos) = components + .iter() + .position(|c| c.as_os_str() == "node_modules") + else { + return "".to_string(); + }; + + match components.get(pos + 1) { + Some(first) if first.as_os_str().to_string_lossy().starts_with('@') => { + // Scoped package: @scope/name + let scope = first.as_os_str().to_string_lossy(); + let name = components + .get(pos + 2) + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .unwrap_or_default(); + format!("{}/{}", scope, name) + } + Some(pkg) => pkg.as_os_str().to_string_lossy().into_owned(), + None => "".to_string(), + } +} + +fn is_allowlisted(package: &str, allowlist: &[String]) -> bool { + allowlist.iter().any(|a| a == package) +} + +/// Returns `true` if `path` has the executable bit set (Unix) or an executable +/// extension (Windows). +#[cfg(unix)] +fn is_executable(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + path.metadata() + .map(|m| m.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + +#[cfg(not(unix))] +fn is_executable(path: &Path) -> bool { + matches!( + path.extension().and_then(|e| e.to_str()), + Some("exe") | Some("bat") | Some("cmd") | Some("ps1") + ) +} + +// ── Output ──────────────────────────────────────────────────────────────────── + +fn report_findings(findings: &[PhantomFinding], allow_postinstall: &[String]) { + if findings.is_empty() { + return; + } + eprintln!(); + eprintln!( + "🚨 greengate watch-install: {} suspicious event(s) detected:", + findings.len() + ); + eprintln!(); + for f in findings { + let kind_label = match f.kind { + FindingKind::EphemeralFile => "PHANTOM ", + FindingKind::ExecutableDrop => "EXEC_DROP ", + }; + let hint = if is_allowlisted(&f.package, allow_postinstall) { + " [allowlisted — warning only]" + } else { + "" + }; + eprintln!(" [{}] {}{}", kind_label, f.package, hint); + eprintln!(" path: {}", f.path.display()); + } + eprintln!(); + eprintln!( + " Tip: if this package is a known native build tool (e.g. esbuild, swc),\n \ + add it to [supply_chain] allow_postinstall in .greengate.toml to suppress." + ); + eprintln!(); +} + +// ── Unit tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::time::SystemTime; + + // ── Helpers ────────────────────────────────────────────────────────────── + + /// Build a snapshot map from bare path strings (mtime is irrelevant for + /// these tests so we always use UNIX_EPOCH). + fn snap(paths: &[&str]) -> HashMap { + paths + .iter() + .map(|p| (PathBuf::from(p), SystemTime::UNIX_EPOCH)) + .collect() + } + + fn allowlist(pkgs: &[&str]) -> Vec { + pkgs.iter().map(|s| s.to_string()).collect() + } + + // ── package_from_path ──────────────────────────────────────────────────── + + #[test] + fn package_from_path_regular_package() { + let p = PathBuf::from("node_modules/axios/lib/core/settle.js"); + assert_eq!(package_from_path(&p), "axios"); + } + + #[test] + fn package_from_path_scoped_package() { + let p = PathBuf::from("node_modules/@scope/pkg/index.js"); + assert_eq!(package_from_path(&p), "@scope/pkg"); + } + + #[test] + fn package_from_path_no_node_modules_segment() { + let p = PathBuf::from("src/main.rs"); + assert_eq!(package_from_path(&p), ""); + } + + #[test] + fn package_from_path_bare_node_modules() { + // Path that ends exactly at node_modules with no package segment after it + let p = PathBuf::from("node_modules"); + assert_eq!(package_from_path(&p), ""); + } + + #[test] + fn package_from_path_nested_node_modules() { + // Hoisted deps can create node_modules inside node_modules in older npm versions + let p = PathBuf::from("node_modules/outer/node_modules/inner/index.js"); + // Should return the package immediately after the first node_modules segment + assert_eq!(package_from_path(&p), "outer"); + } + + // ── is_allowlisted ─────────────────────────────────────────────────────── + + #[test] + fn allowlist_returns_true_for_known_package() { + assert!(is_allowlisted( + "esbuild", + &allowlist(&["esbuild", "prisma"]) + )); + } + + #[test] + fn allowlist_returns_false_for_unknown_package() { + assert!(!is_allowlisted("evil-pkg", &allowlist(&["esbuild"]))); + } + + #[test] + fn allowlist_empty_list_never_matches() { + assert!(!is_allowlisted("anything", &[])); + } + + // ── WatchState ─────────────────────────────────────────────────────────── + + #[test] + fn watch_state_starts_empty_with_no_phantoms() { + let state = WatchState::new(snap(&["node_modules/pkg/index.js"])); + assert!(state.phantom_list.is_empty()); + assert!(state.created_since_start.is_empty()); + } + + #[test] + fn new_file_added_to_created_since_start() { + let mut state = WatchState::new(snap(&[])); + state.update(snap(&["node_modules/evil-pkg/backdoor"])); + assert!( + state + .created_since_start + .contains(&PathBuf::from("node_modules/evil-pkg/backdoor")), + "new file must appear in created_since_start" + ); + assert!( + state.phantom_list.is_empty(), + "file still present — not a phantom yet" + ); + } + + #[test] + fn pre_existing_file_deleted_is_not_a_phantom() { + // The file existed before install — deleting it is not suspicious. + let mut state = WatchState::new(snap(&["node_modules/pkg/index.js"])); + state.update(snap(&[])); // pkg/index.js disappears + assert!( + state.phantom_list.is_empty(), + "pre-existing file deletion must not be flagged" + ); + } + + #[test] + fn file_created_then_deleted_is_flagged_as_phantom() { + let mut state = WatchState::new(snap(&[])); + + // Poll 1: file appears + state.update(snap(&["node_modules/evil-pkg/backdoor"])); + assert!(state.phantom_list.is_empty()); + + // Poll 2: file disappears + state.update(snap(&[])); + + let phantoms = state.phantoms(); + assert_eq!(phantoms.len(), 1); + assert_eq!(phantoms[0].package, "evil-pkg"); + assert!( + matches!(phantoms[0].kind, FindingKind::EphemeralFile), + "kind must be EphemeralFile" + ); + } + + #[test] + fn file_that_persists_after_install_is_not_a_phantom() { + let mut state = WatchState::new(snap(&[])); + state.update(snap(&["node_modules/pkg/real-file.js"])); + // File stays + state.update(snap(&["node_modules/pkg/real-file.js"])); + + assert!( + state.phantom_list.is_empty(), + "persistent file must not be flagged" + ); + } + + #[test] + fn each_create_delete_cycle_produces_one_phantom() { + // File appears, disappears, reappears, disappears — two separate phantom events. + let mut state = WatchState::new(snap(&[])); + + state.update(snap(&["node_modules/pkg/run.sh"])); // create + state.update(snap(&[])); // delete → phantom #1 + state.update(snap(&["node_modules/pkg/run.sh"])); // create again + state.update(snap(&[])); // delete → phantom #2 + + let phantoms = state.phantoms(); + assert_eq!( + phantoms.len(), + 2, + "two create-delete cycles must produce two phantom findings" + ); + } + + #[test] + fn multiple_packages_each_produce_own_phantom() { + let mut state = WatchState::new(snap(&[])); + + state.update(snap(&[ + "node_modules/pkg-a/evil", + "node_modules/pkg-b/evil", + ])); + state.update(snap(&[])); // both deleted + + let phantoms = state.phantoms(); + assert_eq!(phantoms.len(), 2); + let packages: Vec<&str> = phantoms.iter().map(|p| p.package.as_str()).collect(); + assert!(packages.contains(&"pkg-a")); + assert!(packages.contains(&"pkg-b")); + } + + // ── is_executable (Unix only) ───────────────────────────────────────────── + + #[test] + #[cfg(unix)] + fn is_executable_returns_false_without_exec_bit() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("script.sh"); + std::fs::write(&path, "#!/bin/sh\n").unwrap(); + let mut perms = std::fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o644); // no exec bit + std::fs::set_permissions(&path, perms).unwrap(); + assert!(!is_executable(&path)); + } + + #[test] + #[cfg(unix)] + fn is_executable_returns_true_with_exec_bit() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("script.sh"); + std::fs::write(&path, "#!/bin/sh\n").unwrap(); + let mut perms = std::fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&path, perms).unwrap(); + assert!(is_executable(&path)); + } + + #[test] + #[cfg(not(unix))] + fn is_executable_detects_windows_extensions() { + assert!(is_executable(Path::new("run.exe"))); + assert!(is_executable(Path::new("script.bat"))); + assert!(is_executable(Path::new("cmd.cmd"))); + assert!(is_executable(Path::new("run.ps1"))); + assert!(!is_executable(Path::new("readme.txt"))); + assert!(!is_executable(Path::new("index.js"))); + } +} diff --git a/src/utils/config.rs b/src/utils/config.rs index 2290a28..07d308e 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -22,6 +22,8 @@ pub struct Config { pub audit: AuditConfig, #[serde(default)] pub review: ReviewConfig, + #[serde(default)] + pub supply_chain: SupplyChainConfig, } /// Audit settings loaded from `.greengate.toml` under `[audit]`. @@ -325,6 +327,45 @@ pub struct PipelineConfig { pub steps: Vec, } +// ── Supply chain config ─────────────────────────────────────────────────────── + +/// Settings for supply-chain protection loaded from `.greengate.toml` +/// under `[supply_chain]`. Currently drives `greengate watch-install`; +/// reserved for `greengate sandbox-install` in a future release. +#[derive(Deserialize, Clone)] +pub struct SupplyChainConfig { + /// Fail the install if a phantom file (created-then-deleted postinstall + /// binary) or unexpected executable drop is detected (default: true). + #[serde(default = "default_supply_chain_block_phantom")] + pub block_phantom_scripts: bool, + /// Monitor the project root for new executables dropped during install. + /// When false, only `node_modules/` phantom-file detection runs (default: true). + #[serde(default = "default_supply_chain_sandbox")] + pub enforce_sandbox: bool, + /// Packages whose postinstall scripts may legitimately create temp files + /// (e.g. native build tools). Findings from these packages are reported + /// as warnings and do not trigger a failure. + #[serde(default)] + pub allow_postinstall: Vec, +} + +impl Default for SupplyChainConfig { + fn default() -> Self { + Self { + block_phantom_scripts: default_supply_chain_block_phantom(), + enforce_sandbox: default_supply_chain_sandbox(), + allow_postinstall: Vec::new(), + } + } +} + +fn default_supply_chain_block_phantom() -> bool { + true +} +fn default_supply_chain_sandbox() -> bool { + true +} + /// Load `.greengate.toml` from the current directory, falling back to defaults. pub fn load() -> Config { let path = std::path::Path::new(".greengate.toml"); diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 1a4748c..0a883c3 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -883,3 +883,297 @@ fn review_sarif_output_is_valid() { assert_eq!(parsed["version"].as_str(), Some("2.1.0")); assert!(parsed["runs"].is_array()); } + +// ── watch-install ───────────────────────────────────────────────────────────── +// +// These tests use `sh -c "..."` as the wrapped "package manager" so they run +// without npm/yarn being installed. All `watch-install` integration tests are +// Unix-only because they rely on POSIX shell. +// +// Timing note: the watcher polls every 250 ms. Scripts that create-then-delete +// a file sleep for 0.7 s between the two operations, giving the watcher at +// least two polls to observe the file before it disappears. + +#[test] +#[cfg(unix)] +fn watch_install_exits_zero_on_clean_install() { + // Package manager does nothing — no files touched, no phantoms. + let dir = tempfile::tempdir().unwrap(); + + let status = Command::new(binary()) + .args(["watch-install", "sh", "-c", "exit 0"]) + .current_dir(dir.path()) + .status() + .unwrap(); + + assert!( + status.success(), + "clean install with no filesystem changes must exit 0" + ); +} + +#[test] +#[cfg(unix)] +fn watch_install_exits_nonzero_on_phantom_file() { + // Script creates a file inside node_modules/, waits, then deletes it — + // the classic postinstall-dropper signature. + let dir = tempfile::tempdir().unwrap(); + + let status = Command::new(binary()) + .args([ + "watch-install", + "sh", + "-c", + "mkdir -p node_modules/evil-pkg \ + && touch node_modules/evil-pkg/backdoor \ + && sleep 0.7 \ + && rm node_modules/evil-pkg/backdoor", + ]) + .current_dir(dir.path()) + .status() + .unwrap(); + + assert!( + !status.success(), + "phantom file (created-then-deleted) must cause non-zero exit" + ); +} + +#[test] +#[cfg(unix)] +fn watch_install_no_fail_flag_exits_zero_despite_phantom() { + // --no-fail: report findings but never set non-zero exit. + let dir = tempfile::tempdir().unwrap(); + + let status = Command::new(binary()) + .args([ + "watch-install", + "--no-fail", + "sh", + "-c", + "mkdir -p node_modules/evil-pkg \ + && touch node_modules/evil-pkg/backdoor \ + && sleep 0.7 \ + && rm node_modules/evil-pkg/backdoor", + ]) + .current_dir(dir.path()) + .status() + .unwrap(); + + assert!( + status.success(), + "--no-fail must suppress non-zero exit even when phantom detected" + ); +} + +#[test] +#[cfg(unix)] +fn watch_install_config_block_false_exits_zero_on_phantom() { + // block_phantom_scripts = false in .greengate.toml: same effect as --no-fail. + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(".greengate.toml"), + "[supply_chain]\nblock_phantom_scripts = false\n", + ) + .unwrap(); + + let status = Command::new(binary()) + .args([ + "watch-install", + "sh", + "-c", + "mkdir -p node_modules/evil-pkg \ + && touch node_modules/evil-pkg/backdoor \ + && sleep 0.7 \ + && rm node_modules/evil-pkg/backdoor", + ]) + .current_dir(dir.path()) + .status() + .unwrap(); + + assert!( + status.success(), + "block_phantom_scripts = false must not fail even with a phantom" + ); +} + +#[test] +#[cfg(unix)] +fn watch_install_allowlisted_package_does_not_fail() { + // Phantom inside a package on allow_postinstall must not cause failure. + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(".greengate.toml"), + "[supply_chain]\nallow_postinstall = [\"trusted-builder\"]\n", + ) + .unwrap(); + + let status = Command::new(binary()) + .args([ + "watch-install", + "sh", + "-c", + "mkdir -p node_modules/trusted-builder \ + && touch node_modules/trusted-builder/native.node \ + && sleep 0.7 \ + && rm node_modules/trusted-builder/native.node", + ]) + .current_dir(dir.path()) + .status() + .unwrap(); + + assert!( + status.success(), + "phantom from an allow_postinstall package must not fail the install" + ); +} + +#[test] +#[cfg(unix)] +fn watch_install_non_allowlisted_fails_even_when_other_package_is_allowed() { + // allow_postinstall = ["trusted"] but the phantom is from "evil-pkg" → still fails. + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(".greengate.toml"), + "[supply_chain]\nallow_postinstall = [\"trusted\"]\n", + ) + .unwrap(); + + let status = Command::new(binary()) + .args([ + "watch-install", + "sh", + "-c", + "mkdir -p node_modules/evil-pkg \ + && touch node_modules/evil-pkg/dropper \ + && sleep 0.7 \ + && rm node_modules/evil-pkg/dropper", + ]) + .current_dir(dir.path()) + .status() + .unwrap(); + + assert!( + !status.success(), + "non-allowlisted phantom must still fail even when other packages are allowed" + ); +} + +#[test] +#[cfg(unix)] +fn watch_install_persistent_file_in_node_modules_is_not_flagged() { + // A file that remains after install is not a phantom. + let dir = tempfile::tempdir().unwrap(); + + let status = Command::new(binary()) + .args([ + "watch-install", + "sh", + "-c", + "mkdir -p node_modules/real-pkg && echo 'module.exports={}' > node_modules/real-pkg/index.js", + ]) + .current_dir(dir.path()) + .status() + .unwrap(); + + assert!( + status.success(), + "a persistent node_modules file must not be flagged as a phantom" + ); +} + +#[test] +#[cfg(unix)] +fn watch_install_unknown_pm_exits_nonzero() { + let dir = tempfile::tempdir().unwrap(); + + let status = Command::new(binary()) + .args([ + "watch-install", + "this-pm-does-not-exist-anywhere", + "install", + ]) + .current_dir(dir.path()) + .status() + .unwrap(); + + assert!( + !status.success(), + "non-existent package manager must cause non-zero exit" + ); +} + +#[test] +#[cfg(unix)] +fn watch_install_exec_drop_in_project_root_is_flagged() { + let dir = tempfile::tempdir().unwrap(); + + // Script drops an executable in the project root. + let status = Command::new(binary()) + .args([ + "watch-install", + "sh", + "-c", + "printf '#!/bin/sh\\necho hi\\n' > ./injected && chmod +x ./injected", + ]) + .current_dir(dir.path()) + .status() + .unwrap(); + + assert!( + !status.success(), + "executable dropped in project root must cause non-zero exit" + ); + + // Clean up so the temp dir can be removed cleanly. + let _ = std::fs::remove_file(dir.path().join("injected")); +} + +#[test] +#[cfg(unix)] +fn watch_install_enforce_sandbox_false_ignores_exec_drop() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(".greengate.toml"), + "[supply_chain]\nenforce_sandbox = false\n", + ) + .unwrap(); + + let status = Command::new(binary()) + .args([ + "watch-install", + "sh", + "-c", + "printf '#!/bin/sh\\necho hi\\n' > ./injected && chmod +x ./injected", + ]) + .current_dir(dir.path()) + .status() + .unwrap(); + + assert!( + status.success(), + "exec-drop in root must be ignored when enforce_sandbox = false" + ); + + let _ = std::fs::remove_file(dir.path().join("injected")); +} + +#[test] +#[cfg(unix)] +fn watch_install_pm_failure_emits_warning() { + // Wrapped pm exits non-zero. Greengate should warn about it. + let dir = tempfile::tempdir().unwrap(); + + let output = Command::new(binary()) + .args(["watch-install", "sh", "-c", "exit 1"]) + .current_dir(dir.path()) + .output() + .unwrap(); + + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("non-zero"), + "failed pm must emit a warning to stderr; stderr: {}", + stderr + ); +}