diff --git a/.github/workflows/security-gate.yml b/.github/workflows/security-gate.yml new file mode 100644 index 0000000..faef3a9 --- /dev/null +++ b/.github/workflows/security-gate.yml @@ -0,0 +1,162 @@ +name: code-to-gate Security Gate + +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: security-gate-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + security-gate: + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version-file: .node-version + cache: npm + + - name: Verify locked security toolchain + run: node scripts/verify-security-toolchain.mjs + + - name: Use locked npm and install without lifecycle scripts + shell: bash + run: | + set -euo pipefail + NPM_VERSION="$(jq -r '.tools.npm.version' security/toolchain-lock.json)" + if [ "$(npm --version)" != "$NPM_VERSION" ]; then + npm install --global "npm@$NPM_VERSION" + fi + test "$(npm --version)" = "$NPM_VERSION" + npm ci --ignore-scripts + + - name: Install verified Gitleaks binary + shell: bash + run: | + set -euo pipefail + mkdir -p .security-tools + GITLEAKS_VERSION="$(jq -r '.tools.gitleaks.version' security/toolchain-lock.json)" + GITLEAKS_URL="$(jq -r '.tools.gitleaks.archive.url' security/toolchain-lock.json)" + GITLEAKS_SHA="$(jq -r '.tools.gitleaks.archive.sha256' security/toolchain-lock.json)" + CHECKSUMS_URL="$(jq -r '.tools.gitleaks.checksums.url' security/toolchain-lock.json)" + CHECKSUMS_SHA="$(jq -r '.tools.gitleaks.checksums.sha256' security/toolchain-lock.json)" + + curl --proto '=https' --tlsv1.2 --fail --location --retry 3 "$GITLEAKS_URL" --output .security-tools/gitleaks.tar.gz + curl --proto '=https' --tlsv1.2 --fail --location --retry 3 "$CHECKSUMS_URL" --output .security-tools/gitleaks.checksums.txt + + echo "$GITLEAKS_SHA .security-tools/gitleaks.tar.gz" | sha256sum --check --strict + echo "$CHECKSUMS_SHA .security-tools/gitleaks.checksums.txt" | sha256sum --check --strict + grep -F "$GITLEAKS_SHA" .security-tools/gitleaks.checksums.txt + tar -xzf .security-tools/gitleaks.tar.gz -C .security-tools gitleaks + test "$(.security-tools/gitleaks version)" = "$GITLEAKS_VERSION" + + - name: Run npm audit and generate CycloneDX SBOM + id: dependencies + shell: bash + run: | + set -euo pipefail + mkdir -p .qh/security + AUDIT_LEVEL="$(jq -r '.tools.npm.auditLevel' security/toolchain-lock.json)" + set +e + npm audit --audit-level="$AUDIT_LEVEL" --json > .qh/security/npm-audit.json + AUDIT_EXIT=$? + set -e + echo "$AUDIT_EXIT" > .qh/security/npm-audit.exit + test -s .qh/security/npm-audit.json + jq -e '.metadata.vulnerabilities != null' .qh/security/npm-audit.json + + npm sbom --sbom-format cyclonedx --sbom-type application > .qh/security/sbom.cdx.json + jq -e '.bomFormat == "CycloneDX" and (.specVersion | type == "string") and (.components | type == "array")' .qh/security/sbom.cdx.json + + - name: Run Semgrep with a digest-pinned offline container + id: semgrep + shell: bash + run: | + set -euo pipefail + SEMGREP_IMAGE="$(jq -r '.tools.semgrep.image' security/toolchain-lock.json)" + set +e + docker run --rm --platform linux/amd64 --network none --read-only --cap-drop ALL --security-opt no-new-privileges --pids-limit 256 --memory 2g --cpus 2 --tmpfs /tmp:rw,noexec,nosuid,size=128m --env HOME=/tmp --volume "$PWD:/src:ro" --workdir /src "$SEMGREP_IMAGE" semgrep scan --config .semgrep/security.yml --metrics=off --error --jobs 2 --timeout 30 --max-memory 1500 --exclude .qh --exclude .security-tools --json . > .qh/security/semgrep.json + SEMGREP_EXIT=$? + set -e + echo "$SEMGREP_EXIT" > .qh/security/semgrep.exit + test -s .qh/security/semgrep.json + jq -e '.results | type == "array"' .qh/security/semgrep.json + + - name: Run Gitleaks across repository history + id: gitleaks + shell: bash + run: | + set -euo pipefail + set +e + .security-tools/gitleaks git --redact=100 --no-banner --report-format json --report-path .qh/security/gitleaks.json --exit-code 13 + GITLEAKS_EXIT=$? + set -e + echo "$GITLEAKS_EXIT" > .qh/security/gitleaks.exit + if [ ! -s .qh/security/gitleaks.json ]; then + echo '[]' > .qh/security/gitleaks.json + fi + jq -e 'type == "array"' .qh/security/gitleaks.json + + - name: Run security golden regression + shell: bash + run: | + set -euo pipefail + node scripts/security-golden.mjs prepare .qh/security/golden + SEMGREP_IMAGE="$(jq -r '.tools.semgrep.image' security/toolchain-lock.json)" + + set +e + docker run --rm --platform linux/amd64 --network none --read-only --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --tmpfs /tmp:rw,noexec,nosuid,size=64m --env HOME=/tmp --volume "$PWD:/src:ro" --workdir /src "$SEMGREP_IMAGE" semgrep scan --config .semgrep/security.yml --metrics=off --error --no-git-ignore --json .qh/security/golden > .qh/security/golden-semgrep.json + GOLDEN_SEMGREP_EXIT=$? + + .security-tools/gitleaks dir .qh/security/golden --redact=100 --no-banner --report-format json --report-path .qh/security/golden-gitleaks.json --exit-code 13 + GOLDEN_GITLEAKS_EXIT=$? + set -e + + test "$GOLDEN_SEMGREP_EXIT" -eq 1 + test "$GOLDEN_GITLEAKS_EXIT" -eq 13 + node scripts/security-golden.mjs verify .qh/security/golden-semgrep.json .qh/security/golden-gitleaks.json + + - name: Upload security evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ctg-security-evidence-${{ github.sha }} + path: | + .qh/security/npm-audit.json + .qh/security/npm-audit.exit + .qh/security/sbom.cdx.json + .qh/security/semgrep.json + .qh/security/semgrep.exit + .qh/security/gitleaks.json + .qh/security/gitleaks.exit + .qh/security/golden-semgrep.json + .qh/security/golden-gitleaks.json + security/toolchain-lock.json + include-hidden-files: true + if-no-files-found: warn + retention-days: 90 + + - name: Enforce security gate + shell: bash + run: | + set -euo pipefail + test "$(cat .qh/security/npm-audit.exit)" -eq 0 + test "$(cat .qh/security/semgrep.exit)" -eq 0 + test "$(cat .qh/security/gitleaks.exit)" -eq 0 + echo "Security gate passed: npm audit, Semgrep, Gitleaks, CycloneDX SBOM, and golden regression." diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..55278b6 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,3 @@ +692bb21c2c9fdd5334066b88f38c508ac5c79a36:src/rules/__tests__/hardcoded-secret.test.ts:stripe-access-token:39 +910f4f47882542c0e3a69e2f489dc86fa2e4af50:src/cli/__tests__/llm-trust.test.ts:generic-api-key:357 +9d56518274e6ca52bf24a7571c3c12915c18da2d:plugins/example-custom-rule/src/index.js:generic-api-key:112 diff --git a/.semgrep/security.yml b/.semgrep/security.yml new file mode 100644 index 0000000..ed8386d --- /dev/null +++ b/.semgrep/security.yml @@ -0,0 +1,22 @@ +rules: + - id: ctg.javascript.no-eval + message: Avoid eval(); untrusted input can become arbitrary code execution. + severity: ERROR + languages: [javascript, typescript] + pattern: eval(...) + + - id: ctg.javascript.no-function-constructor + message: Avoid the Function constructor; it evaluates strings as code. + severity: ERROR + languages: [javascript, typescript] + pattern: new Function(...) + + - id: ctg.javascript.no-disabled-tls-verification + message: TLS certificate verification must not be disabled. + severity: ERROR + languages: [javascript, typescript] + pattern-either: + - pattern: | + $CLIENT.request({..., rejectUnauthorized: false, ...}, ...) + - pattern: | + $CLIENT.get({..., rejectUnauthorized: false, ...}, ...) diff --git a/README.md b/README.md index 071923d..3090dc4 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ readiness: | [Quickstart](docs/quickstart.md) | First run and CI setup | | [Distribution Status](docs/distribution-status.md) | Package, GitHub release, and npm publication state | | [CLI Reference](docs/cli-reference.md) | Commands, flags, output formats | +| [Security Gate](docs/security-gate.md) | Pinned scanners, SBOM, audit, and CI evidence | | [Policy Guide](docs/policy-guide.md) | Gate policy configuration | | [Integrations](docs/integrations.md) | GitHub Actions and downstream exports | | [Plugin Development](docs/plugin-development.md) | Custom rule SDK | diff --git a/README_EN.md b/README_EN.md index f40aff5..d8beaf0 100644 --- a/README_EN.md +++ b/README_EN.md @@ -98,6 +98,7 @@ should use `ctg/v1`. | [docs/quickstart.md](docs/quickstart.md) | First-run guide | | [docs/distribution-status.md](docs/distribution-status.md) | Package, GitHub release, and npm publication state | | [docs/cli-reference.md](docs/cli-reference.md) | CLI details | +| [docs/security-gate.md](docs/security-gate.md) | Pinned scanners, SBOM, audit, and CI evidence | | [docs/policy-guide.md](docs/policy-guide.md) | Gate policy configuration | | [docs/integrations.md](docs/integrations.md) | Tool integrations | | [docs/plugin-development.md](docs/plugin-development.md) | Plugin development | diff --git a/README_JA.md b/README_JA.md index 3db46d5..d932759 100644 --- a/README_JA.md +++ b/README_JA.md @@ -106,6 +106,7 @@ readiness: | [docs/quickstart.md](docs/quickstart.md) | 初回実行ガイド | | [docs/distribution-status.md](docs/distribution-status.md) | package / GitHub release / npm 公開状態 | | [docs/cli-reference.md](docs/cli-reference.md) | CLI 詳細 | +| [docs/security-gate.md](docs/security-gate.md) | Scanner固定、SBOM、監査、CI証跡 | | [docs/policy-guide.md](docs/policy-guide.md) | Gate policy 設定 | | [docs/integrations.md](docs/integrations.md) | CI / 外部連携 | | [docs/plugin-development.md](docs/plugin-development.md) | Plugin 開発 | diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 5403058..15770ab 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -121,6 +121,12 @@ code-to-gate scan --out Note: current CLI help does not expose `--lang`, `--languages`, `--ignore`, or `--exclude`; language detection and default exclusions are handled by the scanner. +Repository discovery is bounded to 100,000 files, depth 64, 10 MiB per +file, 2 GiB total accepted bytes, and 15 minutes. Exceeding a limit, a read +failure, or deadline exhaustion marks `repo-graph.stats.partial`, records the +limit and reason in `repo-graph.stats.scan`, and propagates incomplete input +to findings/readiness instead of silently succeeding. + **Default Exclusions:** The scanner automatically excludes these directories: @@ -327,19 +333,22 @@ Import results from external analysis tools and convert to normalized findings. **Usage:** ```bash -code-to-gate import --out +code-to-gate import --out [--repo-root ] [--max-input-mb ] [--producer-version ] ``` **Arguments:** | Argument | Required | Description | |----------|----------|-------------| -| `` | Yes | Tool name: `semgrep`, `eslint`, `sarif`, `codeql`, `tsc`, `coverage`, `test` | +| `` | Yes | Tool name: `semgrep`, `eslint`, `sarif`, `codeql`, `npm-audit`, `tsc`, `coverage`, `test` | | `` | Yes | Path to the tool output file | **Options:** | Option | Default | Description | |--------|---------|-------------| -| `--out ` | `.qh/imports` | Output directory for imported findings | +| `--out ` | `.qh/imports` | Output directory for imported findings and manifest | +| `--repo-root ` | current directory | Root used for path containment, source hashes, and exact Git revision | +| `--max-input-mb ` | `50` | Maximum input size in MiB; hard cap is 1024 | +| `--producer-version ` | unknown | Upstream producer version recorded in provenance | **Supported Tools:** | Tool | Input Format | Notes | @@ -348,12 +357,16 @@ code-to-gate import --out | `eslint` | JSON formatter output | Code quality and style findings | | `sarif` | SARIF 2.1.0 | Generic SARIF result import | | `codeql` | CodeQL SARIF 2.1.0 | CodeQL result import with SARIF severity metadata | +| `npm-audit` | `npm audit --json` | Dependency advisories and affected package paths | | `tsc` | TypeScript diagnostics JSON | Type errors and warnings | | `coverage` | Istanbul/nyc coverage-summary.json | Coverage metrics and gaps | | `test` | Generic test result JSON | Failed tests as testing findings | **Output:** -- `.qh/imports/-findings.json` - Normalized findings from the external tool +- `.qh/imports/-findings.json` - Schema-validated normalized findings +- `.qh/imports/-import-manifest.json` - Input/output SHA-256, producer metadata, exact repository revision, diagnostics, and completeness + +Both files are staged before replacement; existing outputs are backed up for rollback, and the manifest is committed last as the pair marker. Downstream `analyze --from-imports` revalidates the manifest, artifact hash, tool identity, repository revision, source-file hash, and finding IDs. Legacy manifest-less files remain readable but are marked partial. **Example:** ```bash @@ -372,6 +385,10 @@ code-to-gate import tsc ./tsc-errors.json --out .qh/imports # Import coverage summary code-to-gate import coverage ./coverage-summary.json --out .qh/imports + +# Import npm audit with repository-bound provenance +npm audit --json > npm-audit.json +code-to-gate import npm-audit ./npm-audit.json --repo-root . --out .qh/imports ``` **Exit Codes:** @@ -379,7 +396,9 @@ code-to-gate import coverage ./coverage-summary.json --out .qh/imports |------|------|-------------| | 0 | OK | Import completed successfully | | 2 | USAGE_ERROR | Invalid arguments or tool name | -| 8 | IMPORT_FAILED | Failed to parse or process input file | +| 7 | SCHEMA_FAILED | Findings or import manifest failed schema validation | +| 8 | IMPORT_FAILED | Failed to parse/process input, exceeded size limits, or violated path constraints | +| 12 | PARTIAL_SUCCESS | Import succeeded with dropped or unsupported records; manifest records diagnostics | --- diff --git a/docs/plugin-sandbox.md b/docs/plugin-sandbox.md index f94f35f..34f8df5 100644 --- a/docs/plugin-sandbox.md +++ b/docs/plugin-sandbox.md @@ -1,15 +1,18 @@ # Plugin Sandbox Documentation -This document describes the Docker-based sandbox execution environment for code-to-gate plugins. +This document describes the fail-closed execution policy for code-to-gate plugins. ## Overview -The plugin sandbox provides isolated execution for plugins using Docker containers. This ensures: +Plugin execution defaults to permission-restricted Process mode. Process mode only accepts plugins whose manifest and entrypoint SHA-256 digests are bound by an execution policy. Docker mode is required for untrusted plugins and never falls back to host execution. The direct `none` mode is an explicit emergency escape hatch and is forbidden in CI and release contexts. -- **Security**: Plugins run in isolated containers with limited resources -- **Resource Control**: Memory, CPU, and timeout limits are enforced -- **Network Isolation**: Plugins have no network access by default -- **Filesystem Restriction**: Plugins can only access specified paths +The sandbox enforces: + +- **Trust binding**: Process-mode plugins are identified by name, version, manifest digest, and entrypoint digest +- **Resource control**: 60-second runtime, bounded stdout/stderr, finding count, and evidence count +- **Network isolation**: Docker always uses `--network=none` +- **Filesystem restriction**: Docker uses a read-only root; Node Process mode uses the permission model +- **Environment isolation**: Only explicitly allowed variables are passed; `NODE_OPTIONS`, `NODE_PATH`, and `ELECTRON_RUN_AS_NODE` are denied ## Architecture @@ -55,21 +58,27 @@ The plugin sandbox provides isolated execution for plugins using Docker containe | Mode | Description | |------|-------------| -| `none` | Direct process execution (no isolation) | -| `docker` | Execute plugins in isolated Docker containers | +| `process` | Default. Node permission model plus digest-bound execution policy | +| `docker` | Required for untrusted plugins; network-disabled and read-only | +| `none` | Unsafe direct execution; requires `--unsafe-allow-none` and is rejected in CI/release | ### Default Configuration ```typescript const DEFAULT_SANDBOX_CONFIG = { - mode: "none", - timeout: 60, // seconds - memoryLimit: 512, // MB - cpuLimit: 0.5, // fraction of CPU - networkAccess: false, // blocked by default + mode: "process", + timeout: 60, + memoryLimit: 512, + cpuLimit: 0.5, + networkAccess: false, dockerImage: "code-to-gate-plugin-runner:latest", containerUser: "node", strictSecurity: true, + maxStdoutBytes: 10 * 1024 * 1024, + maxStderrBytes: 1 * 1024 * 1024, + maxFindings: 1000, + maxEvidencePerFinding: 10, + nodePermissionModel: true, }; ``` @@ -80,13 +89,17 @@ const DEFAULT_SANDBOX_CONFIG = { | `timeout` | number | 60 | Maximum execution time in seconds | | `memoryLimit` | number | 512 | Memory limit in MB | | `cpuLimit` | number | 0.5 | CPU limit as fraction (0-4) | -| `networkAccess` | boolean | false | Allow network access | +| `networkAccess` | boolean | false | Must remain false for Docker execution | | `dockerImage` | string | "code-to-gate-plugin-runner:latest" | Docker image to use | | `containerUser` | string | "node" | User to run as in container | -| `strictSecurity` | boolean | true | Enable strict security (seccomp, no-new-privileges) | +| `strictSecurity` | boolean | true | Required for Docker execution | | `allowedReadPaths` | string[] | ["${repoRoot}"] | Paths plugin can read | | `allowedWritePaths` | string[] | ["${workDir}"] | Paths plugin can write | | `maxFileSizeMB` | number | 10 | Maximum file size for writes | +| `maxStdoutBytes` | number | 10485760 | Hard stdout limit | +| `maxStderrBytes` | number | 1048576 | Hard stderr limit | +| `maxFindings` | number | 1000 | Maximum findings accepted from one plugin | +| `maxEvidencePerFinding` | number | 10 | Maximum evidence references per finding | ## CLI Usage @@ -125,16 +138,19 @@ code-to-gate plugin-sandbox run ./my-plugin \ Options: - `--input `: Input JSON file - `--output `: Output file (default: stdout) -- `--sandbox `: Required sandbox mode (`docker` or `none`) -- `--timeout `: Execution timeout in seconds -- `--memory `: Memory limit in MB -- `--cpu `: CPU limit +- `--sandbox `: `process` (default), `docker`, or `none` +- `--execution-policy `: Required for Process mode +- `--unsafe-allow-none`: Required together with `--sandbox none` +- `--timeout `: Execution timeout in seconds, capped at 60 +- `--memory `: Docker memory limit in MB +- `--cpu `: Docker CPU limit - `--verbose`: Show detailed information -`--sandbox` has no implicit default. Omitting it or passing an unsupported value -returns usage exit code `2`. `--sandbox none` is an explicit lower-trust choice: -the plugin runs directly on the host and can access the host process -environment, so the CLI prints a warning before execution. +Process mode rejects unknown or tampered plugins. The policy schema is +`ctg/plugin-execution-policy/v1`; each trusted plugin entry binds its name, +version, manifest SHA-256, and entrypoint SHA-256. Use Docker for plugins that +cannot be placed on that allowlist. `none` mode is rejected when `CI`, +`GITHUB_ACTIONS`, or `CTG_RELEASE` is set. ### Build Docker Image @@ -154,18 +170,11 @@ By default, plugins run with `--network=none`, preventing any network access: docker run --network=none ... ``` -To allow network access (requires manifest declaration): - -```yaml -security: - network: true -``` - -The command builder contract is tested in `src/plugin/__tests__/docker-sandbox.test.ts`: `networkAccess: false` must emit `--network=none`, and `networkAccess: true` is the only path that omits it. +Docker network access cannot be enabled by plugin manifests or CLI input. Unsafe configuration is rejected, and the command builder still emits `--network=none` defensively. ### Docker Unavailable Fallback Policy -If Docker is unavailable, `--sandbox docker` is a policy failure for sandbox-required execution and should return a plugin failure result rather than silently running the plugin directly. Users may explicitly choose `--sandbox none` for direct process execution, but this is a lower-trust mode and must be documented in the runbook or policy exception. CI/release gates that require private plugin isolation should fail closed when Docker sandbox status is unavailable. +If Docker is unavailable, `--sandbox docker` returns a plugin failure and never falls back to Process or `none`. Trusted plugins may be run with Process mode and a valid execution policy. Direct execution requires both `--sandbox none` and `--unsafe-allow-none`, and remains unavailable in CI/release contexts. ### Memory Limits @@ -256,13 +265,9 @@ Sensitive environment variables are blocked: ### Manifest Environment -Custom environment from manifest: - -```yaml -entry: - env: - CUSTOM_VAR: "value" -``` +Manifest environment values are filtered through the execution policy allowlist. +The full host environment is never inherited. Runtime-control variables such as +`NODE_OPTIONS`, `NODE_PATH`, and `ELECTRON_RUN_AS_NODE` cannot be allowed. ## Docker Image @@ -432,8 +437,8 @@ Error: Plugin execution timed out ``` Solution: -- Increase timeout in manifest: `entry.timeout: 120` -- Use CLI option: `--timeout 120` +- Keep the manifest and CLI timeout at or below the hard 60-second cap +- Split large plugin work into bounded operations - Optimize plugin code for faster execution ### Memory Issues @@ -449,13 +454,13 @@ Solution: ## Best Practices -1. **Always use sandbox mode for production**: Prevent plugins from affecting host system -2. **Set appropriate timeouts**: Balance between allowing completion and preventing hangs -3. **Limit memory based on plugin needs**: Lower limits for simple plugins, higher for complex analysis -4. **Declare security requirements**: Use manifest `security` section for explicit permissions -5. **Test in sandbox before deployment**: Verify plugin works correctly in isolated environment -6. **Handle errors gracefully**: Plugins should write meaningful error messages to output -7. **Avoid network dependencies**: Design plugins to work without network access when possible +1. **Use Docker for untrusted plugins**: Unknown code must not enter Process mode +2. **Review digest changes**: Manifest or entrypoint hash changes require an explicit policy update +3. **Keep hard caps**: Do not raise the 60-second, output, finding, or evidence limits +4. **Keep Docker offline**: Plugins must not depend on network access +5. **Treat `none` as break-glass only**: It is unavailable in CI/release and requires explicit acknowledgement +6. **Handle partial results**: Limit violations produce partial or failed execution, never silent success +7. **Rotate plugin trust deliberately**: Review name, version, and both digests together ## API Reference @@ -497,6 +502,11 @@ interface SandboxConfig { dockerImage: string; containerUser: string; strictSecurity: boolean; + maxStdoutBytes: number; + maxStderrBytes: number; + maxFindings: number; + maxEvidencePerFinding: number; + nodePermissionModel: boolean; } ``` diff --git a/docs/security-gate.md b/docs/security-gate.md new file mode 100644 index 0000000..8aa66ba --- /dev/null +++ b/docs/security-gate.md @@ -0,0 +1,45 @@ +# Security Gate + +`.github/workflows/security-gate.yml` runs on pull requests, pushes to +`main`, and manual dispatch. The job has only `contents: read` permission. + +## Blocking checks + +- Semgrep 1.164.0 from an OCI digest-pinned image, with local rules and no + container network +- Gitleaks 8.30.1 from the official release archive, verified by both the + locked archive SHA-256 and the locked official checksum-file SHA-256 +- `npm audit --audit-level=high` +- CycloneDX SBOM generation and structural validation +- Golden regression fixtures that must be detected by both Semgrep and + Gitleaks + +The workflow uploads JSON reports, exit-code evidence, the SBOM, golden +reports, and `security/toolchain-lock.json` for 90 days. Scanner findings, +audit vulnerabilities, missing reports, checksum failures, or golden misses +fail the final gate. + +## Updating tools + +1. Review the upstream immutable release and its checksum or OCI digest. +2. Update `security/toolchain-lock.json`. +3. Run `npm run security:toolchain:verify`. +4. Review the security workflow contract test. +5. Do not replace a digest with a mutable tag or remove archive verification. + +The lock validator intentionally accepts only the official Semgrep Docker Hub +endpoint and official Gitleaks GitHub release URLs. + +## Local verification + +```bash +npm run security:toolchain:verify +npm run audit:deps +npm run typecheck +npm run test:smoke +npx vitest run src/__tests__/contract/security-toolchain.test.ts +``` + +The complete Semgrep/Gitleaks golden run requires a Linux Docker environment +and is executed by the security workflow. Configure the repository branch +protection rule to require the `security-gate` job before merge. diff --git a/package-lock.json b/package-lock.json index ab1afc2..c56574a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -714,9 +714,9 @@ "license": "MIT" }, "node_modules/@ts-morph/common/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -1312,9 +1312,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -1674,9 +1674,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "funding": [ { "type": "github", diff --git a/package.json b/package.json index bf513a2..39ec1e1 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,9 @@ "release:public": "npm run release:immutable && npm run lint && npm run typecheck && npm run test:smoke && npm run test:package", "release:public:quality": "npm run lint && npm run typecheck && npm run test:smoke && npm run test:ci:stable && npm run test:coverage && npm run release:validate", "audit:deps": "npm audit --audit-level=high", + "security:toolchain:verify": "node scripts/verify-security-toolchain.mjs", + "security:golden:prepare": "node scripts/security-golden.mjs prepare .qh/security/golden", + "security:golden:verify": "node scripts/security-golden.mjs verify .qh/security/golden-semgrep.json .qh/security/golden-gitleaks.json", "sbom:generate": "npm sbom --sbom-format cyclonedx --sbom-type application > sbom.json", "evidence:pack": "node -e \"console.log('Evidence artifacts:\\n- .qh/release-readiness.json\\n- .qh/findings.json\\n- .qh/audit.json\\n- .qh/results.sarif\\n- coverage/coverage-summary.json\\n- sbom.json\\n- npm pack --dry-run output')\"", "prepublishOnly": "npm run release:immutable && npm run build && npm run test:smoke && npm run test:package" diff --git a/schemas/findings.schema.json b/schemas/findings.schema.json index 1329e67..f1805be 100644 --- a/schemas/findings.schema.json +++ b/schemas/findings.schema.json @@ -38,7 +38,7 @@ "required": ["tool"], "additionalProperties": false, "properties": { - "tool": { "enum": ["native", "semgrep", "eslint", "sarif", "codeql", "sonarqube", "tsc", "coverage", "test"] }, + "tool": { "enum": ["native", "semgrep", "eslint", "sarif", "codeql", "npm-audit", "sonarqube", "tsc", "coverage", "test"] }, "ruleId": { "type": "string" } } } diff --git a/schemas/import-manifest.schema.json b/schemas/import-manifest.schema.json new file mode 100644 index 0000000..c180585 --- /dev/null +++ b/schemas/import-manifest.schema.json @@ -0,0 +1,216 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://code-to-gate.local/schemas/import-manifest.schema.json", + "title": "ImportManifestArtifact", + "allOf": [ + { + "$ref": "./shared-defs.schema.json#/$defs/artifactHeader" + } + ], + "required": [ + "artifact", + "schema", + "completeness", + "source", + "normalized", + "summary", + "diagnostics", + "generated_by" + ], + "additionalProperties": false, + "properties": { + "version": { + "$ref": "./shared-defs.schema.json#/$defs/version" + }, + "generated_at": { + "$ref": "./shared-defs.schema.json#/$defs/isoDateTime" + }, + "run_id": { + "type": "string", + "minLength": 1 + }, + "repo": { + "$ref": "./shared-defs.schema.json#/$defs/repoRef" + }, + "tool": { + "$ref": "./shared-defs.schema.json#/$defs/toolRef" + }, + "artifact": { + "const": "import-manifest" + }, + "schema": { + "const": "import-manifest@v1" + }, + "completeness": { + "$ref": "./shared-defs.schema.json#/$defs/completeness" + }, + "source": { + "type": "object", + "required": [ + "tool", + "format", + "path", + "path_kind", + "sha256", + "size_bytes", + "producer" + ], + "additionalProperties": false, + "properties": { + "tool": { + "enum": [ + "semgrep", + "eslint", + "sarif", + "codeql", + "npm-audit", + "sonarqube", + "tsc", + "coverage", + "test" + ] + }, + "format": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "path_kind": { + "enum": [ + "repo_relative", + "external_redacted" + ] + }, + "sha256": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "size_bytes": { + "type": "integer", + "minimum": 0 + }, + "producer": { + "type": "object", + "required": [ + "name", + "version" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + }, + "format_version": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "repository_revision": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + } + } + }, + "normalized": { + "type": "object", + "required": [ + "path", + "sha256", + "size_bytes", + "schema" + ], + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "sha256": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "size_bytes": { + "type": "integer", + "minimum": 0 + }, + "schema": { + "const": "findings@v1" + } + } + }, + "summary": { + "type": "object", + "required": [ + "seen", + "accepted", + "dropped", + "errors" + ], + "additionalProperties": false, + "properties": { + "seen": { + "type": "integer", + "minimum": 0 + }, + "accepted": { + "type": "integer", + "minimum": 0 + }, + "dropped": { + "type": "integer", + "minimum": 0 + }, + "errors": { + "type": "integer", + "minimum": 0 + } + } + }, + "diagnostics": { + "type": "array", + "maxItems": 100, + "items": { + "type": "object", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "message": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "record_index": { + "type": "integer", + "minimum": 0 + } + } + } + }, + "generated_by": { + "const": "ctg-import/v1" + } + } +} diff --git a/schemas/normalized-repo-graph.schema.json b/schemas/normalized-repo-graph.schema.json index 694cd84..4896063 100644 --- a/schemas/normalized-repo-graph.schema.json +++ b/schemas/normalized-repo-graph.schema.json @@ -140,7 +140,35 @@ "required": ["partial"], "additionalProperties": true, "properties": { - "partial": { "type": "boolean" } + "partial": { "type": "boolean" }, + "scan": { + "type": "object", + "required": ["visitedFiles", "acceptedFiles", "acceptedBytes", "skippedFiles", "limits", "reasons"], + "additionalProperties": false, + "properties": { + "visitedFiles": { "type": "integer", "minimum": 0 }, + "acceptedFiles": { "type": "integer", "minimum": 0 }, + "acceptedBytes": { "type": "integer", "minimum": 0 }, + "skippedFiles": { "type": "integer", "minimum": 0 }, + "limits": { + "type": "object", + "required": ["maxFiles", "maxDepth", "maxFileSizeBytes", "maxTotalBytes", "deadlineMs"], + "additionalProperties": false, + "properties": { + "maxFiles": { "type": "integer", "minimum": 0 }, + "maxDepth": { "type": "integer", "minimum": 0 }, + "maxFileSizeBytes": { "type": "integer", "minimum": 0 }, + "maxTotalBytes": { "type": "integer", "minimum": 0 }, + "deadlineMs": { "type": "integer", "minimum": 0 } + } + }, + "reasons": { + "type": "array", + "maxItems": 100, + "items": { "type": "string", "minLength": 1 } + } + } + } } } } diff --git a/schemas/plugin-execution-policy.schema.json b/schemas/plugin-execution-policy.schema.json new file mode 100644 index 0000000..78b7237 --- /dev/null +++ b/schemas/plugin-execution-policy.schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://code-to-gate.local/schemas/plugin-execution-policy.schema.json", + "title": "PluginExecutionPolicy", + "type": "object", + "required": ["schema", "trusted_plugins"], + "additionalProperties": false, + "properties": { + "schema": { "const": "ctg/plugin-execution-policy/v1" }, + "trusted_plugins": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "version", "manifest_sha256", "entrypoint_sha256"], + "additionalProperties": false, + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 128 }, + "version": { "type": "string", "minLength": 1, "maxLength": 128 }, + "manifest_sha256": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, + "entrypoint_sha256": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" } + } + } + }, + "process": { + "type": "object", + "additionalProperties": false, + "properties": { + "allowed_env_vars": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" } + }, + "timeout_seconds": { "type": "integer", "minimum": 1, "maximum": 60 }, + "max_stdout_bytes": { "type": "integer", "minimum": 1, "maximum": 10485760 }, + "max_stderr_bytes": { "type": "integer", "minimum": 1, "maximum": 1048576 }, + "max_findings": { "type": "integer", "minimum": 1, "maximum": 1000 }, + "max_evidence_per_finding": { "type": "integer", "minimum": 1, "maximum": 10 }, + "node_permission_model": { "type": "boolean" } + } + } + } +} diff --git a/scripts/security-golden.mjs b/scripts/security-golden.mjs new file mode 100644 index 0000000..411fd4b --- /dev/null +++ b/scripts/security-golden.mjs @@ -0,0 +1,61 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +function fail(message) { + process.stderr.write(`security golden failure: ${message}\n`); + process.exit(1); +} + +const [mode, ...args] = process.argv.slice(2); + +if (mode === "prepare") { + const target = path.resolve(args[0] ?? ".qh/security/golden"); + mkdirSync(target, { recursive: true }); + + writeFileSync( + path.join(target, "unsafe.js"), + "export function run(value) { return eval(value); }\n", + "utf8" + ); + + const syntheticToken = ["gh", "p", "_", "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8"].join(""); + writeFileSync( + path.join(target, "secret.txt"), + `github_token=${syntheticToken}\n`, + "utf8" + ); + + process.stdout.write(`${target}\n`); +} else if (mode === "verify") { + const semgrepPath = path.resolve(args[0] ?? ""); + const gitleaksPath = path.resolve(args[1] ?? ""); + let semgrep; + let gitleaks; + try { + semgrep = JSON.parse(readFileSync(semgrepPath, "utf8")); + gitleaks = JSON.parse(readFileSync(gitleaksPath, "utf8")); + } catch (error) { + fail(error instanceof Error ? error.message : String(error)); + } + + const semgrepIds = new Set( + Array.isArray(semgrep.results) + ? semgrep.results.map((result) => result?.check_id).filter(Boolean) + : [] + ); + const evalRuleIds = ["ctg.javascript.no-eval", "semgrep.ctg.javascript.no-eval"]; + if (!evalRuleIds.some((ruleId) => semgrepIds.has(ruleId))) { + fail("Semgrep did not detect the eval() golden fixture"); + } + + if ( + !Array.isArray(gitleaks) || + !gitleaks.some((finding) => String(finding?.RuleID ?? "").toLowerCase().includes("github")) + ) { + fail("Gitleaks did not detect the synthetic GitHub token fixture"); + } + + process.stdout.write("security golden fixtures detected by Semgrep and Gitleaks\n"); +} else { + fail("usage: security-golden.mjs prepare | verify "); +} diff --git a/scripts/verify-security-toolchain.mjs b/scripts/verify-security-toolchain.mjs new file mode 100644 index 0000000..e8b1d4e --- /dev/null +++ b/scripts/verify-security-toolchain.mjs @@ -0,0 +1,59 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const root = process.cwd(); +const lockPath = path.join(root, "security", "toolchain-lock.json"); +const packagePath = path.join(root, "package.json"); + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +const lock = JSON.parse(readFileSync(lockPath, "utf8")); +const packageJson = JSON.parse(readFileSync(packagePath, "utf8")); + +assert(lock.schema === "ctg/security-toolchain-lock/v1", "Unexpected security toolchain lock schema"); + +const semgrep = lock.tools?.semgrep; +assert(semgrep?.version === "1.164.0", "Semgrep must remain pinned to 1.164.0"); +assert(/^sha256:[0-9a-f]{64}$/.test(semgrep.digest), "Semgrep digest must be a SHA-256 OCI digest"); +assert( + semgrep.image === `semgrep/semgrep@${semgrep.digest}`, + "Semgrep image must be referenced by the locked digest" +); +assert( + semgrep.source === `https://hub.docker.com/v2/repositories/semgrep/semgrep/tags/${semgrep.version}`, + "Semgrep source must be the official Docker Hub tag endpoint" +); +assert(semgrep.platform === "linux/amd64", "Semgrep CI platform must be explicit"); + +const gitleaks = lock.tools?.gitleaks; +assert(gitleaks?.version === "8.30.1", "Gitleaks must remain pinned to 8.30.1"); +assert( + gitleaks.archive?.url === + `https://github.com/gitleaks/gitleaks/releases/download/v${gitleaks.version}/gitleaks_${gitleaks.version}_linux_x64.tar.gz`, + "Gitleaks archive must come from the official versioned GitHub release" +); +assert(/^[0-9a-f]{64}$/.test(gitleaks.archive.sha256), "Gitleaks archive hash must be SHA-256"); +assert( + gitleaks.checksums?.url === + `https://github.com/gitleaks/gitleaks/releases/download/v${gitleaks.version}/gitleaks_${gitleaks.version}_checksums.txt`, + "Gitleaks checksum file must come from the official versioned GitHub release" +); +assert(/^[0-9a-f]{64}$/.test(gitleaks.checksums.sha256), "Gitleaks checksum-file hash must be SHA-256"); + +const npm = lock.tools?.npm; +assert(packageJson.packageManager === `npm@${npm?.version}`, "Locked npm version must match packageManager"); +assert(npm.auditLevel === "high", "npm audit must block high and critical vulnerabilities"); +assert(npm.sbomFormat === "cyclonedx", "SBOM format must remain CycloneDX"); + +process.stdout.write( + JSON.stringify({ + schema: lock.schema, + semgrep: { version: semgrep.version, digest: semgrep.digest }, + gitleaks: { version: gitleaks.version, sha256: gitleaks.archive.sha256 }, + npm, + }) + "\n" +); diff --git a/security/toolchain-lock.json b/security/toolchain-lock.json new file mode 100644 index 0000000..e380a1a --- /dev/null +++ b/security/toolchain-lock.json @@ -0,0 +1,29 @@ +{ + "schema": "ctg/security-toolchain-lock/v1", + "generatedAt": "2026-07-22T00:00:00.000Z", + "tools": { + "semgrep": { + "version": "1.164.0", + "image": "semgrep/semgrep@sha256:207983631beecdbe7fa29196c7f4a7a5f29033933cdb76c687ce4a672e07618d", + "digest": "sha256:207983631beecdbe7fa29196c7f4a7a5f29033933cdb76c687ce4a672e07618d", + "platform": "linux/amd64", + "source": "https://hub.docker.com/v2/repositories/semgrep/semgrep/tags/1.164.0" + }, + "gitleaks": { + "version": "8.30.1", + "archive": { + "url": "https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz", + "sha256": "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" + }, + "checksums": { + "url": "https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_checksums.txt", + "sha256": "061476c21adaf5441516f96f185c1a4706a83cd6329b9b38762271b3d4a52fae" + } + }, + "npm": { + "version": "10.9.8", + "auditLevel": "high", + "sbomFormat": "cyclonedx" + } + } +} diff --git a/src/__tests__/contract/security-toolchain.test.ts b/src/__tests__/contract/security-toolchain.test.ts new file mode 100644 index 0000000..6b7e7bf --- /dev/null +++ b/src/__tests__/contract/security-toolchain.test.ts @@ -0,0 +1,69 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { load } from "js-yaml"; + +const ROOT = path.resolve(import.meta.dirname, "../../.."); + +function read(relativePath: string): string { + return readFileSync(path.join(ROOT, relativePath), "utf8"); +} + +describe("security toolchain contract", () => { + it("pins external scanners to exact versions and SHA-256 digests", () => { + const lock = JSON.parse(read("security/toolchain-lock.json")) as { + schema: string; + tools: { + semgrep: { version: string; image: string; digest: string }; + gitleaks: { version: string; archive: { url: string; sha256: string } }; + npm: { version: string; sbomFormat: string }; + }; + }; + + expect(lock.schema).toBe("ctg/security-toolchain-lock/v1"); + expect(lock.tools.semgrep.version).toBe("1.164.0"); + expect(lock.tools.semgrep.image).toBe(`semgrep/semgrep@${lock.tools.semgrep.digest}`); + expect(lock.tools.semgrep.digest).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(lock.tools.gitleaks.version).toBe("8.30.1"); + expect(lock.tools.gitleaks.archive.url).toContain("/releases/download/v8.30.1/"); + expect(lock.tools.gitleaks.archive.sha256).toMatch(/^[0-9a-f]{64}$/); + expect(lock.tools.npm).toMatchObject({ version: "10.9.8", sbomFormat: "cyclonedx" }); + }); + + it("keeps the security workflow least-privileged and action refs immutable", () => { + const source = read(".github/workflows/security-gate.yml"); + const workflow = load(source) as { + permissions: Record; + jobs: Record; + }; + + expect(workflow.permissions).toEqual({ contents: "read" }); + expect(workflow.jobs).toHaveProperty("security-gate"); + expect(source).not.toMatch(/uses:\s+[^\s]+@v\d+/); + expect(source).toContain("--network none"); + expect(source).toContain("--read-only"); + expect(source).toContain("--cap-drop ALL"); + expect(source).toContain("npm ci --ignore-scripts"); + }); + + it("uses only local Semgrep rules and keeps synthetic secrets out of source", () => { + const semgrep = load(read(".semgrep/security.yml")) as { + rules: Array<{ id: string; severity: string }>; + }; + const goldenScript = read("scripts/security-golden.mjs"); + + expect(semgrep.rules.map((rule) => rule.id)).toContain("ctg.javascript.no-eval"); + expect(semgrep.rules.every((rule) => rule.severity === "ERROR")).toBe(true); + expect(goldenScript).not.toMatch(/ghp_[A-Za-z0-9]{36}/); + }); + + it("limits historical secret suppressions to exact fingerprints", () => { + const fingerprints = read(".gitleaksignore").trim().split(/\r?\n/); + + expect(fingerprints).toHaveLength(3); + expect(new Set(fingerprints).size).toBe(fingerprints.length); + for (const fingerprint of fingerprints) { + expect(fingerprint).toMatch(/^[0-9a-f]{40}:[^:\r\n]+:[a-z0-9-]+:\d+$/); + } + }); +}); diff --git a/src/application/__tests__/rule-evaluator.test.ts b/src/application/__tests__/rule-evaluator.test.ts index 3b52a04..ab945e7 100644 --- a/src/application/__tests__/rule-evaluator.test.ts +++ b/src/application/__tests__/rule-evaluator.test.ts @@ -131,6 +131,13 @@ describe("Rule Evaluator", () => { expect(artifact.unsupported_claims[0].reason).toBe("missing_evidence"); }); + it("marks a successfully analyzed repository complete even when no findings are produced", () => { + const artifact = evaluateRules(graph, contentContext, undefined, []); + + expect(artifact.findings).toEqual([]); + expect(artifact.completeness).toBe("complete"); + }); + it("marks findings partial when source graph is partial", () => { const artifact = evaluateRules( { ...graph, stats: { partial: true } }, @@ -143,6 +150,31 @@ describe("Rule Evaluator", () => { expect(artifact.completeness).toBe("partial"); }); + it("routes scan-limit truncation to unsupported claims", () => { + const artifact = evaluateRules( + { + ...graph, + stats: { + partial: true, + scan: { reasons: ["MAX_FILES_EXCEEDED"] }, + }, + }, + contentContext, + undefined, + [testRule(testFinding({}))] + ); + + expect(artifact.completeness).toBe("partial"); + expect(artifact.unsupported_claims).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + claim: "Repository scan covered all eligible files within the configured limits.", + sourceSection: "repo-graph:scan", + }), + ]) + ); + }); + it("routes findings with missing evidence paths to unsupported_claims", () => { const artifact = evaluateRules(graph, contentContext, undefined, [ testRule(testFinding({ diff --git a/src/application/rule-evaluator.ts b/src/application/rule-evaluator.ts index 4e1b4a0..cd03f6c 100644 --- a/src/application/rule-evaluator.ts +++ b/src/application/rule-evaluator.ts @@ -189,7 +189,12 @@ export function evaluateRules( run_id: string; generated_at: string; repo: { root: string; dirty?: boolean; revision?: string; branch?: string; base_ref?: string; head_ref?: string }; - stats: { partial: boolean }; + stats: { + partial: boolean; + scan?: { + reasons: string[]; + }; + }; }, applicationContext: ApplicationContext, policyId?: string, @@ -205,6 +210,15 @@ export function evaluateRules( const unsupported_claims: UnsupportedClaim[] = []; let unsupportedClaimIndex = 0; + if (graph.stats.scan?.reasons.length) { + unsupported_claims.push({ + id: generateUnsupportedClaimId("repository-scan", unsupportedClaimIndex++), + claim: "Repository scan covered all eligible files within the configured limits.", + reason: "missing_evidence", + sourceSection: "repo-graph:scan", + }); + } + // Create simple graph for rule context const simpleGraph: SimpleGraph = { files: graph.files, @@ -301,11 +315,19 @@ export function evaluateRules( } } + const analyzableLanguages = new Set([ + "ts", "tsx", "js", "jsx", "py", "rb", "go", "rs", "java", "php", "cs", "cpp", + ]); + const hasAnalyzableFiles = graph.files.some((file) => analyzableLanguages.has(file.language)); + return { ...header, artifact: "findings", schema: "findings@v1", - completeness: graph.stats.partial || allFindings.length === 0 ? "partial" : "complete", + completeness: + graph.stats.partial || !hasAnalyzableFiles || unsupported_claims.length > 0 + ? "partial" + : "complete", findings: allFindings, unsupported_claims, }; diff --git a/src/cli.ts b/src/cli.ts index c546a3f..af9a883 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -46,8 +46,8 @@ Usage: code-to-gate scan --out [--database-analysis] code-to-gate analyze [--emit all] --out [--require-llm] [--llm-provider ] [--llm-base-url ] [--debug-llm-trace] [--database-analysis] code-to-gate diff --base --head --out [--database-analysis] - code-to-gate import --out - Tools: eslint, semgrep, sarif, codeql, tsc, coverage, test + code-to-gate import --out [--repo-root ] [--max-input-mb ] [--producer-version ] + Tools: eslint, semgrep, sarif, codeql, npm-audit, tsc, coverage, test code-to-gate readiness --policy [--from ] --out [--baseline ] [--manual-evidence ] code-to-gate export --from [--out ] Targets: gatefield, state-gate, manual-bb, workflow-evidence, sarif, qeg-code-to-gate, hate-qeg-bundle, qeg-gate-input, evidence-dag, provenance-index @@ -123,7 +123,7 @@ Options: --format Machine output format for supported commands (json) --quiet Suppress success summary on stdout; diagnostics still use stderr --database-analysis Enable SQL and migration risk analysis - --plugin-sandbox Sandbox mode for plugin execution: none, docker (default: none) + --plugin-sandbox Sandbox mode for plugin execution: process, docker, none (default: process) --verbose Show detailed progress and timing information --title Report title for viewer --dark Enable dark mode for viewer @@ -160,8 +160,9 @@ Local LLM Providers: deterministic - Built-in deterministic fallback (always available) Plugin Sandbox: - none - Direct process execution (no isolation) - docker - Execute plugins in isolated Docker containers`); + process - Permission-restricted host process; requires a digest-bound execution policy + docker - Network-disabled, read-only Docker isolation; no host fallback + none - Unsafe direct execution; requires --unsafe-allow-none and is forbidden in CI/release`); } async function main(): Promise<number> { diff --git a/src/cli/__tests__/analyze-imports-security.test.ts b/src/cli/__tests__/analyze-imports-security.test.ts new file mode 100644 index 0000000..f1abc92 --- /dev/null +++ b/src/cli/__tests__/analyze-imports-security.test.ts @@ -0,0 +1,119 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { analyzeCommand } from "../analyze.js"; +import { importCommand } from "../import.js"; +import { EXIT, getOption, VERSION } from "../exit-codes.js"; + +describe("analyze import provenance integration", () => { + let root: string; + let outDir: string; + const repoRoot = path.resolve(import.meta.dirname, "../../../fixtures/demo-ci-imports"); + const semgrepFile = path.join(repoRoot, "semgrep.json"); + + beforeAll(() => { + root = mkdtempSync(path.join(tmpdir(), "ctg-analyze-imports-")); + }); + + beforeEach(() => { + outDir = path.join(root, "out"); + rmSync(outDir, { recursive: true, force: true }); + mkdirSync(outDir, { recursive: true }); + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(() => { + rmSync(root, { recursive: true, force: true }); + }); + + async function importSemgrep(): Promise<void> { + const result = await importCommand( + ["semgrep", semgrepFile, "--out", outDir, "--repo-root", repoRoot], + { VERSION, EXIT, getOption } + ); + expect(result).toBe(EXIT.OK); + } + + async function analyze(): Promise<number> { + return analyzeCommand( + [repoRoot, "--out", outDir, "--emit", "json", "--from-imports"], + { VERSION, EXIT, getOption } + ); + } + + it("merges a manifest-verified import without degrading completeness", async () => { + await importSemgrep(); + + expect(await analyze()).toBe(EXIT.OK); + + const findings = JSON.parse(readFileSync(path.join(outDir, "findings.json"), "utf8")); + expect(findings.unsupported_claims).not.toContainEqual(expect.objectContaining({ + id: "imports-partial", + })); + expect(findings.findings).toContainEqual(expect.objectContaining({ + upstream: expect.objectContaining({ tool: "semgrep" }), + })); + }); + + it("accepts a legacy import only as partial", async () => { + await importSemgrep(); + unlinkSync(path.join(outDir, "imports", "semgrep-import-manifest.json")); + + expect(await analyze()).toBe(EXIT.OK); + + const findings = JSON.parse(readFileSync(path.join(outDir, "findings.json"), "utf8")); + expect(findings.completeness).toBe("partial"); + expect(findings.unsupported_claims).toContainEqual(expect.objectContaining({ + id: "imports-partial", + })); + }); + + it("fails the analysis when an imported findings file no longer matches its manifest", async () => { + await importSemgrep(); + const findingsPath = path.join(outDir, "imports", "semgrep-findings.json"); + writeFileSync(findingsPath, readFileSync(findingsPath, "utf8") + " ", "utf8"); + + expect(await analyze()).toBe(EXIT.SCAN_FAILED); + expect(existsSync(path.join(outDir, "findings.json"))).toBe(false); + }); + + it("discovers npm-audit imports through --from-imports", async () => { + const auditFile = path.join(root, "npm-audit.json"); + writeFileSync(auditFile, JSON.stringify({ + auditReportVersion: 2, + vulnerabilities: { + lodash: { + name: "lodash", + severity: "high", + isDirect: true, + via: [{ title: "Prototype pollution", severity: "high" }], + }, + }, + }), "utf8"); + expect(await importCommand( + ["npm-audit", auditFile, "--out", outDir, "--repo-root", repoRoot], + { VERSION, EXIT, getOption } + )).toBe(EXIT.OK); + + expect(await analyze()).toBe(EXIT.OK); + + const findings = JSON.parse(readFileSync(path.join(outDir, "findings.json"), "utf8")); + expect(findings.findings).toContainEqual(expect.objectContaining({ + upstream: expect.objectContaining({ tool: "npm-audit" }), + })); + }); +}); diff --git a/src/cli/__tests__/import-consumer.test.ts b/src/cli/__tests__/import-consumer.test.ts new file mode 100644 index 0000000..2711da3 --- /dev/null +++ b/src/cli/__tests__/import-consumer.test.ts @@ -0,0 +1,177 @@ +import { createHash } from "node:crypto"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { importCommand } from "../import.js"; +import { consumeImportArtifacts } from "../import-consumer.js"; +import { EXIT, getOption, VERSION } from "../exit-codes.js"; + +function semgrepReport(errors: Array<{ message: string }> = []) { + return { + version: "1.164.0", + results: [{ + check_id: "security.test", + path: "src/security.ts", + start: { line: 1, col: 1 }, + end: { line: 1, col: 2 }, + extra: { message: "finding", severity: "ERROR" }, + }], + errors, + }; +} + +function sha256Text(value: string): string { + return "sha256:" + createHash("sha256").update(value, "utf8").digest("hex"); +} + +describe("import artifact consumer", () => { + let root: string; + let caseDir: string; + let outDir: string; + const repoRoot = process.cwd(); + + beforeAll(() => { + root = mkdtempSync(path.join(tmpdir(), "ctg-import-consumer-")); + }); + + beforeEach(() => { + caseDir = path.join(root, "case"); + rmSync(caseDir, { recursive: true, force: true }); + mkdirSync(caseDir, { recursive: true }); + outDir = path.join(caseDir, "out"); + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(() => { + rmSync(root, { recursive: true, force: true }); + }); + + async function createImport(errors: Array<{ message: string }> = []): Promise<void> { + const inputFile = path.join(caseDir, "semgrep.json"); + writeFileSync(inputFile, JSON.stringify(semgrepReport(errors)), "utf8"); + const result = await importCommand( + ["semgrep", inputFile, "--out", outDir], + { VERSION, EXIT, getOption } + ); + expect(result).toBe(errors.length > 0 ? EXIT.PARTIAL_SUCCESS : EXIT.OK); + } + + function findingsPath(): string { + return path.join(outDir, "imports", "semgrep-findings.json"); + } + + function manifestPath(): string { + return path.join(outDir, "imports", "semgrep-import-manifest.json"); + } + + it("accepts a fully bound current import", async () => { + await createImport(); + + const consumed = await consumeImportArtifacts(path.join(outDir, "imports"), repoRoot); + + expect(consumed.completeness).toBe("complete"); + expect(consumed.loadedTools).toEqual(["semgrep"]); + expect(consumed.findings).toHaveLength(1); + expect(consumed.incompleteReasons).toEqual([]); + }); + + it("accepts a legacy artifact only as partial", async () => { + await createImport(); + unlinkSync(manifestPath()); + + const consumed = await consumeImportArtifacts(path.join(outDir, "imports"), repoRoot); + + expect(consumed.findings).toHaveLength(1); + expect(consumed.completeness).toBe("partial"); + expect(consumed.incompleteReasons).toEqual(["LEGACY_IMPORT_MANIFEST_MISSING:semgrep"]); + }); + + it("accepts a verified partial manifest and propagates diagnostics", async () => { + await createImport([{ message: "scanner failed one target" }]); + + const consumed = await consumeImportArtifacts(path.join(outDir, "imports"), repoRoot); + + expect(consumed.findings).toHaveLength(1); + expect(consumed.completeness).toBe("partial"); + expect(consumed.incompleteReasons).toEqual( + expect.arrayContaining(["IMPORT_PARTIAL:semgrep", "IMPORT_DIAGNOSTIC:semgrep:SEMGREP_ERROR"]) + ); + }); + + it("rejects normalized finding hash tampering", async () => { + await createImport(); + writeFileSync(findingsPath(), readFileSync(findingsPath(), "utf8") + " ", "utf8"); + + await expect( + consumeImportArtifacts(path.join(outDir, "imports"), repoRoot) + ).rejects.toThrow(/hash mismatch/); + }); + + it("rejects tool identity tampering", async () => { + await createImport(); + const manifest = JSON.parse(readFileSync(manifestPath(), "utf8")); + manifest.source.tool = "eslint"; + writeFileSync(manifestPath(), JSON.stringify(manifest, null, 2) + "\n", "utf8"); + + await expect( + consumeImportArtifacts(path.join(outDir, "imports"), repoRoot) + ).rejects.toThrow(/tool mismatch/); + }); + + it("rejects a stale but structurally valid repository revision", async () => { + await createImport(); + const findingsText = readFileSync(findingsPath(), "utf8"); + const findings = JSON.parse(findingsText); + const stale = "0".repeat(40); + findings.repo.revision = stale; + const rewrittenFindings = JSON.stringify(findings, null, 2) + "\n"; + writeFileSync(findingsPath(), rewrittenFindings, "utf8"); + + const manifest = JSON.parse(readFileSync(manifestPath(), "utf8")); + manifest.repo.revision = stale; + manifest.source.repository_revision = stale; + manifest.normalized.sha256 = sha256Text(rewrittenFindings); + manifest.normalized.size_bytes = Buffer.byteLength(rewrittenFindings, "utf8"); + writeFileSync(manifestPath(), JSON.stringify(manifest, null, 2) + "\n", "utf8"); + + await expect( + consumeImportArtifacts(path.join(outDir, "imports"), repoRoot) + ).rejects.toThrow(/current HEAD/); + }); + + it("rejects duplicate IDs against native findings", async () => { + await createImport(); + const imported = JSON.parse(readFileSync(findingsPath(), "utf8")); + const existingId = imported.findings[0].id; + + await expect( + consumeImportArtifacts(path.join(outDir, "imports"), repoRoot, [existingId]) + ).rejects.toThrow(/duplicate finding id/); + }); + + it("returns complete when the imports directory is absent", async () => { + const missing = path.join(outDir, "imports"); + expect(existsSync(missing)).toBe(false); + + await expect(consumeImportArtifacts(missing, repoRoot)).resolves.toEqual({ + findings: [], + completeness: "complete", + incompleteReasons: [], + loadedTools: [], + }); + }); +}); diff --git a/src/cli/__tests__/import-parsers.test.ts b/src/cli/__tests__/import-parsers.test.ts index 753270c..3c2721c 100644 --- a/src/cli/__tests__/import-parsers.test.ts +++ b/src/cli/__tests__/import-parsers.test.ts @@ -165,15 +165,33 @@ describe("import parsers", () => { partialFingerprints: { primaryLocationLineHash: "fp-one" }, properties: { tags: ["jwt"] }, }, - { ruleIndex: 1, level: "error" }, - { ruleIndex: 2, level: "warning" }, - { ruleIndex: 3, level: "note" }, + { + ruleIndex: 1, + level: "error", + locations: [{ physicalLocation: { artifactLocation: { uri: "src/b.ts" }, region: { startLine: 4 } } }], + }, + { + ruleIndex: 2, + level: "warning", + locations: [{ physicalLocation: { artifactLocation: { uri: "src/c.ts" }, region: { startLine: 5 } } }], + }, + { + ruleIndex: 3, + level: "note", + locations: [{ physicalLocation: { artifactLocation: { uri: "src/d.ts" }, region: { startLine: 6 } } }], + }, { ruleId: "xss-rule", level: "none", fingerprints: { primaryLocationLineHash: "fp-two" }, + locations: [{ physicalLocation: { artifactLocation: { uri: "src/e.ts" }, region: { startLine: 7 } } }], + }, + { + ruleId: "unknown level", + level: "other", + locations: [{ physicalLocation: { artifactLocation: { uri: "src/f.ts" }, region: { startLine: 8 } } }], }, - { ruleId: "unknown level", level: "other" }, + { ruleId: "location-missing", level: "error" }, ], }, ], diff --git a/src/cli/__tests__/import-security.test.ts b/src/cli/__tests__/import-security.test.ts new file mode 100644 index 0000000..5d3cb30 --- /dev/null +++ b/src/cli/__tests__/import-security.test.ts @@ -0,0 +1,299 @@ +import { createHash } from "node:crypto"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { importCommand } from "../import.js"; +import { + normalizeEvidencePath, + portableSourcePath, +} from "../import-provenance.js"; +import { EXIT, getOption, VERSION } from "../exit-codes.js"; + +function digest(value: Buffer | string): string { + return "sha256:" + createHash("sha256").update(value).digest("hex"); +} + +function semgrepResult(reportPath: string, checkId = "security.test") { + return { + check_id: checkId, + path: reportPath, + start: { line: 1, col: 1 }, + end: { line: 1, col: 2 }, + extra: { message: "security finding", severity: "ERROR" }, + }; +} + +describe("import security boundary", () => { + let root: string; + let caseDir: string; + let outDir: string; + + beforeAll(() => { + root = mkdtempSync(path.join(tmpdir(), "ctg-import-security-")); + }); + + beforeEach(() => { + caseDir = path.join(root, "case"); + rmSync(caseDir, { recursive: true, force: true }); + mkdirSync(caseDir, { recursive: true }); + outDir = path.join(caseDir, "out"); + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(() => { + rmSync(root, { recursive: true, force: true }); + }); + + function writeJson(name: string, value: unknown): string { + const filePath = path.join(caseDir, name); + writeFileSync(filePath, JSON.stringify(value), "utf8"); + return filePath; + } + + function artifact(tool: string, suffix: "findings" | "import-manifest"): Record<string, any> { + return JSON.parse(readFileSync(path.join(outDir, "imports", tool + "-" + suffix + ".json"), "utf8")); + } + + async function run(tool: string, inputFile: string, extra: string[] = []): Promise<number> { + return importCommand( + [tool, inputFile, "--out", outDir, ...extra], + { VERSION, EXIT, getOption } + ); + } + + it("records complete zero-result provenance with exact hashes and revision", async () => { + const inputFile = writeJson("empty-semgrep.json", { version: "1.164.0", results: [], errors: [] }); + + expect(await run("semgrep", inputFile)).toBe(EXIT.OK); + + const findingsPath = path.join(outDir, "imports", "semgrep-findings.json"); + const findings = artifact("semgrep", "findings"); + const manifest = artifact("semgrep", "import-manifest"); + expect(findings.completeness).toBe("complete"); + expect(findings.findings).toEqual([]); + expect(manifest.completeness).toBe("complete"); + expect(manifest.source).toMatchObject({ + tool: "semgrep", + producer: { name: "semgrep", version: "1.164.0" }, + sha256: digest(readFileSync(inputFile)), + size_bytes: readFileSync(inputFile).byteLength, + path_kind: "external_redacted", + }); + expect(manifest.source.repository_revision).toMatch(/^[0-9a-f]{40}$/); + expect(manifest.repo.revision).toBe(manifest.source.repository_revision); + expect(manifest.normalized).toMatchObject({ + path: "imports/semgrep-findings.json", + sha256: digest(readFileSync(findingsPath)), + size_bytes: readFileSync(findingsPath).byteLength, + schema: "findings@v1", + }); + expect(manifest.summary).toEqual({ seen: 0, accepted: 0, dropped: 0, errors: 0 }); + }); + + it("replaces an existing findings/manifest pair and keeps hashes synchronized", async () => { + const inputFile = writeJson("repeat-semgrep.json", { version: "1.164.0", results: [], errors: [] }); + expect(await run("semgrep", inputFile)).toBe(EXIT.OK); + const firstHash = artifact("semgrep", "import-manifest").normalized.sha256; + + writeFileSync( + inputFile, + JSON.stringify({ version: "1.164.0", results: [semgrepResult("src/security.ts")], errors: [] }), + "utf8" + ); + expect(await run("semgrep", inputFile)).toBe(EXIT.OK); + + const findingsPath = path.join(outDir, "imports", "semgrep-findings.json"); + const findings = artifact("semgrep", "findings"); + const manifest = artifact("semgrep", "import-manifest"); + expect(findings.findings).toHaveLength(1); + expect(manifest.normalized.sha256).not.toBe(firstHash); + expect(manifest.normalized.sha256).toBe(digest(readFileSync(findingsPath))); + }); + + it("returns partial and preserves scanner diagnostics", async () => { + const inputFile = writeJson("partial-semgrep.json", { + results: [semgrepResult("src/security.ts")], + errors: [{ message: "one file could not be scanned" }], + }); + + expect(await run("semgrep", inputFile)).toBe(EXIT.PARTIAL_SUCCESS); + + const findings = artifact("semgrep", "findings"); + const manifest = artifact("semgrep", "import-manifest"); + expect(findings.completeness).toBe("partial"); + expect(findings.unsupported_claims).toHaveLength(1); + expect(manifest.completeness).toBe("partial"); + expect(manifest.summary).toMatchObject({ seen: 1, accepted: 1, dropped: 0, errors: 1 }); + expect(manifest.diagnostics).toContainEqual(expect.objectContaining({ code: "SEMGREP_ERROR" })); + }); + + it("drops traversal, UNC, and duplicate evidence instead of trusting it", async () => { + const inputFile = writeJson("unsafe-semgrep.json", { + results: [ + semgrepResult("../escape.ts", "escape"), + semgrepResult("\\\\server\\share\\file.ts", "unc"), + semgrepResult("src/security.ts", "duplicate"), + semgrepResult("src/security.ts", "duplicate"), + ], + errors: [], + }); + + expect(await run("semgrep", inputFile)).toBe(EXIT.PARTIAL_SUCCESS); + + const findings = artifact("semgrep", "findings"); + const manifest = artifact("semgrep", "import-manifest"); + expect(findings.findings).toHaveLength(1); + expect(findings.findings[0].evidence[0].path).toBe("src/security.ts"); + expect(manifest.summary).toMatchObject({ seen: 4, accepted: 1, dropped: 3, errors: 0 }); + expect(manifest.diagnostics.map((item: { code: string }) => item.code)).toEqual( + expect.arrayContaining(["EVIDENCE_PATH_REJECTED", "DUPLICATE_FINDING_ID"]) + ); + }); + + it("drops SARIF records without a trustworthy location and never emits unknown:1", async () => { + const inputFile = writeJson("missing-location.sarif", { + version: "2.1.0", + runs: [{ + tool: { driver: { name: "Generic SARIF", version: "1.0.0" } }, + results: [{ ruleId: "missing-location", level: "error", message: { text: "missing" } }], + }], + }); + + expect(await run("sarif", inputFile)).toBe(EXIT.PARTIAL_SUCCESS); + + const findings = artifact("sarif", "findings"); + const manifest = artifact("sarif", "import-manifest"); + expect(findings.findings).toEqual([]); + expect(JSON.stringify(findings)).not.toContain("unknown"); + expect(manifest.summary).toMatchObject({ seen: 1, accepted: 0, dropped: 1, errors: 0 }); + expect(manifest.diagnostics).toContainEqual(expect.objectContaining({ code: "SARIF_LOCATION_MISSING" })); + }); + + it("imports npm audit v7+ JSON as security findings", async () => { + const inputFile = writeJson("npm-audit.json", { + auditReportVersion: 2, + vulnerabilities: { + lodash: { + name: "lodash", + severity: "high", + isDirect: true, + via: [{ + title: "Prototype pollution", + url: "https://example.invalid/advisory", + severity: "high", + cwe: ["CWE-1321"], + }], + range: "<4.17.21", + nodes: ["node_modules/lodash"], + }, + }, + }); + + expect(await run("npm-audit", inputFile)).toBe(EXIT.OK); + + const findings = artifact("npm-audit", "findings"); + const manifest = artifact("npm-audit", "import-manifest"); + expect(findings.findings).toHaveLength(1); + expect(findings.findings[0]).toMatchObject({ + ruleId: "NPM_AUDIT_LODASH", + category: "security", + severity: "high", + upstream: { tool: "npm-audit", ruleId: "lodash" }, + }); + expect(manifest.source).toMatchObject({ + tool: "npm-audit", + format: "npm-audit-json", + format_version: "2", + }); + }); + + it("fails closed without artifacts for malformed, oversize, bad shape, and false CodeQL identity", async () => { + const malformed = path.join(caseDir, "malformed.json"); + writeFileSync(malformed, "{", "utf8"); + expect(await run("semgrep", malformed)).toBe(EXIT.IMPORT_FAILED); + expect(existsSync(path.join(outDir, "imports"))).toBe(false); + + const oversize = writeJson("oversize.json", { results: [], errors: [] }); + expect(await run("semgrep", oversize, ["--max-input-mb", "0.000001"])).toBe(EXIT.IMPORT_FAILED); + expect(existsSync(path.join(outDir, "imports"))).toBe(false); + + const badShape = writeJson("bad-shape.json", { results: "not-an-array" }); + expect(await run("semgrep", badShape)).toBe(EXIT.IMPORT_FAILED); + expect(existsSync(path.join(outDir, "imports"))).toBe(false); + + const falseCodeql = writeJson("false-codeql.sarif", { + version: "2.1.0", + runs: [{ tool: { driver: { name: "Other Scanner" } }, results: [] }], + }); + expect(await run("codeql", falseCodeql)).toBe(EXIT.IMPORT_FAILED); + expect(existsSync(path.join(outDir, "imports"))).toBe(false); + }); + + it("returns schema failure and writes nothing when producer metadata violates the companion schema", async () => { + const inputFile = writeJson("bad-producer.sarif", { + version: "2.1.0", + runs: [{ + tool: { driver: { name: "x".repeat(129), version: "1" } }, + results: [{ + ruleId: "rule", + level: "warning", + message: { text: "finding" }, + locations: [{ + physicalLocation: { + artifactLocation: { uri: "src/security.ts" }, + region: { startLine: 1 }, + }, + }], + }], + }], + }); + + expect(await run("sarif", inputFile)).toBe(EXIT.SCHEMA_FAILED); + expect(existsSync(path.join(outDir, "imports"))).toBe(false); + }); +}); + +describe("import path provenance", () => { + let root: string; + let outside: string; + + beforeEach(() => { + root = mkdtempSync(path.join(tmpdir(), "ctg-import-root-")); + outside = mkdtempSync(path.join(tmpdir(), "ctg-import-outside-")); + mkdirSync(path.join(root, "src"), { recursive: true }); + writeFileSync(path.join(outside, "report.json"), "{}", "utf8"); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + rmSync(outside, { recursive: true, force: true }); + }); + + it("rejects symlink escapes for evidence and redacts escaped source paths", () => { + const link = path.join(root, "linked"); + symlinkSync(outside, link, process.platform === "win32" ? "junction" : "dir"); + + expect(normalizeEvidencePath("src/new.ts", root)).toBe("src/new.ts"); + expect(normalizeEvidencePath("../escape.ts", root)).toBeUndefined(); + expect(normalizeEvidencePath("\\\\server\\share\\file.ts", root)).toBeUndefined(); + expect(normalizeEvidencePath("linked/report.json", root)).toBeUndefined(); + + const source = portableSourcePath(path.join(link, "report.json"), root, "sha256:" + "a".repeat(64)); + expect(source.kind).toBe("external_redacted"); + expect(source.path).toMatch(/^external\/[0-9a-f]{16}-report\.json$/); + }); +}); diff --git a/src/cli/__tests__/plugin-sandbox-security.test.ts b/src/cli/__tests__/plugin-sandbox-security.test.ts new file mode 100644 index 0000000..d28f8f9 --- /dev/null +++ b/src/cli/__tests__/plugin-sandbox-security.test.ts @@ -0,0 +1,133 @@ +import { createHash } from "node:crypto"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { pluginSandboxCommand } from "../plugin-sandbox.js"; +import { EXIT, getOption, VERSION } from "../exit-codes.js"; + +function digest(filePath: string): string { + return "sha256:" + createHash("sha256").update(readFileSync(filePath)).digest("hex"); +} + +describe("plugin-sandbox security policy", () => { + let root: string; + let pluginRoot: string; + let manifestPath: string; + let entrypointPath: string; + let inputPath: string; + let policyPath: string; + + beforeAll(() => { + root = mkdtempSync(path.join(tmpdir(), "ctg-plugin-cli-security-")); + }); + + beforeEach(() => { + pluginRoot = path.join(root, "plugin"); + rmSync(pluginRoot, { recursive: true, force: true }); + mkdirSync(pluginRoot, { recursive: true }); + entrypointPath = path.join(pluginRoot, "plugin.mjs"); + writeFileSync( + entrypointPath, + 'process.stdin.resume(); process.stdin.on("data", () => {}); process.stdin.on("end", () => process.stdout.write(JSON.stringify({version:"ctg.plugin-output/v1",findings:[]})));\n', + "utf8" + ); + manifestPath = path.join(pluginRoot, "plugin-manifest.json"); + writeFileSync(manifestPath, JSON.stringify({ + apiVersion: "ctg/v1", + kind: "rule-plugin", + name: "trusted-cli-plugin", + version: "1.0.0", + visibility: "private", + entry: { command: ["node", "plugin.mjs"], timeout: 5, retry: 0 }, + capabilities: ["evaluate"], + receives: ["normalized-repo-graph@v1"], + returns: ["findings@v1"], + }, null, 2) + "\n", "utf8"); + inputPath = path.join(root, "input.json"); + writeFileSync(inputPath, JSON.stringify({ version: "ctg.plugin-input/v1" }), "utf8"); + policyPath = path.join(root, "policy.json"); + writeFileSync(policyPath, JSON.stringify({ + schema: "ctg/plugin-execution-policy/v1", + trusted_plugins: [{ + name: "trusted-cli-plugin", + version: "1.0.0", + manifest_sha256: digest(manifestPath), + entrypoint_sha256: digest(entrypointPath), + }], + process: { + timeout_seconds: 5, + max_stdout_bytes: 4096, + max_stderr_bytes: 1024, + max_findings: 10, + max_evidence_per_finding: 2, + node_permission_model: false, + }, + }), "utf8"); + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it("uses Process by default only for a digest-pinned trusted plugin", async () => { + await expect(pluginSandboxCommand([ + "run", + pluginRoot, + "--input", + inputPath, + "--execution-policy", + policyPath, + ], { VERSION, EXIT, getOption })).resolves.toBe(EXIT.OK); + }); + + it("requires Docker when Process trust is absent or tampered", async () => { + await expect(pluginSandboxCommand([ + "run", + pluginRoot, + "--input", + inputPath, + ], { VERSION, EXIT, getOption })).resolves.toBe(EXIT.PLUGIN_FAILED); + + writeFileSync(entrypointPath, "tampered\n", "utf8"); + await expect(pluginSandboxCommand([ + "run", + pluginRoot, + "--input", + inputPath, + "--execution-policy", + policyPath, + ], { VERSION, EXIT, getOption })).resolves.toBe(EXIT.PLUGIN_FAILED); + }); + + it("forbids none in CI even with the unsafe acknowledgement", async () => { + const previous = process.env.CI; + process.env.CI = "true"; + try { + await expect(pluginSandboxCommand([ + "run", + pluginRoot, + "--input", + inputPath, + "--sandbox", + "none", + "--unsafe-allow-none", + ], { VERSION, EXIT, getOption })).resolves.toBe(EXIT.USAGE_ERROR); + } finally { + if (previous === undefined) delete process.env.CI; + else process.env.CI = previous; + } + }); +}); diff --git a/src/cli/__tests__/plugin-sandbox.test.ts b/src/cli/__tests__/plugin-sandbox.test.ts index 53f7550..53dfd45 100644 --- a/src/cli/__tests__/plugin-sandbox.test.ts +++ b/src/cli/__tests__/plugin-sandbox.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; import { pluginSandboxCommand } from "../plugin-sandbox.js"; @@ -6,7 +6,14 @@ import { EXIT, VERSION, getOption } from "../exit-codes.js"; const TEST_DIR = path.join(process.cwd(), ".test-temp", "plugin-sandbox-cli"); +beforeEach(() => { + vi.stubEnv("CI", ""); + vi.stubEnv("GITHUB_ACTIONS", ""); + vi.stubEnv("CTG_RELEASE", ""); +}); + afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); rmSync(TEST_DIR, { recursive: true, force: true }); }); @@ -19,7 +26,7 @@ describe("plugin-sandbox run CLI", () => { await expect( pluginSandboxCommand(["--help"], { VERSION, EXIT, getOption }) ).resolves.toBe(EXIT.OK); - expect(log).toHaveBeenCalledWith(expect.stringContaining("--sandbox <docker|none>")); + expect(log).toHaveBeenCalledWith(expect.stringContaining("--sandbox <process|docker|none>")); await expect( pluginSandboxCommand(["unknown"], { VERSION, EXIT, getOption }) @@ -87,7 +94,7 @@ describe("plugin-sandbox run CLI", () => { expect(available.checkDockerImageExists).toHaveBeenCalledWith("image with spaces"); }); - it("requires an explicit sandbox mode", async () => { + it("defaults to Process mode before validating paths", async () => { const error = vi.spyOn(console, "error").mockImplementation(() => undefined); const exitCode = await pluginSandboxCommand( @@ -97,7 +104,7 @@ describe("plugin-sandbox run CLI", () => { expect(exitCode).toBe(EXIT.USAGE_ERROR); expect(error).toHaveBeenCalledWith( - "Error: Sandbox mode required (--sandbox docker|none)" + "Error: Plugin path does not exist: plugin" ); }); @@ -111,7 +118,7 @@ describe("plugin-sandbox run CLI", () => { expect(exitCode).toBe(EXIT.USAGE_ERROR); expect(error).toHaveBeenCalledWith( - "Error: Invalid sandbox mode: docer. Expected docker or none." + "Error: Invalid sandbox mode: docer. Expected process, docker, or none." ); }); @@ -210,6 +217,7 @@ describe("plugin-sandbox run CLI", () => { inputPath, "--sandbox", "none", + "--unsafe-allow-none", ], { VERSION, EXIT, @@ -256,7 +264,7 @@ describe("plugin-sandbox run CLI", () => { vi.spyOn(console, "log").mockImplementation(() => undefined); const exitCode = await pluginSandboxCommand( - ["run", pluginDir, "--input", inputPath, "--sandbox", "none"], + ["run", pluginDir, "--input", inputPath, "--sandbox", "none", "--unsafe-allow-none"], { VERSION, EXIT, getOption } ); @@ -320,6 +328,7 @@ describe("plugin-sandbox run CLI", () => { inputPath, "--sandbox", "none", + "--unsafe-allow-none", "--verbose", ]; await expect( @@ -329,7 +338,7 @@ describe("plugin-sandbox run CLI", () => { getOption, dependencies, }) - ).resolves.toBe(EXIT.OK); + ).resolves.toBe(EXIT.PARTIAL_SUCCESS); expect(existsSync(outputPath)).toBe(true); expect(JSON.parse(readFileSync(outputPath, "utf8")).errors[0].code).toBe("PARTIAL"); diff --git a/src/cli/analyze.ts b/src/cli/analyze.ts index 647f207..8cc8302 100644 --- a/src/cli/analyze.ts +++ b/src/cli/analyze.ts @@ -10,11 +10,11 @@ import { createParserRegistry } from "../adapters/parser-registry.js"; import { CORE_RULES, DATABASE_RULES } from "../rules/index.js"; import { EXIT, getOption, VERSION } from "./exit-codes.js"; import { emitCliError, emitCliSummary } from "./output.js"; +import { consumeImportArtifacts } from "./import-consumer.js"; import { EmitFormat, AuditOutputArtifact, - FindingsArtifact, } from "../types/artifacts.js"; import { CtgPolicy, @@ -431,25 +431,27 @@ Provide concise, actionable findings.`, llmProviderName ); - // Load imported findings if --from-imports is specified (P1-04) + // Load imported findings through the provenance-verifying consumer. + const incompleteReasons = findings.completeness === "partial" + ? findings.unsupported_claims.map((claim) => claim.id) + : []; if (fromImports) { const importsDir = nodePathService.join(absoluteOutDir, "imports"); - if (nodeFileAccess.exists(importsDir)) { - const importFiles = ["eslint-findings.json", "semgrep-findings.json", "tsc-findings.json", "coverage-findings.json", "test-findings.json"]; - for (const importFile of importFiles) { - const importPath = nodePathService.join(importsDir, importFile); - if (nodeFileAccess.exists(importPath)) { - const importedContent = nodeFileAccess.readFile(importPath); - if (importedContent) { - try { - const importedData = JSON.parse(importedContent) as FindingsArtifact; - findings.findings.push(...importedData.findings); - } catch { - console.error(`Warning: Failed to load import file: ${importFile}`); - } - } - } - } + const consumed = await consumeImportArtifacts( + importsDir, + repoRoot, + findings.findings.map((finding) => finding.id) + ); + findings.findings.push(...consumed.findings); + if (consumed.completeness === "partial") { + findings.completeness = "partial"; + incompleteReasons.push(...consumed.incompleteReasons); + findings.unsupported_claims.push({ + id: "imports-partial", + claim: "All imported findings have a current, verified provenance manifest.", + reason: "missing_evidence", + sourceSection: "imports", + }); } } @@ -482,7 +484,12 @@ Provide concise, actionable findings.`, } // Evaluate policy using shared evaluator (unified with readiness) - const evalResult = policy ? evaluatePolicy(findings.findings, policy, suppressions) : undefined; + const evalResult = policy + ? evaluatePolicy(findings.findings, policy, suppressions, { + completeness: findings.completeness, + incompleteReasons: [...new Set(incompleteReasons)].sort(), + }) + : undefined; const readinessStatus = evalResult?.status ?? "passed"; const finalExitCode = evalResult ? getExitCode(readinessStatus) : options.EXIT.OK; diff --git a/src/cli/import-consumer.ts b/src/cli/import-consumer.ts new file mode 100644 index 0000000..ab9e871 --- /dev/null +++ b/src/cli/import-consumer.ts @@ -0,0 +1,208 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import type { + Completeness, + Finding, + FindingsArtifact, + ImportManifestArtifact, +} from "../types/artifacts.js"; +import { + normalizeEvidencePath, + repositoryRevision, + sha256Bytes, + sha256Text, + type ImportTool, +} from "./import-provenance.js"; +import { validateArtifactObject } from "./schema-validate.js"; + +const IMPORT_TOOLS: ImportTool[] = [ + "eslint", + "semgrep", + "sarif", + "codeql", + "npm-audit", + "tsc", + "coverage", + "test", +]; + +export interface ImportConsumptionResult { + findings: Finding[]; + completeness: Completeness; + incompleteReasons: string[]; + loadedTools: ImportTool[]; +} + +export class ImportConsumptionError extends Error { + constructor(message: string) { + super(message); + this.name = "ImportConsumptionError"; + } +} + +function parseJson(filePath: string, content: string): unknown { + try { + return JSON.parse(content); + } catch (error) { + throw new ImportConsumptionError( + "invalid import artifact JSON at " + filePath + ": " + + (error instanceof Error ? error.message : String(error)) + ); + } +} + +async function validateArtifact(value: unknown, filePath: string): Promise<void> { + const validation = await validateArtifactObject(value, path.basename(filePath)); + if (validation.status !== "ok") { + throw new ImportConsumptionError( + "invalid import artifact schema at " + filePath + ": " + + (validation.errors ?? ["unknown validation error"]).join("; ") + ); + } +} + +function assertManifestBinding( + tool: ImportTool, + manifest: ImportManifestArtifact, + findings: FindingsArtifact, + findingsContent: string, + findingsFile: string, + repoRoot: string, + currentRevision: string | undefined +): void { + const expectedNormalizedPath = "imports/" + findingsFile; + if (manifest.source.tool !== tool) { + throw new ImportConsumptionError("import manifest tool mismatch for " + tool); + } + if (manifest.normalized.path.replace(/\\/g, "/") !== expectedNormalizedPath) { + throw new ImportConsumptionError("import manifest normalized path mismatch for " + tool); + } + if (manifest.normalized.sha256 !== sha256Text(findingsContent)) { + throw new ImportConsumptionError("import findings hash mismatch for " + tool); + } + if (manifest.normalized.size_bytes !== Buffer.byteLength(findingsContent, "utf8")) { + throw new ImportConsumptionError("import findings size mismatch for " + tool); + } + if (manifest.normalized.schema !== "findings@v1" || findings.schema !== "findings@v1") { + throw new ImportConsumptionError("import findings schema binding mismatch for " + tool); + } + if (manifest.run_id !== findings.run_id || manifest.completeness !== findings.completeness) { + throw new ImportConsumptionError("import artifact run or completeness mismatch for " + tool); + } + if (manifest.summary.accepted !== findings.findings.length) { + throw new ImportConsumptionError("import accepted finding count mismatch for " + tool); + } + if (manifest.completeness === "complete" && (manifest.summary.dropped !== 0 || manifest.summary.errors !== 0)) { + throw new ImportConsumptionError("complete import manifest records dropped results or scanner errors for " + tool); + } + + const revision = manifest.source.repository_revision; + if ( + !revision + || !/^[0-9a-f]{40}$/.test(revision) + || manifest.repo.revision !== revision + || findings.repo.revision !== revision + ) { + throw new ImportConsumptionError("import repository revision binding is missing or invalid for " + tool); + } + if (!currentRevision || revision !== currentRevision) { + throw new ImportConsumptionError("import repository revision does not match current HEAD for " + tool); + } + + if (manifest.source.path_kind === "repo_relative") { + const safeSourcePath = normalizeEvidencePath(manifest.source.path, repoRoot); + if (!safeSourcePath || safeSourcePath !== manifest.source.path.replace(/\\/g, "/")) { + throw new ImportConsumptionError("import source path escapes the repository for " + tool); + } + const sourcePath = path.join(repoRoot, ...safeSourcePath.split("/")); + if (!existsSync(sourcePath)) { + throw new ImportConsumptionError("import source report is missing for " + tool); + } + const sourceBytes = readFileSync(sourcePath); + if ( + manifest.source.sha256 !== sha256Bytes(sourceBytes) + || manifest.source.size_bytes !== sourceBytes.byteLength + ) { + throw new ImportConsumptionError("import source report hash or size mismatch for " + tool); + } + } +} + +function assertFindingIdentities( + tool: ImportTool, + artifact: FindingsArtifact, + findingIds: Set<string> +): void { + for (const finding of artifact.findings) { + if (finding.upstream?.tool !== tool) { + throw new ImportConsumptionError("import finding tool identity mismatch for " + tool + ": " + finding.id); + } + if (findingIds.has(finding.id)) { + throw new ImportConsumptionError("duplicate finding id across analysis inputs: " + finding.id); + } + findingIds.add(finding.id); + } +} + +export async function consumeImportArtifacts( + importsDir: string, + repoRoot: string, + existingFindingIds: Iterable<string> = [] +): Promise<ImportConsumptionResult> { + if (!existsSync(importsDir)) { + return { findings: [], completeness: "complete", incompleteReasons: [], loadedTools: [] }; + } + + const findings: Finding[] = []; + const incompleteReasons: string[] = []; + const loadedTools: ImportTool[] = []; + const findingIds = new Set(existingFindingIds); + const currentRevision = repositoryRevision(repoRoot); + + for (const tool of IMPORT_TOOLS) { + const findingsFile = tool + "-findings.json"; + const findingsPath = path.join(importsDir, findingsFile); + if (!existsSync(findingsPath)) continue; + + const findingsContent = readFileSync(findingsPath, "utf8"); + const findingsValue = parseJson(findingsPath, findingsContent); + await validateArtifact(findingsValue, findingsPath); + const findingsArtifact = findingsValue as FindingsArtifact; + assertFindingIdentities(tool, findingsArtifact, findingIds); + + const manifestPath = path.join(importsDir, tool + "-import-manifest.json"); + if (!existsSync(manifestPath)) { + incompleteReasons.push("LEGACY_IMPORT_MANIFEST_MISSING:" + tool); + } else { + const manifestContent = readFileSync(manifestPath, "utf8"); + const manifestValue = parseJson(manifestPath, manifestContent); + await validateArtifact(manifestValue, manifestPath); + const manifest = manifestValue as ImportManifestArtifact; + assertManifestBinding( + tool, + manifest, + findingsArtifact, + findingsContent, + findingsFile, + repoRoot, + currentRevision + ); + if (manifest.completeness === "partial") { + incompleteReasons.push("IMPORT_PARTIAL:" + tool); + for (const item of manifest.diagnostics) { + incompleteReasons.push("IMPORT_DIAGNOSTIC:" + tool + ":" + item.code); + } + } + } + + findings.push(...findingsArtifact.findings); + loadedTools.push(tool); + } + + return { + findings, + completeness: incompleteReasons.length > 0 ? "partial" : "complete", + incompleteReasons: [...new Set(incompleteReasons)].sort(), + loadedTools, + }; +} diff --git a/src/cli/import-parsers.ts b/src/cli/import-parsers.ts index e6032e3..077992c 100644 --- a/src/cli/import-parsers.ts +++ b/src/cli/import-parsers.ts @@ -90,10 +90,31 @@ interface SarifLog { interface TSCDiagnostic { file?: string; code: number | string; - message: string; + message?: unknown; + messageText?: unknown; start?: { line: number; character: number }; end?: { line: number; character: number }; - category?: number; + category?: number | string; +} + +interface NpmAuditAdvisory { + title?: string; + url?: string; + severity?: string; + cwe?: string[]; +} + +interface NpmAuditVulnerability { + name?: string; + severity?: string; + isDirect?: boolean; + via?: Array<string | NpmAuditAdvisory>; + range?: string; + nodes?: string[]; +} + +interface NpmAuditResult { + vulnerabilities: Record<string, NpmAuditVulnerability>; } interface CoverageResult { @@ -104,6 +125,10 @@ interface CoverageResult { }>; } +function importedJson<T>(inputFile: string, input?: unknown): T { + return (input === undefined ? JSON.parse(readFileSync(inputFile, "utf8")) : input) as T; +} + /** * Generate unique ID for imported finding */ @@ -166,8 +191,21 @@ function mapSARIFSeverity(level: string | undefined, rule?: SarifRule, result?: /** * Map TSC category to code-to-gate severity */ -export function mapTSCSeverity(category: number | undefined): Severity { - return category === 1 ? "high" : "medium"; +export function mapTSCSeverity(category: number | string | undefined): Severity { + return category === 1 || String(category).toLowerCase() === "error" ? "high" : "medium"; +} + +export function mapNpmAuditSeverity(severity: string | undefined): Severity { + switch ((severity ?? "").toLowerCase()) { + case "critical": + return "critical"; + case "high": + return "high"; + case "moderate": + return "medium"; + default: + return "low"; + } } /** @@ -245,9 +283,8 @@ function sanitizeRuleIdForPrefix(ruleId: string): string { /** * Import ESLint results */ -export function importESLint(inputFile: string): Finding[] { - const content = readFileSync(inputFile, "utf8"); - const results: ESLintResult[] = JSON.parse(content); +export function importESLint(inputFile: string, input?: unknown): Finding[] { + const results = importedJson<ESLintResult[]>(inputFile, input); const findings: Finding[] = []; for (const result of results) { @@ -282,9 +319,8 @@ export function importESLint(inputFile: string): Finding[] { /** * Import Semgrep results */ -export function importSemgrep(inputFile: string): Finding[] { - const content = readFileSync(inputFile, "utf8"); - const result: SemgrepResult = JSON.parse(content); +export function importSemgrep(inputFile: string, input?: unknown): Finding[] { + const result = importedJson<SemgrepResult>(inputFile, input); const findings: Finding[] = []; for (const finding of result.results) { @@ -319,9 +355,8 @@ export function importSemgrep(inputFile: string): Finding[] { * Import SARIF 2.1.0 results. CodeQL is accepted through the same parser by * passing sourceTool="codeql". */ -export function importSARIF(inputFile: string, sourceTool: Extract<UpstreamTool, "sarif" | "codeql"> = "sarif"): Finding[] { - const content = readFileSync(inputFile, "utf8"); - const log: SarifLog = JSON.parse(content); +export function importSARIF(inputFile: string, sourceTool: Extract<UpstreamTool, "sarif" | "codeql"> = "sarif", input?: unknown): Finding[] { + const log = importedJson<SarifLog>(inputFile, input); const findings: Finding[] = []; for (const run of log.runs ?? []) { @@ -332,10 +367,16 @@ export function importSARIF(inputFile: string, sourceTool: Extract<UpstreamTool, const rule = result.ruleIndex !== undefined ? rules[result.ruleIndex] : undefined; const ruleId = result.ruleId ?? rule?.id ?? "unknown-rule"; const location = result.locations?.[0]?.physicalLocation; - const filePath = location?.artifactLocation?.uri ?? "unknown"; - const line = location?.region?.startLine ?? 1; - const endLine = location?.region?.endLine ?? line; - const findingId = generateImportId(sourceTool, ruleId, filePath, line); + const filePath = location?.artifactLocation?.uri; + const line = location?.region?.startLine; + if (!filePath || !Number.isInteger(line) || (line as number) < 1) { + continue; + } + const trustedLine = line as number; + const endLine = Number.isInteger(location?.region?.endLine) && (location?.region?.endLine as number) >= trustedLine + ? location?.region?.endLine as number + : trustedLine; + const findingId = generateImportId(sourceTool, ruleId, filePath, trustedLine); const category = inferCategoryFromSarif(ruleId, rule, result); const tags = normalizeTags(rule?.properties?.tags, result.properties?.tags); const rawFingerprint = result.partialFingerprints?.primaryLocationLineHash @@ -355,7 +396,7 @@ export function importSARIF(inputFile: string, sourceTool: Extract<UpstreamTool, evidence: [{ id: generateImportEvidenceId(findingId, 0), path: filePath, - startLine: line, + startLine: trustedLine, endLine, kind: "external", externalRef: { tool: sourceTool, ruleId }, @@ -373,9 +414,23 @@ export function importSARIF(inputFile: string, sourceTool: Extract<UpstreamTool, /** * Import TypeScript compiler results */ -export function importTSC(inputFile: string): Finding[] { - const content = readFileSync(inputFile, "utf8"); - const diagnostics: TSCDiagnostic[] = JSON.parse(content); +function tscMessage(value: unknown): string { + if (typeof value === "string") return value; + if (value && typeof value === "object") { + const record = value as { messageText?: unknown; next?: unknown[] }; + const head = tscMessage(record.messageText); + const tail = Array.isArray(record.next) ? record.next.map(tscMessage).filter(Boolean) : []; + return [head, ...tail].filter(Boolean).join(" "); + } + return "TypeScript compiler diagnostic"; +} + +export function importTSC(inputFile: string, input?: unknown): Finding[] { + const parsed = importedJson<TSCDiagnostic[] | { diagnostics?: TSCDiagnostic[] }>(inputFile, input); + const diagnostics = Array.isArray(parsed) ? parsed : parsed.diagnostics; + if (!Array.isArray(diagnostics)) { + throw new Error("TSC input must be an array or contain diagnostics"); + } const findings: Finding[] = []; for (const diag of diagnostics) { @@ -392,7 +447,7 @@ export function importTSC(inputFile: string): Finding[] { severity: mapTSCSeverity(diag.category), confidence: 0.95, title: `TypeScript Error TS${diag.code}`, - summary: diag.message, + summary: tscMessage(diag.message ?? diag.messageText), evidence: [{ id: generateImportEvidenceId(findingId, 0), path: diag.file, @@ -408,12 +463,64 @@ export function importTSC(inputFile: string): Finding[] { return findings; } +/** + * Import npm audit v7+ JSON results. + */ +export function importNpmAudit(inputFile: string, evidencePath = "package-lock.json", input?: unknown): Finding[] { + const result = importedJson<NpmAuditResult>(inputFile, input); + if (!result.vulnerabilities || typeof result.vulnerabilities !== "object") { + throw new Error("npm audit input must contain vulnerabilities"); + } + + const findings: Finding[] = []; + for (const [packageName, vulnerability] of Object.entries(result.vulnerabilities)) { + const advisories = Array.isArray(vulnerability.via) + ? vulnerability.via.filter((item): item is NpmAuditAdvisory => typeof item === "object" && item !== null) + : []; + const severity = vulnerability.severity + ?? advisories.map((item) => item.severity).find((value): value is string => typeof value === "string"); + const findingId = generateImportId("npm-audit", packageName, evidencePath, 1); + const advisoryTitles = advisories + .map((item) => item.title) + .filter((value): value is string => typeof value === "string" && value.length > 0); + const tags = new Set<string>([ + vulnerability.isDirect ? "direct-dependency" : "transitive-dependency", + ...advisories.flatMap((item) => item.cwe ?? []), + ]); + + findings.push({ + id: findingId, + ruleId: `NPM_AUDIT_${sanitizeRuleIdForPrefix(packageName)}`, + category: "security", + severity: mapNpmAuditSeverity(severity), + confidence: 0.95, + title: `Vulnerable dependency: ${vulnerability.name ?? packageName}`, + summary: advisoryTitles.length > 0 + ? advisoryTitles.join("; ") + : `npm audit reported ${severity ?? "unknown"} severity for ${packageName}${vulnerability.range ? ` (${vulnerability.range})` : ""}`, + evidence: [{ + id: generateImportEvidenceId(findingId, 0), + path: evidencePath, + kind: "external", + externalRef: { + tool: "npm-audit", + ruleId: packageName, + ...(advisories[0]?.url ? { url: advisories[0].url } : {}), + }, + }], + upstream: { tool: "npm-audit", ruleId: packageName }, + tags: [...tags].sort(), + }); + } + + return findings; +} + /** * Import coverage results */ -export function importCoverage(inputFile: string): Finding[] { - const content = readFileSync(inputFile, "utf8"); - const result: CoverageResult = JSON.parse(content); +export function importCoverage(inputFile: string, input?: unknown): Finding[] { + const result = importedJson<CoverageResult>(inputFile, input); const findings: Finding[] = []; for (const [filePath, coverage] of Object.entries(result.coverageMap)) { @@ -469,9 +576,8 @@ export function importCoverage(inputFile: string): Finding[] { /** * Import test results (generic format) */ -export function importTest(inputFile: string): Finding[] { - const content = readFileSync(inputFile, "utf8"); - const results = JSON.parse(content); +export function importTest(inputFile: string, input?: unknown): Finding[] { + const results = importedJson<unknown>(inputFile, input); const findings: Finding[] = []; if (Array.isArray(results)) { diff --git a/src/cli/import-provenance.ts b/src/cli/import-provenance.ts new file mode 100644 index 0000000..1022565 --- /dev/null +++ b/src/cli/import-provenance.ts @@ -0,0 +1,381 @@ +import { execFileSync } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { existsSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Finding, ImportDiagnostic, UpstreamTool } from "../types/artifacts.js"; + +export type ImportTool = Exclude<UpstreamTool, "native" | "sonarqube">; + +export const DEFAULT_MAX_INPUT_BYTES = 50 * 1024 * 1024; +export const MAX_IMPORT_INPUT_BYTES = 1024 * 1024 * 1024; +export const MAX_IMPORTED_FINDINGS = 100_000; +export const MAX_IMPORT_DIAGNOSTICS = 100; + +export class ImportInputError extends Error { + constructor(message: string) { + super(message); + this.name = "ImportInputError"; + } +} + +export interface ImportInput { + bytes: Buffer; + data: unknown; + sha256: `sha256:${string}`; + sizeBytes: number; +} + +export interface ImportInspection { + seen: number; + dropped: number; + errors: number; + diagnostics: ImportDiagnostic[]; + producerName: string; + producerVersion: string; + formatVersion?: string; +} + +export interface NormalizedImport { + findings: Finding[]; + dropped: number; + diagnostics: ImportDiagnostic[]; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function records(value: unknown): Record<string, unknown>[] { + return Array.isArray(value) ? value.filter(isRecord) : []; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function diagnostic(code: string, message: string, recordIndex?: number): ImportDiagnostic { + return { + code: code.slice(0, 64), + message: message.slice(0, 4096), + ...(recordIndex === undefined ? {} : { record_index: recordIndex }), + }; +} + +function boundedDiagnostics(values: ImportDiagnostic[]): ImportDiagnostic[] { + return values.slice(0, MAX_IMPORT_DIAGNOSTICS); +} + +function countEslintMessages(value: unknown): number { + return records(value).reduce((total, result) => total + (Array.isArray(result.messages) ? result.messages.length : 0), 0); +} + +function inspectSarif(tool: ImportTool, value: Record<string, unknown>): ImportInspection { + if (value.version !== "2.1.0" || !Array.isArray(value.runs)) { + throw new ImportInputError("SARIF input must be a SARIF 2.1.0 log with runs"); + } + const diagnostics: ImportDiagnostic[] = []; + let seen = 0; + let dropped = 0; + const producerNames = new Set<string>(); + const producerVersions = new Set<string>(); + + for (const run of records(value.runs)) { + const toolValue = isRecord(run.tool) ? run.tool : undefined; + const driver = toolValue && isRecord(toolValue.driver) ? toolValue.driver : undefined; + const producerName = stringValue(driver?.name); + const producerVersion = stringValue(driver?.semanticVersion) ?? stringValue(driver?.version); + if (producerName) producerNames.add(producerName); + if (producerVersion) producerVersions.add(producerVersion); + + for (const invocation of records(run.invocations)) { + if (invocation.executionSuccessful === false) { + diagnostics.push(diagnostic("SARIF_INVOCATION_FAILED", "SARIF producer reported an unsuccessful invocation")); + } + } + + const results = records(run.results); + seen += results.length; + results.forEach((result, index) => { + const locations = records(result.locations); + const physical = locations[0] && isRecord(locations[0].physicalLocation) ? locations[0].physicalLocation as Record<string, unknown> : undefined; + const artifactLocation = physical && isRecord(physical.artifactLocation) ? physical.artifactLocation : undefined; + const region = physical && isRecord(physical.region) ? physical.region : undefined; + if (!stringValue(artifactLocation?.uri) || !Number.isInteger(region?.startLine) || (region?.startLine as number) < 1) { + dropped += 1; + diagnostics.push(diagnostic("SARIF_LOCATION_MISSING", "SARIF result has no trustworthy repository path and start line", index)); + } + }); + } + + if (tool === "codeql" && (producerNames.size === 0 || ![...producerNames].some((name) => /codeql/i.test(name)))) { + throw new ImportInputError("CodeQL import requires SARIF whose driver identifies CodeQL"); + } + + return { + seen, + dropped, + errors: diagnostics.filter((item) => item.code === "SARIF_INVOCATION_FAILED").length, + diagnostics: boundedDiagnostics(diagnostics), + producerName: [...producerNames].sort().join(",") || tool, + producerVersion: [...producerVersions].sort().join(",") || "unknown", + formatVersion: "2.1.0", + }; +} + +export function inspectImportInput(tool: ImportTool, value: unknown): ImportInspection { + if (tool === "eslint") { + if (!Array.isArray(value)) throw new ImportInputError("ESLint input must be an array"); + return { seen: countEslintMessages(value), dropped: 0, errors: 0, diagnostics: [], producerName: "eslint", producerVersion: "unknown" }; + } + + if (tool === "semgrep") { + if (!isRecord(value) || !Array.isArray(value.results)) { + throw new ImportInputError("Semgrep input must contain a results array"); + } + const scannerErrors = records(value.errors); + const diagnostics = scannerErrors.map((entry, index) => + diagnostic("SEMGREP_ERROR", stringValue(entry.message) ?? "Semgrep reported an error", index)); + return { + seen: value.results.length, + dropped: 0, + errors: scannerErrors.length, + diagnostics: boundedDiagnostics(diagnostics), + producerName: "semgrep", + producerVersion: stringValue(value.version) ?? "unknown", + }; + } + + if (tool === "sarif" || tool === "codeql") { + if (!isRecord(value)) throw new ImportInputError("SARIF input must be an object"); + return inspectSarif(tool, value); + } + + if (tool === "tsc") { + const values = Array.isArray(value) ? value : isRecord(value) && Array.isArray(value.diagnostics) ? value.diagnostics : undefined; + if (!values) throw new ImportInputError("TSC input must be an array or contain diagnostics"); + return { seen: values.length, dropped: 0, errors: 0, diagnostics: [], producerName: "typescript", producerVersion: "unknown" }; + } + + if (tool === "coverage") { + if (!isRecord(value) || !isRecord(value.coverageMap)) { + throw new ImportInputError("Coverage input must contain coverageMap"); + } + return { seen: Object.keys(value.coverageMap).length, dropped: 0, errors: 0, diagnostics: [], producerName: "coverage", producerVersion: "unknown" }; + } + + if (tool === "test") { + if (!Array.isArray(value)) throw new ImportInputError("Test input must be an array"); + return { seen: value.length, dropped: 0, errors: 0, diagnostics: [], producerName: "test", producerVersion: "unknown" }; + } + + if (tool === "npm-audit") { + if (!isRecord(value) || !isRecord(value.vulnerabilities)) { + throw new ImportInputError("npm audit input must contain vulnerabilities"); + } + const auditError = isRecord(value.error) ? value.error : undefined; + const diagnostics = auditError + ? [diagnostic("NPM_AUDIT_ERROR", stringValue(auditError.summary) ?? stringValue(auditError.message) ?? "npm audit reported an error")] + : []; + return { + seen: Object.keys(value.vulnerabilities).length, + dropped: 0, + errors: diagnostics.length, + diagnostics, + producerName: "npm", + producerVersion: "unknown", + formatVersion: String(value.auditReportVersion ?? "unknown"), + }; + } + + throw new ImportInputError(`unsupported import tool: ${tool}`); +} + +export function readImportInput(inputFile: string, maxInputBytes: number): ImportInput { + const sizeBytes = statSync(inputFile).size; + if (sizeBytes > maxInputBytes) { + throw new ImportInputError(`input exceeds configured maximum of ${maxInputBytes} bytes`); + } + const bytes = readFileSync(inputFile); + let data: unknown; + try { + data = JSON.parse(bytes.toString("utf8")); + } catch (error) { + throw new ImportInputError(`invalid JSON: ${error instanceof Error ? error.message : String(error)}`); + } + return { + bytes, + data, + sha256: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, + sizeBytes, + }; +} + +function normalizedAbsolutePath(rawPath: string, repoRoot: string): string | undefined { + const hasControlCharacter = [...rawPath].some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 0x1f || codePoint === 0x7f; + }); + if (!rawPath || rawPath === "unknown" || hasControlCharacter || /^\\\\|^\/\//.test(rawPath)) return undefined; + let candidate = rawPath; + if (/^file:/i.test(candidate)) { + try { + candidate = fileURLToPath(candidate); + } catch { + return undefined; + } + } else if (/^[a-z][a-z0-9+.-]*:/i.test(candidate) && !path.win32.isAbsolute(candidate)) { + return undefined; + } + return path.isAbsolute(candidate) || path.win32.isAbsolute(candidate) + ? path.resolve(candidate) + : path.resolve(repoRoot, candidate); +} + +function inside(root: string, target: string): boolean { + const normalizedRoot = process.platform === "win32" ? root.toLowerCase() : root; + const normalizedTarget = process.platform === "win32" ? target.toLowerCase() : target; + const relative = path.relative(normalizedRoot, normalizedTarget); + return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); +} + +function nearestExistingAncestor(candidate: string): string | undefined { + let current = candidate; + while (!existsSync(current)) { + const parent = path.dirname(current); + if (parent === current) return undefined; + current = parent; + } + return current; +} + +export function normalizeEvidencePath(rawPath: string, repoRoot: string): string | undefined { + const realRoot = realpathSync(repoRoot); + const candidate = normalizedAbsolutePath(rawPath, realRoot); + if (!candidate || !inside(realRoot, candidate)) return undefined; + const ancestor = nearestExistingAncestor(candidate); + if (!ancestor || !inside(realRoot, realpathSync(ancestor))) return undefined; + if (existsSync(candidate) && !inside(realRoot, realpathSync(candidate))) return undefined; + const relative = path.relative(realRoot, candidate).replace(/\\/g, "/"); + return relative && relative !== ".." && !relative.startsWith("../") ? relative : undefined; +} + +export function normalizeImportFindings(findings: Finding[], repoRoot: string): NormalizedImport { + const accepted: Finding[] = []; + const diagnostics: ImportDiagnostic[] = []; + const ids = new Set<string>(); + let dropped = 0; + + for (let index = 0; index < findings.length; index += 1) { + const finding = findings[index]; + if (ids.has(finding.id)) { + dropped += 1; + diagnostics.push(diagnostic("DUPLICATE_FINDING_ID", `duplicate finding id: ${finding.id}`, index)); + continue; + } + const normalizedEvidence = finding.evidence.map((evidence) => { + const normalizedPath = normalizeEvidencePath(evidence.path, repoRoot); + return normalizedPath ? { ...evidence, path: normalizedPath } : undefined; + }); + if (normalizedEvidence.some((value) => value === undefined)) { + dropped += 1; + diagnostics.push(diagnostic("EVIDENCE_PATH_REJECTED", "finding evidence is outside the repository or is not safely resolvable", index)); + continue; + } + if (accepted.length >= MAX_IMPORTED_FINDINGS) { + dropped += 1; + diagnostics.push(diagnostic("FINDING_LIMIT_EXCEEDED", `import accepts at most ${MAX_IMPORTED_FINDINGS} findings`, index)); + continue; + } + ids.add(finding.id); + accepted.push({ ...finding, evidence: normalizedEvidence as Finding["evidence"] }); + } + + return { findings: accepted, dropped, diagnostics: boundedDiagnostics(diagnostics) }; +} + +export function repositoryRevision(repoRoot: string): string | undefined { + try { + const revision = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: repoRoot, + encoding: "utf8", + timeout: 5000, + windowsHide: true, + stdio: ["ignore", "pipe", "ignore"], + }).trim().toLowerCase(); + return /^[0-9a-f]{40}$/.test(revision) ? revision : undefined; + } catch { + return undefined; + } +} + +export function portableSourcePath(inputFile: string, repoRoot: string, digest: string): { path: string; kind: "repo_relative" | "external_redacted" } { + const resolvedRepo = realpathSync(repoRoot); + const requestedInput = path.resolve(inputFile); + const resolvedInput = realpathSync(inputFile); + if (inside(resolvedRepo, resolvedInput)) { + return { path: path.relative(resolvedRepo, resolvedInput).replace(/\\/g, "/"), kind: "repo_relative" }; + } + const base = path.basename(requestedInput).replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120) || "report.json"; + return { path: `external/${digest.replace(/^sha256:/, "").slice(0, 16)}-${base}`, kind: "external_redacted" }; +} + +export function sha256Bytes(value: Uint8Array): `sha256:${string}` { + return `sha256:${createHash("sha256").update(value).digest("hex")}`; +} + +export function sha256Text(value: string): `sha256:${string}` { + return sha256Bytes(Buffer.from(value, "utf8")); +} + +export function atomicWriteArtifacts(files: Array<{ filePath: string; content: string }>): void { + const nonce = `${process.pid}-${Date.now()}-${randomUUID()}`; + const staged = files.map((file, index) => `${file.filePath}.tmp-${nonce}-${index}`); + const backups: Array<{ destination: string; backup: string }> = []; + const committed: string[] = []; + + try { + for (let index = 0; index < files.length; index += 1) { + writeFileSync(staged[index], files[index].content, { encoding: "utf8", flag: "wx" }); + } + + for (let index = 0; index < files.length; index += 1) { + const destination = files[index].filePath; + if (existsSync(destination)) { + const backup = `${destination}.bak-${nonce}-${index}`; + renameSync(destination, backup); + backups.push({ destination, backup }); + } + } + + // The caller orders the manifest last so it acts as the pair commit marker. + for (let index = 0; index < files.length; index += 1) { + renameSync(staged[index], files[index].filePath); + committed.push(files[index].filePath); + } + } catch (error) { + for (const destination of [...committed].reverse()) { + if (existsSync(destination)) { + try { unlinkSync(destination); } catch { /* preserve the original error */ } + } + } + for (const { destination, backup } of [...backups].reverse()) { + if (existsSync(backup) && !existsSync(destination)) { + try { renameSync(backup, destination); } catch { /* leave the backup for recovery */ } + } + } + throw error; + } finally { + for (const tempPath of staged) { + if (existsSync(tempPath)) { + try { unlinkSync(tempPath); } catch { /* best effort cleanup */ } + } + } + } + + for (const { backup } of backups) { + if (existsSync(backup)) { + try { unlinkSync(backup); } catch { /* committed outputs remain authoritative */ } + } + } +} diff --git a/src/cli/import.ts b/src/cli/import.ts index 5f3cfb2..8d3124c 100644 --- a/src/cli/import.ts +++ b/src/cli/import.ts @@ -1,28 +1,44 @@ /** * Import command - External tool result import * - * Imports findings from external tools like ESLint, Semgrep, TSC, Coverage - * and normalizes them to code-to-gate findings format. + * Validates provenance and normalizes external reports into findings@v1 plus + * an import-manifest@v1 commit marker. */ -import { existsSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, statSync } from "node:fs"; import path from "node:path"; import { ensureDir } from "../core/file-utils.js"; -import { EXIT, getOption, VERSION } from "./exit-codes.js"; +import { EXIT, getOption } from "./exit-codes.js"; import { - FindingsArtifact, - Finding, - UpstreamTool, CTG_VERSION, + type FindingsArtifact, + type Finding, + type ImportDiagnostic, + type ImportManifestArtifact, } from "../types/artifacts.js"; import { + importCoverage, importESLint, - importSemgrep, + importNpmAudit, importSARIF, - importTSC, - importCoverage, + importSemgrep, importTest, + importTSC, } from "./import-parsers.js"; +import { + atomicWriteArtifacts, + DEFAULT_MAX_INPUT_BYTES, + inspectImportInput, + MAX_IMPORT_DIAGNOSTICS, + MAX_IMPORT_INPUT_BYTES, + normalizeImportFindings, + portableSourcePath, + readImportInput, + repositoryRevision, + sha256Text, + type ImportTool, +} from "./import-provenance.js"; +import { validateArtifactObject } from "./schema-validate.js"; interface ImportOptions { VERSION: string; @@ -30,114 +46,248 @@ interface ImportOptions { getOption: typeof getOption; } +const SUPPORTED_TOOLS: ImportTool[] = [ + "eslint", + "semgrep", + "sarif", + "codeql", + "npm-audit", + "tsc", + "coverage", + "test", +]; + +function parseMaxInputBytes(raw: string | undefined): number | undefined { + if (raw === undefined) return DEFAULT_MAX_INPUT_BYTES; + const megabytes = Number(raw); + if (!Number.isFinite(megabytes) || megabytes <= 0) return undefined; + const bytes = Math.floor(megabytes * 1024 * 1024); + return bytes <= MAX_IMPORT_INPUT_BYTES ? bytes : undefined; +} + +function reportFormat(tool: ImportTool): string { + if (tool === "sarif" || tool === "codeql") return "sarif"; + if (tool === "npm-audit") return "npm-audit-json"; + return tool + "-json"; +} + +function parserFindings(tool: ImportTool, inputFile: string, data: unknown, repoRoot: string): Finding[] { + switch (tool) { + case "eslint": + return importESLint(inputFile, data); + case "semgrep": + return importSemgrep(inputFile, data); + case "sarif": + return importSARIF(inputFile, "sarif", data); + case "codeql": + return importSARIF(inputFile, "codeql", data); + case "npm-audit": { + const evidencePath = existsSync(path.join(repoRoot, "package-lock.json")) + ? "package-lock.json" + : "package.json"; + return importNpmAudit(inputFile, evidencePath, data); + } + case "tsc": + return importTSC(inputFile, data); + case "coverage": + return importCoverage(inputFile, data); + case "test": + return importTest(inputFile, data); + } +} + +function validationMessage(name: string, errors: string[] | undefined): string { + return "schema validation failed for " + name + ": " + (errors ?? ["unknown validation error"]).join("; "); +} + export async function importCommand(args: string[], options: ImportOptions): Promise<number> { const toolArg = args[0]; const inputArg = args[1]; const outDir = options.getOption(args, "--out") ?? ".qh"; - - const supportedTools: UpstreamTool[] = ["eslint", "semgrep", "sarif", "codeql", "tsc", "coverage", "test"]; + const repoArg = options.getOption(args, "--repo-root") ?? "."; + const producerVersionOverride = options.getOption(args, "--producer-version"); + const maxInputBytes = parseMaxInputBytes(options.getOption(args, "--max-input-mb")); if (!toolArg || !inputArg) { - console.error("usage: code-to-gate import <tool> <input-file> --out <dir>"); - console.error(`supported tools: ${supportedTools.join(", ")}`); + console.error("usage: code-to-gate import <tool> <input-file> [--out <dir>] [--repo-root <dir>] [--max-input-mb <number>] [--producer-version <version>]"); + console.error("supported tools: " + SUPPORTED_TOOLS.join(", ")); + return options.EXIT.USAGE_ERROR; + } + + if (!SUPPORTED_TOOLS.includes(toolArg as ImportTool)) { + console.error("unsupported tool: " + toolArg); + console.error("supported tools: " + SUPPORTED_TOOLS.join(", ")); + return options.EXIT.USAGE_ERROR; + } + + if (maxInputBytes === undefined) { + console.error("--max-input-mb must be greater than 0 and no more than 1024"); return options.EXIT.USAGE_ERROR; } - if (!supportedTools.includes(toolArg as UpstreamTool)) { - console.error(`unsupported tool: ${toolArg}`); - console.error(`supported tools: ${supportedTools.join(", ")}`); + if (producerVersionOverride !== undefined && (producerVersionOverride.length === 0 || producerVersionOverride.length > 128)) { + console.error("--producer-version must contain 1 to 128 characters"); return options.EXIT.USAGE_ERROR; } + const tool = toolArg as ImportTool; const cwd = process.cwd(); + const repoRoot = path.resolve(cwd, repoArg); const inputFile = path.resolve(cwd, inputArg); + if (!existsSync(repoRoot) || !statSync(repoRoot).isDirectory()) { + console.error("repository root is not a directory: " + repoArg); + return options.EXIT.USAGE_ERROR; + } + if (!existsSync(inputFile)) { - console.error(`input file not found: ${inputArg}`); + console.error("input file not found: " + inputArg); return options.EXIT.USAGE_ERROR; } if (!statSync(inputFile).isFile()) { - console.error(`input is not a file: ${inputArg}`); + console.error("input is not a file: " + inputArg); return options.EXIT.USAGE_ERROR; } const absoluteOutDir = path.resolve(cwd, outDir); const importsDir = path.join(absoluteOutDir, "imports"); + const findingsPath = path.join(importsDir, tool + "-findings.json"); + const manifestPath = path.join(importsDir, tool + "-import-manifest.json"); try { - let findings: Finding[]; - - switch (toolArg) { - case "eslint": - findings = importESLint(inputFile); - break; - case "semgrep": - findings = importSemgrep(inputFile); - break; - case "sarif": - findings = importSARIF(inputFile, "sarif"); - break; - case "codeql": - findings = importSARIF(inputFile, "codeql"); - break; - case "tsc": - findings = importTSC(inputFile); - break; - case "coverage": - findings = importCoverage(inputFile); - break; - case "test": - findings = importTest(inputFile); - break; - default: - console.error(`unsupported tool: ${toolArg}`); - return options.EXIT.USAGE_ERROR; - } + const input = readImportInput(inputFile, maxInputBytes); + const inspection = inspectImportInput(tool, input.data); + const parsed = parserFindings(tool, inputFile, input.data, repoRoot); + const normalized = normalizeImportFindings(parsed, repoRoot); + const revision = repositoryRevision(repoRoot); - ensureDir(importsDir); + const diagnostics: ImportDiagnostic[] = [ + ...inspection.diagnostics, + ...normalized.diagnostics, + ]; + if (!revision) { + diagnostics.push({ + code: "REPOSITORY_REVISION_UNAVAILABLE", + message: "repository HEAD is not an exact 40 character lowercase commit SHA", + }); + } + const dropped = inspection.dropped + normalized.dropped; + const completeness = dropped > 0 || inspection.errors > 0 || !revision + ? "partial" + : "complete"; const now = new Date().toISOString(); - const runId = `import-${toolArg}-${now.replace(/[-:.TZ]/g, "").slice(0, 14)}`; + const runId = "import-" + tool + "-" + now.replace(/[-:.TZ]/g, "").slice(0, 17); + const repo = { + root: ".", + ...(revision ? { revision } : {}), + }; + const toolRef = { + name: "code-to-gate" as const, + version: options.VERSION, + plugin_versions: [], + }; - const artifact: FindingsArtifact = { + const findingsArtifact: FindingsArtifact = { version: CTG_VERSION, generated_at: now, run_id: runId, - repo: { root: "." }, - tool: { - name: "code-to-gate", - version: VERSION, - plugin_versions: [], - }, + repo, + tool: toolRef, artifact: "findings", schema: "findings@v1", - completeness: findings.length > 0 ? "complete" : "partial", - findings, - unsupported_claims: [], + completeness, + findings: normalized.findings, + unsupported_claims: completeness === "partial" + ? [{ + id: "import-partial", + claim: "The external report was imported without loss and is bound to an exact repository revision.", + reason: "missing_evidence", + sourceSection: "import-manifest", + }] + : [], }; + const findingsText = JSON.stringify(findingsArtifact, null, 2) + "\n"; + const sourcePath = portableSourcePath(inputFile, repoRoot, input.sha256); - const outputPath = path.join(importsDir, `${toolArg}-findings.json`); - writeFileSync(outputPath, JSON.stringify(artifact, null, 2) + "\n", "utf8"); - - console.log( - JSON.stringify({ - tool: "code-to-gate", - command: "import", - source: toolArg, - input: inputArg, - output: path.relative(cwd, outputPath), - summary: { - findings: findings.length, - critical: findings.filter((f) => f.severity === "critical").length, - high: findings.filter((f) => f.severity === "high").length, - medium: findings.filter((f) => f.severity === "medium").length, - low: findings.filter((f) => f.severity === "low").length, + const manifestArtifact: ImportManifestArtifact = { + version: CTG_VERSION, + generated_at: now, + run_id: runId, + repo, + tool: toolRef, + artifact: "import-manifest", + schema: "import-manifest@v1", + completeness, + source: { + tool, + format: reportFormat(tool), + path: sourcePath.path, + path_kind: sourcePath.kind, + sha256: input.sha256, + size_bytes: input.sizeBytes, + producer: { + name: inspection.producerName, + version: producerVersionOverride ?? inspection.producerVersion, }, - }) - ); + ...(inspection.formatVersion ? { format_version: inspection.formatVersion } : {}), + ...(revision ? { repository_revision: revision } : {}), + }, + normalized: { + path: path.relative(absoluteOutDir, findingsPath).replace(/\\/g, "/"), + sha256: sha256Text(findingsText), + size_bytes: Buffer.byteLength(findingsText, "utf8"), + schema: "findings@v1", + }, + summary: { + seen: inspection.seen, + accepted: normalized.findings.length, + dropped, + errors: inspection.errors, + }, + diagnostics: diagnostics.slice(0, MAX_IMPORT_DIAGNOSTICS), + generated_by: "ctg-import/v1", + }; + + const findingsValidation = await validateArtifactObject(findingsArtifact, path.basename(findingsPath)); + if (findingsValidation.status !== "ok") { + console.error(validationMessage(findingsValidation.artifact, findingsValidation.errors)); + return options.EXIT.SCHEMA_FAILED; + } + + const manifestValidation = await validateArtifactObject(manifestArtifact, path.basename(manifestPath)); + if (manifestValidation.status !== "ok") { + console.error(validationMessage(manifestValidation.artifact, manifestValidation.errors)); + return options.EXIT.SCHEMA_FAILED; + } + + ensureDir(importsDir); + atomicWriteArtifacts([ + { filePath: findingsPath, content: findingsText }, + { filePath: manifestPath, content: JSON.stringify(manifestArtifact, null, 2) + "\n" }, + ]); + + console.log(JSON.stringify({ + tool: "code-to-gate", + command: "import", + source: tool, + input: sourcePath.path, + output: path.relative(cwd, findingsPath), + manifest: path.relative(cwd, manifestPath), + completeness, + summary: { + findings: normalized.findings.length, + dropped, + errors: inspection.errors, + critical: normalized.findings.filter((finding) => finding.severity === "critical").length, + high: normalized.findings.filter((finding) => finding.severity === "high").length, + medium: normalized.findings.filter((finding) => finding.severity === "medium").length, + low: normalized.findings.filter((finding) => finding.severity === "low").length, + }, + })); - return options.EXIT.OK; + return completeness === "partial" ? options.EXIT.PARTIAL_SUCCESS : options.EXIT.OK; } catch (error) { console.error(error instanceof Error ? error.message : String(error)); return options.EXIT.IMPORT_FAILED; diff --git a/src/cli/plugin-sandbox.ts b/src/cli/plugin-sandbox.ts index 6404547..55c89db 100644 --- a/src/cli/plugin-sandbox.ts +++ b/src/cli/plugin-sandbox.ts @@ -10,6 +10,8 @@ import { validateSandboxConfig, DEFAULT_SANDBOX_CONFIG, isDockerSandboxAvailable, + loadPluginExecutionPolicy, + verifyTrustedPlugin, } from "../plugin/index.js"; import type { PluginRegistryEntry, SandboxConfig } from "../plugin/index.js"; import * as path from "path"; @@ -92,7 +94,7 @@ Manage and execute plugins in isolated Docker containers. Usage: code-to-gate plugin-sandbox status [--docker-image <image>] - code-to-gate plugin-sandbox run <plugin-path> --input <file> --sandbox <docker|none> [--output <file>] + code-to-gate plugin-sandbox run <plugin-path> --input <file> [--sandbox <process|docker|none>] [--execution-policy <file>] [--unsafe-allow-none] [--output <file>] code-to-gate plugin-sandbox build-image [--docker-image <image>] Subcommands: @@ -104,7 +106,9 @@ Options: --docker-image Docker image to use for sandbox (default: code-to-gate-plugin-runner:latest) --input Input JSON file for plugin execution --output Output file for plugin result (default: stdout) - --sandbox Sandbox mode: none, docker (required) + --sandbox Sandbox mode: process (default), docker, or none + --execution-policy Trusted plugin policy required for Process mode + --unsafe-allow-none Explicitly allow none outside CI/release only --timeout Execution timeout in seconds (default: 60) --memory Memory limit in MB (default: 512) --cpu CPU limit as fraction (default: 0.5) @@ -182,7 +186,9 @@ async function sandboxRunCommand(args: string[], options: PluginOptions): Promis const pluginPath = args[0]; const inputFile = options.getOption(args, "--input"); const outputFile = options.getOption(args, "--output"); - const sandboxValue = options.getOption(args, "--sandbox"); + const sandboxValue = options.getOption(args, "--sandbox") ?? "process"; + const executionPolicyFile = options.getOption(args, "--execution-policy"); + const unsafeAllowNone = args.includes("--unsafe-allow-none"); const timeout = parseInt(options.getOption(args, "--timeout") ?? "60", 10); const memory = parseInt(options.getOption(args, "--memory") ?? "512", 10); const cpu = parseFloat(options.getOption(args, "--cpu") ?? "0.5"); @@ -199,15 +205,18 @@ async function sandboxRunCommand(args: string[], options: PluginOptions): Promis return options.EXIT.USAGE_ERROR; } - if (!sandboxValue) { - console.error("Error: Sandbox mode required (--sandbox docker|none)"); + if (sandboxValue !== "process" && sandboxValue !== "docker" && sandboxValue !== "none") { + console.error(`Error: Invalid sandbox mode: ${sandboxValue}. Expected process, docker, or none.`); return options.EXIT.USAGE_ERROR; } - if (sandboxValue !== "docker" && sandboxValue !== "none") { - console.error(`Error: Invalid sandbox mode: ${sandboxValue}. Expected docker or none.`); + const sandboxMode = sandboxValue; + const protectedEnvironment = ["CI", "GITHUB_ACTIONS", "CTG_RELEASE"].some( + (name) => /^(1|true|yes)$/i.test(process.env[name] ?? "") + ); + if (sandboxMode === "none" && (!unsafeAllowNone || protectedEnvironment)) { + console.error("Error: --sandbox none requires --unsafe-allow-none and is forbidden in CI/release"); return options.EXIT.USAGE_ERROR; } - const sandboxMode = sandboxValue; // Resolve paths const cwd = process.cwd(); @@ -254,7 +263,7 @@ async function sandboxRunCommand(args: string[], options: PluginOptions): Promis const dockerAvailable = await deps.isDockerSandboxAvailable(); if (!dockerAvailable) { console.error("Error: Docker is not available for sandbox mode"); - console.error(" - Install and start Docker, or use --sandbox none"); + console.error(" - Process fallback is intentionally disabled"); return options.EXIT.USAGE_ERROR; } } @@ -288,6 +297,29 @@ async function sandboxRunCommand(args: string[], options: PluginOptions): Promis } const manifest = loadResult.manifest!; + + if (sandboxMode === "process") { + if (!executionPolicyFile) { + console.error("Error: Process mode requires --execution-policy <file>; untrusted plugins require Docker"); + return options.EXIT.PLUGIN_FAILED; + } + try { + const policyPath = path.resolve(cwd, executionPolicyFile); + const policy = loadPluginExecutionPolicy(policyPath); + const verified = verifyTrustedPlugin(policy, absolutePluginPath, manifest); + sandboxConfig.timeout = Math.min(sandboxConfig.timeout, verified.process.timeout_seconds); + sandboxConfig.allowedEnvVars = verified.process.allowed_env_vars; + sandboxConfig.maxStdoutBytes = verified.process.max_stdout_bytes; + sandboxConfig.maxStderrBytes = verified.process.max_stderr_bytes; + sandboxConfig.maxFindings = verified.process.max_findings; + sandboxConfig.maxEvidencePerFinding = verified.process.max_evidence_per_finding; + sandboxConfig.nodePermissionModel = verified.process.node_permission_model; + } catch (error) { + console.error("Error: " + (error instanceof Error ? error.message : String(error))); + return options.EXIT.PLUGIN_FAILED; + } + } + const entry: PluginRegistryEntry = { manifest, path: absolutePluginPath, @@ -343,7 +375,7 @@ async function sandboxRunCommand(args: string[], options: PluginOptions): Promis for (const error of output.errors) { console.error(` - ${error.code}: ${error.message}`); } - return options.EXIT.OK; // Partial success still returns OK + return options.EXIT.PARTIAL_SUCCESS; } return options.EXIT.OK; diff --git a/src/cli/readiness.ts b/src/cli/readiness.ts index a8c06c9..e67e5d2 100644 --- a/src/cli/readiness.ts +++ b/src/cli/readiness.ts @@ -368,6 +368,8 @@ export async function readinessCommand(args: string[], options: ReadinessOptions const evalResult = evaluatePolicy(findingsForPolicy, policy, suppressions, { baselineNewOrWorsenedFindingIds: baselineResult?.summary.gatedFindingIds, manualEvidenceFindingIds, + completeness: findings.completeness, + incompleteReasons: findings.unsupported_claims.map((claim) => claim.id), }); const intakeAssessment = absoluteIntakePath ? assessIntakeArtifact(absoluteIntakePath) : undefined; const readinessStatus = mergeReadinessStatus(evalResult.status, intakeAssessment); diff --git a/src/cli/schema-validate.ts b/src/cli/schema-validate.ts index 8b0366a..0d2694a 100644 --- a/src/cli/schema-validate.ts +++ b/src/cli/schema-validate.ts @@ -34,42 +34,49 @@ export interface SchemaValidationResult { errors?: string[]; } -export async function validateArtifactFile(filePath: string): Promise<SchemaValidationResult> { - if (!existsSync(filePath)) { - return { artifact: path.basename(filePath), status: "error", errors: ["file not found"] }; +export async function validateArtifactObject( + data: unknown, + artifactName: string = "artifact" +): Promise<SchemaValidationResult> { + const schemaPath = schemaForArtifact(data); + if (!schemaPath) { + return { artifact: artifactName, status: "error", errors: ["no schema found"] }; } - let data: unknown; + const ajv = createAjv(); + await loadSchemas(ajv); + const schema = readJson(schemaPath) as { $id?: string }; try { - data = readJson(filePath); + const validate: ValidateFunction = ajv.getSchema(schema.$id || schemaPath) || ajv.compile(schema); + return validate(data) + ? { artifact: artifactName, status: "ok" } + : { artifact: artifactName, status: "error", errors: formatErrors(validate.errors) }; } catch (error) { return { - artifact: path.basename(filePath), + artifact: artifactName, status: "error", - errors: [`parse error: ${error instanceof Error ? error.message : String(error)}`], + errors: [`validation error: ${error instanceof Error ? error.message : String(error)}`], }; } +} - const schemaPath = schemaForArtifact(data); - if (!schemaPath) { - return { artifact: path.basename(filePath), status: "error", errors: ["no schema found"] }; +export async function validateArtifactFile(filePath: string): Promise<SchemaValidationResult> { + if (!existsSync(filePath)) { + return { artifact: path.basename(filePath), status: "error", errors: ["file not found"] }; } - const ajv = createAjv(); - await loadSchemas(ajv); - const schema = readJson(schemaPath) as { $id?: string }; + let data: unknown; try { - const validate: ValidateFunction = ajv.getSchema(schema.$id || schemaPath) || ajv.compile(schema); - return validate(data) - ? { artifact: path.basename(filePath), status: "ok" } - : { artifact: path.basename(filePath), status: "error", errors: formatErrors(validate.errors) }; + data = readJson(filePath); } catch (error) { return { artifact: path.basename(filePath), status: "error", - errors: [`validation error: ${error instanceof Error ? error.message : String(error)}`], + errors: [`parse error: ${error instanceof Error ? error.message : String(error)}`], }; } + + return validateArtifactObject(data, path.basename(filePath)); } const SCHEMA_DIR = path.resolve( @@ -175,6 +182,8 @@ async function loadSchemas(ajv: InstanceType<typeof Ajv>): Promise<void> { "normalized-repo-graph.schema.json", "raw-findings.schema.json", "findings.schema.json", + "import-manifest.schema.json", + "plugin-execution-policy.schema.json", "risk-register.schema.json", "invariants.schema.json", "test-seeds.schema.json", diff --git a/src/config/__tests__/policy-evaluator.test.ts b/src/config/__tests__/policy-evaluator.test.ts index 9f0cb3b..825a896 100644 --- a/src/config/__tests__/policy-evaluator.test.ts +++ b/src/config/__tests__/policy-evaluator.test.ts @@ -390,6 +390,33 @@ describe("policy-evaluator", () => { expect(result.failedConditions).toHaveLength(0); expect(result.passedFindings).toHaveLength(1); }); + it("blocks partial input when allowPartial is false", () => { + const policy = createDefaultPolicy(); + policy.partial = { allowPartial: false }; + + const result = evaluatePolicy([], policy, [], { + completeness: "partial", + incompleteReasons: ["IMPORT_PARTIAL:semgrep"], + }); + + expect(result.status).toBe("blocked_input"); + expect(result.failedConditions).toContainEqual(expect.objectContaining({ + type: "incomplete_input", + })); + }); + + it("never reports plain passed for partial input even when allowed", () => { + const policy = createDefaultPolicy(); + policy.partial = { allowPartial: true }; + + const result = evaluatePolicy([], policy, [], { + completeness: "partial", + incompleteReasons: ["LEGACY_IMPORT_MANIFEST_MISSING:eslint"], + }); + + expect(result.status).toBe("passed_with_risk"); + expect(result.failedConditions[0].message).toContain("LEGACY_IMPORT_MANIFEST_MISSING:eslint"); + }); }); describe("getExitCode", () => { diff --git a/src/config/policy-evaluator.ts b/src/config/policy-evaluator.ts index 64ea1e3..a2c16c3 100644 --- a/src/config/policy-evaluator.ts +++ b/src/config/policy-evaluator.ts @@ -3,7 +3,7 @@ * Based on docs/product-spec-v1.md section 5 */ -import type { Finding, Severity, FindingCategory, PolicyReadinessStatus } from "../types/artifacts.js"; +import type { Completeness, Finding, Severity, FindingCategory, PolicyReadinessStatus } from "../types/artifacts.js"; import type { CtgPolicy, SuppressionEntry } from "./policy-loader.js"; import { isSuppressed } from "./policy-loader.js"; @@ -23,6 +23,7 @@ export interface FailedCondition { | "count_threshold" | "low_confidence" | "suppressed_expired" + | "incomplete_input" | "dsl_block" | "dsl_hold"; severity?: Severity; @@ -38,6 +39,8 @@ export interface FailedCondition { export interface PolicyEvaluationContext { baselineNewOrWorsenedFindingIds?: string[]; manualEvidenceFindingIds?: string[]; + completeness?: Completeness; + incompleteReasons?: string[]; } /** @@ -194,6 +197,11 @@ function determineReadinessStatus( return "blocked_input"; } + const incompleteInput = failedConditions.some((condition) => condition.type === "incomplete_input"); + if (incompleteInput && !partialAllowed) { + return "blocked_input"; + } + // If count threshold exceeded, status is blocked_input const countThresholdFailed = failedConditions.some(c => c.type === "count_threshold"); if (countThresholdFailed) { @@ -219,6 +227,10 @@ function determineReadinessStatus( return "passed_with_risk"; } + if (incompleteInput) { + return "passed_with_risk"; + } + // All clear return "passed"; } @@ -425,6 +437,15 @@ export function evaluatePolicy( const countConditions = checkCountThreshold(nonSuppressedFindings, policy.blocking.countThreshold); failedConditions.push(...countConditions); + if (context.completeness === "partial") { + failedConditions.push({ + type: "incomplete_input", + message: context.incompleteReasons?.length + ? "Input evidence is partial: " + context.incompleteReasons.join(", ") + : "Input evidence is partial", + }); + } + // Determine final status const partialAllowed = policy.partial?.allowPartial ?? false; const status = determineReadinessStatus( diff --git a/src/core/__tests__/file-utils.test.ts b/src/core/__tests__/file-utils.test.ts index 0ab1146..17857b9 100644 --- a/src/core/__tests__/file-utils.test.ts +++ b/src/core/__tests__/file-utils.test.ts @@ -7,6 +7,7 @@ import { detectLanguage, detectRole, walkDir, + walkDirBounded, isTargetFile, isEntrypoint, entrypointKind, @@ -837,6 +838,68 @@ describe("file-utils", () => { }); }); + describe("walkDirBounded", () => { + it("stops at the file-count limit and reports a partial scan", () => { + const root = path.join(tempTestDir, "bounded-file-count"); + mkdirSync(root, { recursive: true }); + writeFileSync(path.join(root, "a.ts"), "a", "utf8"); + writeFileSync(path.join(root, "b.ts"), "b", "utf8"); + + const result = walkDirBounded(root, { maxFiles: 1 }); + + expect(result.files).toHaveLength(1); + expect(result.partial).toBe(true); + expect(result.reasons).toContain("MAX_FILES_EXCEEDED"); + }); + + it("skips oversized files and reports the affected path", () => { + const root = path.join(tempTestDir, "bounded-file-size"); + mkdirSync(root, { recursive: true }); + writeFileSync(path.join(root, "large.ts"), "123456", "utf8"); + + const result = walkDirBounded(root, { maxFileSizeBytes: 5 }); + + expect(result.files).toEqual([]); + expect(result.partial).toBe(true); + expect(result.reasons).toContain("MAX_FILE_SIZE_EXCEEDED:large.ts"); + }); + + it("stops before the total-byte limit is exceeded", () => { + const root = path.join(tempTestDir, "bounded-total-size"); + mkdirSync(root, { recursive: true }); + writeFileSync(path.join(root, "a.ts"), "1234", "utf8"); + writeFileSync(path.join(root, "b.ts"), "5678", "utf8"); + + const result = walkDirBounded(root, { maxTotalBytes: 5 }); + + expect(result.files.map((file) => path.basename(file))).toEqual(["a.ts"]); + expect(result.acceptedBytes).toBe(4); + expect(result.reasons).toContain("MAX_TOTAL_BYTES_EXCEEDED"); + }); + + it("does not descend beyond the configured depth", () => { + const root = path.join(tempTestDir, "bounded-depth"); + mkdirSync(path.join(root, "nested"), { recursive: true }); + writeFileSync(path.join(root, "nested", "deep.ts"), "deep", "utf8"); + + const result = walkDirBounded(root, { maxDepth: 0 }); + + expect(result.files).toEqual([]); + expect(result.reasons).toContain("MAX_DEPTH_EXCEEDED:nested"); + }); + + it("honors an already-expired deadline", () => { + const root = path.join(tempTestDir, "bounded-deadline"); + mkdirSync(root, { recursive: true }); + writeFileSync(path.join(root, "index.ts"), "source", "utf8"); + + const result = walkDirBounded(root, { deadlineMs: 0 }); + + expect(result.files).toEqual([]); + expect(result.reasons).toContain("SCAN_DEADLINE_EXCEEDED"); + }); + }); + describe("DEFAULT_IGNORED_DIRS", () => { it("contains .git", () => { expect(DEFAULT_IGNORED_DIRS.has(".git")).toBe(true); diff --git a/src/core/__tests__/repo-graph-builder.test.ts b/src/core/__tests__/repo-graph-builder.test.ts index 5a5deae..64d3ebd 100644 --- a/src/core/__tests__/repo-graph-builder.test.ts +++ b/src/core/__tests__/repo-graph-builder.test.ts @@ -65,6 +65,32 @@ describe("repo-graph-builder", () => { expect(graph.files.map((file) => file.path)).toEqual(["index.ts"]); }); + it("marks the graph partial and records scan-limit evidence", () => { + tempRoot = mkdtempSync(path.join(tmpdir(), "ctg-repo-graph-limited-")); + writeFileSync(path.join(tempRoot, "a.ts"), "export const a = 1;\n", "utf8"); + writeFileSync(path.join(tempRoot, "b.ts"), "export const b = 2;\n", "utf8"); + + const graph = buildGraph(tempRoot, "1.5.0", { + scanLimits: { maxFiles: 1 }, + }); + + expect(graph.stats.partial).toBe(true); + expect(graph.stats.scan).toMatchObject({ + visitedFiles: 1, + acceptedFiles: 1, + skippedFiles: 1, + }); + expect(graph.stats.scan?.reasons).toContain("MAX_FILES_EXCEEDED"); + expect(graph.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "PARTIAL_GRAPH", + message: expect.stringContaining("MAX_FILES_EXCEEDED"), + }), + ]) + ); + }); + it("records monorepo workspace modules and assigns files to the nearest package boundary", () => { const fixtureRoot = path.resolve(import.meta.dirname, "../../../fixtures/demo-monorepo"); const graph = buildGraph(fixtureRoot, "1.5.0"); diff --git a/src/core/file-utils.ts b/src/core/file-utils.ts index c82a32a..7a349e5 100644 --- a/src/core/file-utils.ts +++ b/src/core/file-utils.ts @@ -257,6 +257,154 @@ export function walkDir(dir: string, ignoredDirs?: Set<string>): string[] { } } +export interface DirectoryWalkLimits { + maxFiles: number; + maxDepth: number; + maxFileSizeBytes: number; + maxTotalBytes: number; + deadlineMs: number; +} + +export interface BoundedDirectoryWalk { + files: string[]; + partial: boolean; + reasons: string[]; + visitedFiles: number; + acceptedBytes: number; + skippedFiles: number; +} + +export const DEFAULT_DIRECTORY_WALK_LIMITS: DirectoryWalkLimits = { + maxFiles: 100_000, + maxDepth: 64, + maxFileSizeBytes: 10 * 1024 * 1024, + maxTotalBytes: 2 * 1024 * 1024 * 1024, + deadlineMs: 15 * 60 * 1000, +}; + +export function resolveDirectoryWalkLimits( + limitOverrides: Partial<DirectoryWalkLimits> = {} +): DirectoryWalkLimits { + const configured = { ...DEFAULT_DIRECTORY_WALK_LIMITS, ...limitOverrides }; + const failClosedInteger = (value: number): number => + Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0; + + return { + maxFiles: failClosedInteger(configured.maxFiles), + maxDepth: failClosedInteger(configured.maxDepth), + maxFileSizeBytes: failClosedInteger(configured.maxFileSizeBytes), + maxTotalBytes: failClosedInteger(configured.maxTotalBytes), + deadlineMs: failClosedInteger(configured.deadlineMs), + }; +} + +export function walkDirBounded( + dir: string, + limitOverrides: Partial<DirectoryWalkLimits> = {}, + ignoredDirs?: Set<string> +): BoundedDirectoryWalk { + const ignored = ignoredDirs ?? DEFAULT_IGNORED_DIRS; + const limits = resolveDirectoryWalkLimits(limitOverrides); + const files: string[] = []; + const reasons: string[] = []; + const startedAt = Date.now(); + let visitedFiles = 0; + let acceptedBytes = 0; + let skippedFiles = 0; + let stopped = false; + + const addReason = (reason: string) => { + if (reasons.length < 100 && !reasons.includes(reason)) reasons.push(reason); + }; + + const visit = (current: string, depth: number): void => { + if (stopped) return; + if (Date.now() - startedAt >= limits.deadlineMs) { + addReason("SCAN_DEADLINE_EXCEEDED"); + stopped = true; + return; + } + + let entries; + try { + entries = readdirSync(current, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)); + } catch { + addReason("DIRECTORY_READ_FAILED:" + toPosix(path.relative(dir, current) || ".")); + return; + } + + for (const entry of entries) { + if (stopped) return; + if (Date.now() - startedAt >= limits.deadlineMs) { + addReason("SCAN_DEADLINE_EXCEEDED"); + stopped = true; + return; + } + if (shouldIgnoreByName(entry.name, ignored)) continue; + const fullPath = path.join(current, entry.name); + const relative = toPosix(path.relative(dir, fullPath)); + + if (entry.isDirectory()) { + if (depth >= limits.maxDepth) { + skippedFiles += 1; + addReason("MAX_DEPTH_EXCEEDED:" + relative); + } else { + visit(fullPath, depth + 1); + } + continue; + } + if (!entry.isFile()) continue; + + if (visitedFiles >= limits.maxFiles) { + skippedFiles += 1; + addReason("MAX_FILES_EXCEEDED"); + stopped = true; + return; + } + visitedFiles += 1; + + let size: number; + try { + size = statSync(fullPath).size; + } catch { + skippedFiles += 1; + addReason("FILE_STAT_FAILED:" + relative); + continue; + } + if (size > limits.maxFileSizeBytes) { + skippedFiles += 1; + addReason("MAX_FILE_SIZE_EXCEEDED:" + relative); + continue; + } + if (acceptedBytes + size > limits.maxTotalBytes) { + skippedFiles += 1; + addReason("MAX_TOTAL_BYTES_EXCEEDED"); + stopped = true; + return; + } + + acceptedBytes += size; + files.push(fullPath); + } + }; + + if (!existsSync(dir)) { + addReason("ROOT_NOT_FOUND"); + } else { + visit(dir, 0); + } + + return { + files, + partial: reasons.length > 0, + reasons, + visitedFiles, + acceptedBytes, + skippedFiles, + }; +} + /** * Check if a file is a target file type for analysis * @param filePath - Path to the file diff --git a/src/core/repo-graph-builder.ts b/src/core/repo-graph-builder.ts index 5c6da34..7d1e179 100644 --- a/src/core/repo-graph-builder.ts +++ b/src/core/repo-graph-builder.ts @@ -17,7 +17,19 @@ import path from "node:path"; import type { NormalizedRepoGraph, RepoFile, RepoModule, RepoRef } from "../types/artifacts.js"; import { CTG_VERSION } from "../types/artifacts.js"; import type { ParserRegistry, ParserAdapterResult } from "../types/contracts.js"; -import { detectLanguage, detectRole, entrypointKind, isEntrypoint, isGeneratedVendoredOrMinifiedPath, walkDir } from "./file-utils.js"; +import { + DEFAULT_DIRECTORY_WALK_LIMITS, + detectLanguage, + detectRole, + entrypointKind, + isEntrypoint, + isGeneratedVendoredOrMinifiedPath, + resolveDirectoryWalkLimits, + walkDir, + walkDirBounded, + type BoundedDirectoryWalk, + type DirectoryWalkLimits, +} from "./file-utils.js"; import { sha256, toPosix } from "./path-utils.js"; /** @@ -28,6 +40,8 @@ export interface BuildGraphOptions { parserRegistry?: ParserRegistry; /** Explicitly request tree-sitter parsing (deprecated - registry handles this) */ useTreeSitter?: boolean; + /** Fail-closed repository discovery and processing limits */ + scanLimits?: Partial<DirectoryWalkLimits>; } // Parse cache for incremental processing @@ -89,9 +103,9 @@ function detectPackageManager(repoRoot: string): RepoModule["packageManager"] { return "unknown"; } -function collectWorkspaceModules(repoRoot: string): RepoModule[] { +function collectWorkspaceModules(repoRoot: string, discoveredFiles?: string[]): RepoModule[] { const packageManager = detectPackageManager(repoRoot); - return walkDir(repoRoot) + return (discoveredFiles ?? walkDir(repoRoot)) .filter((file) => path.basename(file) === "package.json") .flatMap((packageFile): RepoModule[] => { try { @@ -159,8 +173,19 @@ export function isGraphTargetFile(filePath: string): boolean { return /\.(ts|tsx|js|jsx|py|rb|go|rs|java|php|cs|cpp|cc|cxx|hpp|hxx|mjs|cjs|json|yaml|yml|md|txt)$/.test(filePath) && !isGeneratedVendoredOrMinifiedPath(filePath); } +export function discoverGraphFilesBounded( + repoRoot: string, + limits: Partial<DirectoryWalkLimits> = {} +): BoundedDirectoryWalk { + const discovery = walkDirBounded(repoRoot, limits); + return { + ...discovery, + files: discovery.files.filter(isGraphTargetFile), + }; +} + export function discoverGraphFiles(repoRoot: string): string[] { - return walkDir(repoRoot).filter(isGraphTargetFile); + return discoverGraphFilesBounded(repoRoot).files; } export function addPartialGraphDiagnostic(graph: NormalizedRepoGraph): void { @@ -172,7 +197,9 @@ export function addPartialGraphDiagnostic(graph: NormalizedRepoGraph): void { id: `diag:${graph.run_id}:partial-graph`, severity: "warning", code: "PARTIAL_GRAPH", - message: "Some files failed to parse, resulting in a partial graph", + message: graph.stats.scan?.reasons.length + ? "Repository scan is partial: " + graph.stats.scan.reasons.join(", ") + : "Some files failed to parse, resulting in a partial graph", }); } @@ -296,15 +323,34 @@ export function buildGraph( ? { useTreeSitter: options } : options ?? {}; - const targetFiles = discoverGraphFiles(repoRoot); - const shouldUseGraphCache = process.env.NODE_ENV === "test" || process.env.VITEST === "true"; + const scanStartedAt = Date.now(); + const scanLimits: DirectoryWalkLimits = resolveDirectoryWalkLimits({ + ...DEFAULT_DIRECTORY_WALK_LIMITS, + ...normalizedOptions.scanLimits, + }); + const discovery = discoverGraphFilesBounded(repoRoot, scanLimits); + const targetFiles = discovery.files; + const scanReasons = [...discovery.reasons]; + const addScanReason = (reason: string) => { + if (scanReasons.length < 100 && !scanReasons.includes(reason)) { + scanReasons.push(reason); + } + }; + + const shouldUseGraphCache = + !discovery.partial && + (process.env.NODE_ENV === "test" || process.env.VITEST === "true"); const graphCacheKey = shouldUseGraphCache ? `${repoRoot}:${targetFiles .map((file) => { - const stat = statSync(file); - return `${file}:${stat.size}:${stat.mtimeMs}`; + try { + const stat = statSync(file); + return `${file}:${stat.size}:${stat.mtimeMs}`; + } catch { + return `${file}:missing`; + } }) - .join("|")}:${normalizedOptions.useTreeSitter}` + .join("|")}:${normalizedOptions.useTreeSitter}:${JSON.stringify(scanLimits)}` : undefined; if (graphCacheKey) { @@ -315,11 +361,48 @@ export function buildGraph( } const graph = createEmptyRepoGraph(repoRoot, toolVersion); - graph.modules = collectWorkspaceModules(repoRoot); + graph.stats.partial = discovery.partial; + graph.stats.scan = { + visitedFiles: discovery.visitedFiles, + acceptedFiles: 0, + acceptedBytes: discovery.acceptedBytes, + skippedFiles: discovery.skippedFiles, + limits: scanLimits, + reasons: scanReasons, + }; + graph.modules = collectWorkspaceModules(repoRoot, targetFiles); for (const file of targetFiles) { + if (Date.now() - scanStartedAt >= scanLimits.deadlineMs) { + addScanReason("SCAN_DEADLINE_EXCEEDED"); + graph.stats.partial = true; + break; + } + const rel = toPosix(path.relative(repoRoot, file)); - const body = readFileSync(file, "utf8"); + let body: string; + try { + const stat = statSync(file); + if (stat.size > scanLimits.maxFileSizeBytes) { + graph.stats.scan.skippedFiles += 1; + addScanReason(`MAX_FILE_SIZE_EXCEEDED:${rel}`); + graph.stats.partial = true; + continue; + } + body = readFileSync(file, "utf8"); + if (Buffer.byteLength(body) > scanLimits.maxFileSizeBytes) { + graph.stats.scan.skippedFiles += 1; + addScanReason(`MAX_FILE_SIZE_EXCEEDED:${rel}`); + graph.stats.partial = true; + continue; + } + } catch { + graph.stats.scan.skippedFiles += 1; + addScanReason(`FILE_READ_FAILED:${rel}`); + graph.stats.partial = true; + continue; + } + const bodyHash = sha256(body); const language = detectLanguage(file); const role = detectRole(rel); @@ -372,9 +455,12 @@ export function buildGraph( addGraphClassifications(graph, repoFile, body); } + graph.stats.scan.acceptedFiles = graph.files.length; + graph.stats.scan.reasons = scanReasons; + graph.stats.partial ||= scanReasons.length > 0; addPartialGraphDiagnostic(graph); - if (graphCacheKey) { + if (graphCacheKey && !graph.stats.partial) { graphCache.set(graphCacheKey, structuredClone(graph)); } diff --git a/src/plugin/__tests__/docker-sandbox.test.ts b/src/plugin/__tests__/docker-sandbox.test.ts index 22ffaa2..67efe81 100644 --- a/src/plugin/__tests__/docker-sandbox.test.ts +++ b/src/plugin/__tests__/docker-sandbox.test.ts @@ -61,8 +61,8 @@ async function createTestInputFile(data: object): Promise<string> { describe("SandboxConfig", () => { describe("parseSandboxMode", () => { - it("should return 'none' for undefined value", () => { - expect(parseSandboxMode(undefined)).toBe("none"); + it("should return 'process' for undefined value", () => { + expect(parseSandboxMode(undefined)).toBe("process"); }); it("should return 'none' for 'none' value", () => { @@ -81,12 +81,12 @@ describe("SandboxConfig", () => { expect(parseSandboxMode("process")).toBe("process"); }); - it("should return 'none' for invalid value", () => { - expect(parseSandboxMode("invalid")).toBe("none"); + it("should fail toward 'process' for invalid value", () => { + expect(parseSandboxMode("invalid")).toBe("process"); }); - it("should return 'none' for empty string", () => { - expect(parseSandboxMode("")).toBe("none"); + it("should fail toward 'process' for empty string", () => { + expect(parseSandboxMode("")).toBe("process"); }); }); @@ -149,7 +149,7 @@ describe("SandboxConfig", () => { it("should accept valid custom config", () => { const config: SandboxConfig = { mode: "docker", - timeout: 120, + timeout: 60, memoryLimit: 1024, cpuLimit: 1.0, dockerImage: "my-image:latest", @@ -430,9 +430,9 @@ describe("DockerSandboxRunner", () => { await expect(runner.initialize({})).rejects.toThrow("Invalid sandbox config"); }); - it("should accept custom timeout", async () => { + it("should accept the maximum custom timeout", async () => { const runner = createDockerSandboxRunner(); - await runner.initialize({ timeout: 120 }); + await runner.initialize({ timeout: 60 }); }); }); @@ -515,22 +515,27 @@ describe("DockerSandboxRunner", () => { ); expect(command).toContain("--network=none"); + expect(command).toContain("--read-only"); + expect(command).toContain("--tmpfs"); + expect(command).toContain("/tmp:rw,noexec,nosuid,size=16m"); }); - it("should omit --network=none only when network access is explicitly enabled", () => { + it("keeps --network=none even when an unsafe config reaches the command builder", () => { const manifest = createDefaultManifest("test-plugin"); manifest.entry.command = ["node", "index.js"]; + const unsafeConfig = { ...DEFAULT_SANDBOX_CONFIG, mode: "docker" as const, networkAccess: true }; const command = buildDockerRunCommand( manifest, - { ...DEFAULT_SANDBOX_CONFIG, networkAccess: true }, + unsafeConfig, "ctg-plugin-test", [], "/tmp/input.json", "/tmp/output.json" ); - expect(command).not.toContain("--network=none"); + expect(command).toContain("--network=none"); + expect(validateSandboxConfig(unsafeConfig).valid).toBe(false); }); }); diff --git a/src/plugin/__tests__/plugin-execution-policy.test.ts b/src/plugin/__tests__/plugin-execution-policy.test.ts new file mode 100644 index 0000000..606ee22 --- /dev/null +++ b/src/plugin/__tests__/plugin-execution-policy.test.ts @@ -0,0 +1,237 @@ +import { createHash } from "node:crypto"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createDefaultManifest } from "../plugin-schema.js"; +import { + loadPluginExecutionPolicy, + locatePluginManifest, + resolvePluginEntrypoint, + validatePluginExecutionPolicy, + verifyTrustedPlugin, + type PluginExecutionPolicy, +} from "../plugin-execution-policy.js"; + +function digestFile(filePath: string): `sha256:${string}` { + return `sha256:${createHash("sha256").update(readFileSync(filePath)).digest("hex")}`; +} + +describe("plugin execution policy", () => { + let root: string; + let pluginRoot: string; + let manifestPath: string; + let entrypointPath: string; + + beforeEach(() => { + root = mkdtempSync(path.join(tmpdir(), "ctg-plugin-policy-")); + pluginRoot = path.join(root, "plugin"); + mkdirSync(pluginRoot, { recursive: true }); + entrypointPath = path.join(pluginRoot, "index.js"); + writeFileSync(entrypointPath, "process.stdout.write('{}');\n", "utf8"); + const manifest = createDefaultManifest("trusted-plugin"); + manifest.version = "1.2.3"; + manifest.entry.command = ["node", "index.js"]; + manifestPath = path.join(pluginRoot, "plugin-manifest.json"); + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8"); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + function policy(): PluginExecutionPolicy { + return { + schema: "ctg/plugin-execution-policy/v1", + trusted_plugins: [{ + name: "trusted-plugin", + version: "1.2.3", + manifest_sha256: digestFile(manifestPath), + entrypoint_sha256: digestFile(entrypointPath), + }], + process: { + allowed_env_vars: ["CTG_PLUGIN_TEST"], + timeout_seconds: 30, + max_stdout_bytes: 1024, + max_stderr_bytes: 512, + max_findings: 10, + max_evidence_per_finding: 2, + node_permission_model: true, + }, + }; + } + + it("loads and verifies exact manifest and entrypoint digests", () => { + const policyPath = path.join(root, "policy.json"); + writeFileSync(policyPath, JSON.stringify(policy()), "utf8"); + + const loaded = loadPluginExecutionPolicy(policyPath); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + const verified = verifyTrustedPlugin(loaded, pluginRoot, manifest); + + expect(verified.manifestPath).toBe(manifestPath); + expect(verified.entrypointPath).toBe(entrypointPath); + expect(verified.process).toMatchObject({ + allowed_env_vars: ["CTG_PLUGIN_TEST"], + timeout_seconds: 30, + max_stdout_bytes: 1024, + max_stderr_bytes: 512, + max_findings: 10, + max_evidence_per_finding: 2, + node_permission_model: true, + }); + }); + + it("rejects unknown plugins and digest tampering", () => { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + const untrusted = policy(); + untrusted.trusted_plugins = []; + expect(() => verifyTrustedPlugin(untrusted, pluginRoot, manifest)).toThrow(/Docker sandbox is required/); + + const trusted = policy(); + writeFileSync(entrypointPath, "tampered\n", "utf8"); + expect(() => verifyTrustedPlugin(trusted, pluginRoot, manifest)).toThrow(/entrypoint digest mismatch/); + }); + + it("rejects entrypoint symlink escapes", () => { + const outside = path.join(root, "outside"); + mkdirSync(outside, { recursive: true }); + writeFileSync(path.join(outside, "index.js"), "outside\n", "utf8"); + symlinkSync(outside, path.join(pluginRoot, "linked"), process.platform === "win32" ? "junction" : "dir"); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + manifest.entry.command = ["node", "linked/index.js"]; + + expect(() => resolvePluginEntrypoint(pluginRoot, manifest)).toThrow(/escapes the plugin directory/); + }); + + it("rejects forbidden environment variables and limits above hard caps", () => { + const invalid = { + schema: "ctg/plugin-execution-policy/v1", + trusted_plugins: [], + process: { + allowed_env_vars: ["NODE_OPTIONS"], + timeout_seconds: 61, + max_stdout_bytes: 10 * 1024 * 1024 + 1, + max_stderr_bytes: 1024 * 1024 + 1, + max_findings: 1001, + max_evidence_per_finding: 11, + }, + }; + const validation = validatePluginExecutionPolicy(invalid); + + expect(validation.valid).toBe(false); + expect(validation.errors).toEqual(expect.arrayContaining([ + expect.stringContaining("forbidden variable NODE_OPTIONS"), + expect.stringContaining("timeout_seconds"), + expect.stringContaining("max_stdout_bytes"), + expect.stringContaining("max_stderr_bytes"), + expect.stringContaining("max_findings"), + expect.stringContaining("max_evidence_per_finding"), + ])); + }); + + it("rejects malformed policy shapes and duplicate identities", () => { + expect(validatePluginExecutionPolicy(null)).toEqual({ + valid: false, + errors: ["policy must be an object"], + }); + + const wrongShape = validatePluginExecutionPolicy({ + schema: "wrong", + trusted_plugins: "not-an-array", + process: [], + }); + expect(wrongShape.errors).toEqual(expect.arrayContaining([ + expect.stringContaining("schema must be"), + "trusted_plugins must be an array", + "process must be an object", + ])); + + const validDigest = `sha256:${"a".repeat(64)}`; + const malformed = validatePluginExecutionPolicy({ + schema: "ctg/plugin-execution-policy/v1", + trusted_plugins: [ + null, + { name: "", version: "", manifest_sha256: "bad", entrypoint_sha256: "bad" }, + { name: "duplicate", version: "1.0.0", manifest_sha256: validDigest, entrypoint_sha256: validDigest }, + { name: "duplicate", version: "1.0.0", manifest_sha256: validDigest, entrypoint_sha256: validDigest }, + ], + process: { + allowed_env_vars: ["1INVALID", "NODE_PATH"], + timeout_seconds: 0, + max_stdout_bytes: 1.5, + max_stderr_bytes: -1, + max_findings: 1001, + max_evidence_per_finding: null, + node_permission_model: "yes", + }, + }); + expect(malformed.valid).toBe(false); + expect(malformed.errors).toEqual(expect.arrayContaining([ + "trusted_plugins[0] must be an object", + "trusted plugin name is required", + "trusted plugin version is required", + "trusted plugin manifest_sha256 is invalid", + "trusted plugin entrypoint_sha256 is invalid", + "duplicate trusted plugin identity: duplicate@1.0.0", + "allowed_env_vars contains an invalid name", + "allowed_env_vars contains forbidden variable NODE_PATH", + "node_permission_model must be boolean", + ])); + + const envNotArray = validatePluginExecutionPolicy({ + schema: "ctg/plugin-execution-policy/v1", + trusted_plugins: [], + process: { allowed_env_vars: "PATH" }, + }); + expect(envNotArray.errors).toContain("allowed_env_vars must be an array"); + expect(validatePluginExecutionPolicy({ + schema: "ctg/plugin-execution-policy/v1", + trusted_plugins: [], + })).toEqual({ valid: true, errors: [] }); + }); + + it("reports missing policy files and applies secure default limits", () => { + expect(() => locatePluginManifest(path.join(root, "missing-plugin"))).toThrow(/manifest file is missing/); + + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + manifest.entry.command = []; + expect(() => resolvePluginEntrypoint(pluginRoot, manifest)).toThrow(/entrypoint is missing/); + manifest.entry.command = ["missing.js"]; + expect(() => resolvePluginEntrypoint(pluginRoot, manifest)).toThrow(/does not identify a file/); + manifest.entry.command = ["index.js"]; + expect(resolvePluginEntrypoint(pluginRoot, manifest)).toBe(entrypointPath); + + const defaulted = policy(); + delete defaulted.process; + const verified = verifyTrustedPlugin(defaulted, pluginRoot, manifest); + expect(verified.process).toMatchObject({ + allowed_env_vars: [], + timeout_seconds: 60, + max_stdout_bytes: 10 * 1024 * 1024, + max_stderr_bytes: 1024 * 1024, + max_findings: 1000, + max_evidence_per_finding: 10, + node_permission_model: true, + }); + + const trusted = policy(); + writeFileSync(manifestPath, "{}\n", "utf8"); + expect(() => verifyTrustedPlugin(trusted, pluginRoot, manifest)).toThrow(/manifest digest mismatch/); + + const malformedPolicyPath = path.join(root, "malformed-policy.json"); + writeFileSync(malformedPolicyPath, "{", "utf8"); + expect(() => loadPluginExecutionPolicy(malformedPolicyPath)).toThrow(/cannot read plugin execution policy/); + + const invalidPolicyPath = path.join(root, "invalid-policy.json"); + writeFileSync(invalidPolicyPath, "{}", "utf8"); + expect(() => loadPluginExecutionPolicy(invalidPolicyPath)).toThrow(/invalid plugin execution policy/); + }); +}); diff --git a/src/plugin/__tests__/plugin-process-executor.test.ts b/src/plugin/__tests__/plugin-process-executor.test.ts index a2bd566..44033e7 100644 --- a/src/plugin/__tests__/plugin-process-executor.test.ts +++ b/src/plugin/__tests__/plugin-process-executor.test.ts @@ -13,7 +13,9 @@ vi.mock("node:child_process", () => ({ })); import { + buildPluginSpawnSpec, executePluginProcess, + filterPluginProcessEnv, killRunningProcesses, } from "../plugin-process-executor.js"; import { createDefaultManifest } from "../plugin-schema.js"; @@ -316,3 +318,92 @@ describe("plugin process executor", () => { } }); }); + +describe("plugin process hard limits", () => { + beforeEach(() => { + childMocks.spawn.mockReset(); + childMocks.execFile.mockReset(); + vi.clearAllMocks(); + }); + + it("passes only the base and explicitly allowed environment variables", () => { + const previousAllowed = process.env.CTG_ALLOWED; + const previousSecret = process.env.SECRET_TOKEN; + process.env.CTG_ALLOWED = "host"; + process.env.SECRET_TOKEN = "secret"; + try { + const value = manifest(); + value.entry.env = { CTG_ALLOWED: "manifest", SECRET_TOKEN: "manifest-secret" }; + const env = filterPluginProcessEnv(value.entry.env, ["CTG_ALLOWED"]); + const spec = buildPluginSpawnSpec(value, { allowedEnvVars: ["CTG_ALLOWED"] }); + + expect(env.CTG_ALLOWED).toBe("manifest"); + expect(env.SECRET_TOKEN).toBeUndefined(); + expect(spec.env.CTG_ALLOWED).toBe("manifest"); + expect(spec.env.SECRET_TOKEN).toBeUndefined(); + expect(spec.env.NODE_OPTIONS).toBeUndefined(); + } finally { + if (previousAllowed === undefined) delete process.env.CTG_ALLOWED; + else process.env.CTG_ALLOWED = previousAllowed; + if (previousSecret === undefined) delete process.env.SECRET_TOKEN; + else process.env.SECRET_TOKEN = previousSecret; + } + }); + + it("terminates output streams that exceed byte limits", async () => { + const stdoutChild = childProcess(); + stdoutChild.pid = undefined as unknown as number; + childMocks.spawn.mockImplementationOnce(() => { + queueMicrotask(() => stdoutChild.stdout.write("123456")); + return stdoutChild; + }); + await expect(executePluginProcess( + manifest(), "{}", 1000, logger, new Map(), { maxStdoutBytes: 5 } + )).rejects.toThrow("STDOUT_LIMIT_EXCEEDED"); + + const stderrChild = childProcess(); + stderrChild.pid = undefined as unknown as number; + childMocks.spawn.mockImplementationOnce(() => { + queueMicrotask(() => stderrChild.stderr.write("123456")); + return stderrChild; + }); + await expect(executePluginProcess( + manifest(), "{}", 1000, logger, new Map(), { maxStderrBytes: 5 } + )).rejects.toThrow("STDERR_LIMIT_EXCEEDED"); + }); + + it("rejects excessive finding and evidence counts", async () => { + const findingsChild = childProcess(); + childMocks.spawn.mockImplementationOnce(() => { + queueMicrotask(() => { + findingsChild.stdout.write(JSON.stringify({ + version: PLUGIN_OUTPUT_VERSION, + findings: [ + { id: "one", evidence: [] }, + { id: "two", evidence: [] }, + ], + })); + findingsChild.emit("close", 0); + }); + return findingsChild; + }); + await expect(executePluginProcess( + manifest(), "{}", 1000, logger, new Map(), { maxFindings: 1 } + )).rejects.toThrow("FINDING_LIMIT_EXCEEDED"); + + const evidenceChild = childProcess(); + childMocks.spawn.mockImplementationOnce(() => { + queueMicrotask(() => { + evidenceChild.stdout.write(JSON.stringify({ + version: PLUGIN_OUTPUT_VERSION, + findings: [{ id: "one", evidence: [{ id: "a" }, { id: "b" }] }], + })); + evidenceChild.emit("close", 0); + }); + return evidenceChild; + }); + await expect(executePluginProcess( + manifest(), "{}", 1000, logger, new Map(), { maxEvidencePerFinding: 1 } + )).rejects.toThrow("EVIDENCE_LIMIT_EXCEEDED"); + }); +}); diff --git a/src/plugin/__tests__/plugin-runner.test.ts b/src/plugin/__tests__/plugin-runner.test.ts index 19add67..b548978 100644 --- a/src/plugin/__tests__/plugin-runner.test.ts +++ b/src/plugin/__tests__/plugin-runner.test.ts @@ -49,9 +49,9 @@ describe("PluginRunner", () => { expect(runner.healthCheck).toBeDefined(); }); - it("preserves the programmatic none default and supports mode switches", () => { + it("uses the policy-gated Process default and supports mode switches", () => { const runner = new PluginRunnerImpl(); - expect(runner.getSandboxMode()).toBe("none"); + expect(runner.getSandboxMode()).toBe("process"); runner.setSandboxMode("docker"); expect(runner.getSandboxMode()).toBe("docker"); runner.setSandboxMode("process"); diff --git a/src/plugin/docker-command-builder.ts b/src/plugin/docker-command-builder.ts index 6ca4d56..798adc4 100644 --- a/src/plugin/docker-command-builder.ts +++ b/src/plugin/docker-command-builder.ts @@ -32,10 +32,8 @@ export function buildDockerRunCommand( // Container name cmd.push("--name", containerName); - // Network isolation (unless explicitly allowed) - if (!config.networkAccess) { - cmd.push("--network=none"); - } + // Network isolation is mandatory for plugin containers. + cmd.push("--network=none"); // Memory limit const resources = toDockerResourceLimits(config); @@ -52,6 +50,8 @@ export function buildDockerRunCommand( // Security options const securityOptions = getDockerSecurityOptions(config); cmd.push(...buildDockerSecurityFlags(securityOptions)); + cmd.push("--read-only"); + cmd.push("--tmpfs", "/tmp:rw,noexec,nosuid,size=16m"); // Volume mounts cmd.push(...toDockerVolumeFlags(mounts)); diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 27cd927..b6c4ef1 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -132,6 +132,25 @@ export { getFailedPlugins, } from "./plugin-runner.js"; +// === Plugin Execution Policy === +export { + PLUGIN_EXECUTION_POLICY_SCHEMA, + DEFAULT_PLUGIN_TIMEOUT_SECONDS, + DEFAULT_PLUGIN_STDOUT_BYTES, + DEFAULT_PLUGIN_STDERR_BYTES, + DEFAULT_PLUGIN_FINDINGS, + DEFAULT_PLUGIN_EVIDENCE_PER_FINDING, + loadPluginExecutionPolicy, + validatePluginExecutionPolicy, + verifyTrustedPlugin, + locatePluginManifest, + resolvePluginEntrypoint, + type PluginExecutionPolicy, + type PluginProcessPolicy, + type TrustedPluginBinding, + type VerifiedPluginExecution, +} from "./plugin-execution-policy.js"; + // === Sandbox Configuration === export { SandboxMode, diff --git a/src/plugin/plugin-execution-policy.ts b/src/plugin/plugin-execution-policy.ts new file mode 100644 index 0000000..644f520 --- /dev/null +++ b/src/plugin/plugin-execution-policy.ts @@ -0,0 +1,228 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; +import path from "node:path"; +import type { PluginManifest } from "./types.js"; + +export const PLUGIN_EXECUTION_POLICY_SCHEMA = "ctg/plugin-execution-policy/v1" as const; +export const DEFAULT_PLUGIN_TIMEOUT_SECONDS = 60; +export const DEFAULT_PLUGIN_STDOUT_BYTES = 10 * 1024 * 1024; +export const DEFAULT_PLUGIN_STDERR_BYTES = 1024 * 1024; +export const DEFAULT_PLUGIN_FINDINGS = 1000; +export const DEFAULT_PLUGIN_EVIDENCE_PER_FINDING = 10; + +const MANIFEST_FILES = [ + "plugin-manifest.yaml", + "plugin-manifest.yml", + "plugin-manifest.json", + "manifest.yaml", + "manifest.yml", + "manifest.json", + "ctg-plugin.yaml", + "ctg-plugin.json", +]; + +const FORBIDDEN_ENV = new Set([ + "NODE_OPTIONS", + "NODE_PATH", + "ELECTRON_RUN_AS_NODE", +]); + +export interface TrustedPluginBinding { + name: string; + version: string; + manifest_sha256: `sha256:${string}`; + entrypoint_sha256: `sha256:${string}`; +} + +export interface PluginProcessPolicy { + allowed_env_vars?: string[]; + timeout_seconds?: number; + max_stdout_bytes?: number; + max_stderr_bytes?: number; + max_findings?: number; + max_evidence_per_finding?: number; + node_permission_model?: boolean; +} + +export interface PluginExecutionPolicy { + schema: typeof PLUGIN_EXECUTION_POLICY_SCHEMA; + trusted_plugins: TrustedPluginBinding[]; + process?: PluginProcessPolicy; +} + +export interface VerifiedPluginExecution { + manifestPath: string; + entrypointPath: string; + process: Required<PluginProcessPolicy>; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isDigest(value: unknown): value is `sha256:${string}` { + return typeof value === "string" && /^sha256:[0-9a-f]{64}$/.test(value); +} + +function positiveInteger(value: unknown, maximum: number, field: string, errors: string[]): number | undefined { + if (value === undefined) return undefined; + if (!Number.isInteger(value) || (value as number) <= 0 || (value as number) > maximum) { + errors.push(field + " must be an integer between 1 and " + maximum); + return undefined; + } + return value as number; +} + +function inside(root: string, target: string): boolean { + const normalizedRoot = process.platform === "win32" ? root.toLowerCase() : root; + const normalizedTarget = process.platform === "win32" ? target.toLowerCase() : target; + const relative = path.relative(normalizedRoot, normalizedTarget); + return relative === "" || (relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative)); +} + +function containedFile(pluginRoot: string, candidate: string, label: string): string { + const realRoot = realpathSync(pluginRoot); + const requested = path.isAbsolute(candidate) ? candidate : path.resolve(realRoot, candidate); + if (!existsSync(requested) || !statSync(requested).isFile()) { + throw new Error(label + " does not identify a file"); + } + const resolved = realpathSync(requested); + if (!inside(realRoot, resolved)) { + throw new Error(label + " escapes the plugin directory"); + } + return resolved; +} + +function sha256File(filePath: string): `sha256:${string}` { + return `sha256:${createHash("sha256").update(readFileSync(filePath)).digest("hex")}`; +} + +export function locatePluginManifest(pluginRoot: string): string { + for (const name of MANIFEST_FILES) { + const candidate = path.join(pluginRoot, name); + if (existsSync(candidate)) return containedFile(pluginRoot, candidate, "plugin manifest"); + } + throw new Error("plugin manifest file is missing"); +} + +export function resolvePluginEntrypoint(pluginRoot: string, manifest: PluginManifest): string { + const command = manifest.entry.command; + const executable = path.basename(command[0] ?? "").toLowerCase(); + const entrypoint = executable === "node" || executable === "node.exe" + ? command[1] + : command[0]; + if (!entrypoint) { + throw new Error("plugin entrypoint is missing"); + } + return containedFile(pluginRoot, entrypoint, "plugin entrypoint"); +} + +export function validatePluginExecutionPolicy(value: unknown): { valid: boolean; errors: string[] } { + const errors: string[] = []; + if (!isRecord(value)) return { valid: false, errors: ["policy must be an object"] }; + if (value.schema !== PLUGIN_EXECUTION_POLICY_SCHEMA) { + errors.push("schema must be " + PLUGIN_EXECUTION_POLICY_SCHEMA); + } + if (!Array.isArray(value.trusted_plugins)) { + errors.push("trusted_plugins must be an array"); + } else { + const identities = new Set<string>(); + value.trusted_plugins.forEach((entry, index) => { + if (!isRecord(entry)) { + errors.push("trusted_plugins[" + index + "] must be an object"); + return; + } + if (typeof entry.name !== "string" || entry.name.length === 0) errors.push("trusted plugin name is required"); + if (typeof entry.version !== "string" || entry.version.length === 0) errors.push("trusted plugin version is required"); + if (!isDigest(entry.manifest_sha256)) errors.push("trusted plugin manifest_sha256 is invalid"); + if (!isDigest(entry.entrypoint_sha256)) errors.push("trusted plugin entrypoint_sha256 is invalid"); + const identity = String(entry.name) + "@" + String(entry.version); + if (identities.has(identity)) errors.push("duplicate trusted plugin identity: " + identity); + identities.add(identity); + }); + } + + if (value.process !== undefined) { + if (!isRecord(value.process)) { + errors.push("process must be an object"); + } else { + const processPolicy = value.process; + positiveInteger(processPolicy.timeout_seconds, DEFAULT_PLUGIN_TIMEOUT_SECONDS, "timeout_seconds", errors); + positiveInteger(processPolicy.max_stdout_bytes, DEFAULT_PLUGIN_STDOUT_BYTES, "max_stdout_bytes", errors); + positiveInteger(processPolicy.max_stderr_bytes, DEFAULT_PLUGIN_STDERR_BYTES, "max_stderr_bytes", errors); + positiveInteger(processPolicy.max_findings, DEFAULT_PLUGIN_FINDINGS, "max_findings", errors); + positiveInteger(processPolicy.max_evidence_per_finding, DEFAULT_PLUGIN_EVIDENCE_PER_FINDING, "max_evidence_per_finding", errors); + if (processPolicy.node_permission_model !== undefined && typeof processPolicy.node_permission_model !== "boolean") { + errors.push("node_permission_model must be boolean"); + } + if (processPolicy.allowed_env_vars !== undefined) { + if (!Array.isArray(processPolicy.allowed_env_vars)) { + errors.push("allowed_env_vars must be an array"); + } else { + for (const name of processPolicy.allowed_env_vars) { + if (typeof name !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { + errors.push("allowed_env_vars contains an invalid name"); + } else if (FORBIDDEN_ENV.has(name.toUpperCase())) { + errors.push("allowed_env_vars contains forbidden variable " + name); + } + } + } + } + } + } + + return { valid: errors.length === 0, errors }; +} + +export function loadPluginExecutionPolicy(filePath: string): PluginExecutionPolicy { + let value: unknown; + try { + value = JSON.parse(readFileSync(filePath, "utf8")); + } catch (error) { + throw new Error( + "cannot read plugin execution policy: " + (error instanceof Error ? error.message : String(error)), + { cause: error } + ); + } + const validation = validatePluginExecutionPolicy(value); + if (!validation.valid) { + throw new Error("invalid plugin execution policy: " + validation.errors.join("; ")); + } + return value as PluginExecutionPolicy; +} + +export function verifyTrustedPlugin( + policy: PluginExecutionPolicy, + pluginRoot: string, + manifest: PluginManifest +): VerifiedPluginExecution { + const binding = policy.trusted_plugins.find( + (entry) => entry.name === manifest.name && entry.version === manifest.version + ); + if (!binding) { + throw new Error("plugin is not trusted for Process execution; Docker sandbox is required"); + } + + const manifestPath = locatePluginManifest(pluginRoot); + const entrypointPath = resolvePluginEntrypoint(pluginRoot, manifest); + if (sha256File(manifestPath) !== binding.manifest_sha256) { + throw new Error("trusted plugin manifest digest mismatch"); + } + if (sha256File(entrypointPath) !== binding.entrypoint_sha256) { + throw new Error("trusted plugin entrypoint digest mismatch"); + } + + return { + manifestPath, + entrypointPath, + process: { + allowed_env_vars: [...new Set(policy.process?.allowed_env_vars ?? [])].sort(), + timeout_seconds: policy.process?.timeout_seconds ?? DEFAULT_PLUGIN_TIMEOUT_SECONDS, + max_stdout_bytes: policy.process?.max_stdout_bytes ?? DEFAULT_PLUGIN_STDOUT_BYTES, + max_stderr_bytes: policy.process?.max_stderr_bytes ?? DEFAULT_PLUGIN_STDERR_BYTES, + max_findings: policy.process?.max_findings ?? DEFAULT_PLUGIN_FINDINGS, + max_evidence_per_finding: policy.process?.max_evidence_per_finding ?? DEFAULT_PLUGIN_EVIDENCE_PER_FINDING, + node_permission_model: policy.process?.node_permission_model ?? true, + }, + }; +} diff --git a/src/plugin/plugin-process-executor.ts b/src/plugin/plugin-process-executor.ts index 722ef08..a98fef0 100644 --- a/src/plugin/plugin-process-executor.ts +++ b/src/plugin/plugin-process-executor.ts @@ -1,15 +1,128 @@ /** * Plugin Process Executor - * Handles spawning and execution of plugin processes + * Handles bounded spawning and execution of trusted plugin processes. */ import type { PluginManifest, PluginOutput } from "./types.js"; import { PLUGIN_OUTPUT_VERSION } from "./types.js"; import { DefaultPluginLogger } from "./plugin-context.js"; import { execFile, spawn, ChildProcess } from "node:child_process"; -import * as path from "path"; +import { existsSync, realpathSync, statSync } from "node:fs"; +import path from "node:path"; const TERMINATION_GRACE_MS = 500; +const DEFAULT_STDOUT_BYTES = 10 * 1024 * 1024; +const DEFAULT_STDERR_BYTES = 1024 * 1024; +const DEFAULT_FINDINGS = 1000; +const DEFAULT_EVIDENCE_PER_FINDING = 10; +const BASE_ENV_ALLOWLIST = process.platform === "win32" + ? ["SystemRoot", "WINDIR", "COMSPEC", "PATHEXT", "TEMP", "TMP", "PATH"] + : ["PATH", "TMPDIR", "TEMP", "TMP", "LANG", "LC_ALL"]; + +export interface PluginProcessExecutionOptions { + pluginRoot?: string; + workDir?: string; + allowedEnvVars?: string[]; + maxStdoutBytes?: number; + maxStderrBytes?: number; + maxFindings?: number; + maxEvidencePerFinding?: number; + nodePermissionModel?: boolean; +} + +export interface PluginSpawnSpec { + executable: string; + args: string[]; + cwd: string; + env: NodeJS.ProcessEnv; +} + +function inside(root: string, target: string): boolean { + const normalizedRoot = process.platform === "win32" ? root.toLowerCase() : root; + const normalizedTarget = process.platform === "win32" ? target.toLowerCase() : target; + const relative = path.relative(normalizedRoot, normalizedTarget); + return relative === "" || (relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative)); +} + +function containedFile(pluginRoot: string, candidate: string): string { + const root = realpathSync(pluginRoot); + const requested = path.isAbsolute(candidate) ? candidate : path.resolve(root, candidate); + if (!existsSync(requested) || !statSync(requested).isFile()) { + throw new Error("ENTRYPOINT_INVALID: Plugin entrypoint does not identify a file"); + } + const resolved = realpathSync(requested); + if (!inside(root, resolved)) { + throw new Error("ENTRYPOINT_ESCAPE: Plugin entrypoint escapes the plugin directory"); + } + return resolved; +} + +export function filterPluginProcessEnv( + manifestEnv: Record<string, string>, + allowedEnvVars: string[] +): NodeJS.ProcessEnv { + const allowed = new Set([...BASE_ENV_ALLOWLIST, ...allowedEnvVars]); + const env: NodeJS.ProcessEnv = {}; + for (const name of allowed) { + const value = process.env[name]; + if (typeof value === "string") env[name] = value; + } + for (const [name, value] of Object.entries(manifestEnv)) { + if (allowedEnvVars.includes(name)) env[name] = value; + } + return env; +} + +export function buildPluginSpawnSpec( + manifest: PluginManifest, + options: PluginProcessExecutionOptions = {} +): PluginSpawnSpec { + const command = manifest.entry.command; + if (!command[0]) throw new Error("ENTRYPOINT_INVALID: Plugin command is empty"); + + if (!options.pluginRoot) { + return { + executable: command[0], + args: command.slice(1), + cwd: path.dirname(command[0] === "node" ? command[1] ?? "." : command[0]), + env: filterPluginProcessEnv(manifest.entry.env ?? {}, options.allowedEnvVars ?? []), + }; + } + + const pluginRoot = realpathSync(options.pluginRoot); + const workDir = path.resolve(options.workDir ?? path.join(pluginRoot, ".ctg-work")); + const executableName = path.basename(command[0]).toLowerCase(); + const isNode = executableName === "node" || executableName === "node.exe"; + + if (isNode) { + if (!command[1]) throw new Error("ENTRYPOINT_INVALID: Node plugin script is missing"); + const script = containedFile(pluginRoot, command[1]); + const args: string[] = []; + if (options.nodePermissionModel !== false) { + args.push( + "--permission", + "--allow-fs-read=" + pluginRoot, + "--allow-fs-read=" + workDir, + "--allow-fs-write=" + workDir + ); + } + args.push(script, ...command.slice(2)); + return { + executable: process.execPath, + args, + cwd: pluginRoot, + env: filterPluginProcessEnv(manifest.entry.env ?? {}, options.allowedEnvVars ?? []), + }; + } + + const executable = containedFile(pluginRoot, command[0]); + return { + executable, + args: command.slice(1), + cwd: pluginRoot, + env: filterPluginProcessEnv(manifest.entry.env ?? {}, options.allowedEnvVars ?? []), + }; +} function waitForExit(childProcess: ChildProcess, timeoutMs: number): Promise<boolean> { if (childProcess.exitCode !== null || childProcess.signalCode !== null) { @@ -57,48 +170,63 @@ async function terminateChildProcess(childProcess: ChildProcess): Promise<void> } try { - if (pid) { - process.kill(-pid, "SIGTERM"); - } else { - childProcess.kill("SIGTERM"); - } + if (pid) process.kill(-pid, "SIGTERM"); + else childProcess.kill("SIGTERM"); } catch { childProcess.kill("SIGTERM"); } - if (await waitForExit(childProcess, TERMINATION_GRACE_MS)) { - return; - } + if (await waitForExit(childProcess, TERMINATION_GRACE_MS)) return; try { - if (pid) { - process.kill(-pid, "SIGKILL"); - } else { - childProcess.kill("SIGKILL"); - } + if (pid) process.kill(-pid, "SIGKILL"); + else childProcess.kill("SIGKILL"); } catch { childProcess.kill("SIGKILL"); } await waitForExit(childProcess, TERMINATION_GRACE_MS); } +function validateOutputLimits( + output: PluginOutput, + maxFindings: number, + maxEvidencePerFinding: number +): void { + if ((output.findings?.length ?? 0) > maxFindings) { + throw new Error("FINDING_LIMIT_EXCEEDED: Plugin output contains too many findings"); + } + for (const finding of output.findings ?? []) { + if ((finding.evidence?.length ?? 0) > maxEvidencePerFinding) { + throw new Error("EVIDENCE_LIMIT_EXCEEDED: Plugin finding contains too many evidence records"); + } + } +} + /** - * Execute plugin process via stdin/stdout + * Execute plugin process via stdin/stdout. */ export async function executePluginProcess( manifest: PluginManifest, inputJson: string, timeoutMs: number, logger: DefaultPluginLogger, - runningProcesses: Map<string, ChildProcess> + runningProcesses: Map<string, ChildProcess>, + options: PluginProcessExecutionOptions = {} ): Promise<PluginOutput> { - const command = manifest.entry.command; - const env = manifest.entry.env ?? {}; + const maxStdoutBytes = options.maxStdoutBytes ?? DEFAULT_STDOUT_BYTES; + const maxStderrBytes = options.maxStderrBytes ?? DEFAULT_STDERR_BYTES; + const maxFindings = options.maxFindings ?? DEFAULT_FINDINGS; + const maxEvidencePerFinding = options.maxEvidencePerFinding ?? DEFAULT_EVIDENCE_PER_FINDING; + const spawnSpec = buildPluginSpawnSpec(manifest, options); return new Promise<PluginOutput>((resolve, reject) => { let childProcess: ChildProcess | undefined; let settled = false; - let timedOut = false; + let terminating = false; + let stdoutBytes = 0; + let stderrBytes = 0; + let stdoutData = ""; + let stderrData = ""; const rejectOnce = (error: Error) => { if (settled) return; @@ -112,86 +240,96 @@ export async function executePluginProcess( clearTimeout(timeoutId); resolve(output); }; - const timeoutId = setTimeout(() => { - timedOut = true; - const timeoutError = new Error(`TIMEOUT: Plugin execution exceeded ${timeoutMs}ms`); + const terminateFor = (error: Error) => { + if (terminating || settled) return; + terminating = true; if (!childProcess) { - rejectOnce(timeoutError); + rejectOnce(error); return; } void terminateChildProcess(childProcess).finally(() => { runningProcesses.delete(manifest.name); - rejectOnce(timeoutError); + rejectOnce(error); }); + }; + const timeoutId = setTimeout(() => { + terminateFor(new Error("TIMEOUT: Plugin execution exceeded " + timeoutMs + "ms")); }, timeoutMs); try { - childProcess = spawn(command[0], command.slice(1), { - cwd: path.dirname(command[0] === "node" ? command[1] ?? "." : command[0]), - env: { - ...process.env, - ...env, - }, + childProcess = spawn(spawnSpec.executable, spawnSpec.args, { + cwd: spawnSpec.cwd, + env: spawnSpec.env, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32", + windowsHide: true, }); runningProcesses.set(manifest.name, childProcess); - let stdoutData = ""; - let stderrData = ""; - childProcess.stdout?.on("data", (data) => { - stdoutData += data.toString(); + const chunk = data.toString(); + stdoutBytes += Buffer.byteLength(chunk, "utf8"); + if (stdoutBytes > maxStdoutBytes) { + terminateFor(new Error("STDOUT_LIMIT_EXCEEDED: Plugin stdout exceeded " + maxStdoutBytes + " bytes")); + return; + } + stdoutData += chunk; }); childProcess.stderr?.on("data", (data) => { - stderrData += data.toString(); - logger.debug("Plugin stderr", { data: data.toString() }); + const chunk = data.toString(); + stderrBytes += Buffer.byteLength(chunk, "utf8"); + if (stderrBytes > maxStderrBytes) { + terminateFor(new Error("STDERR_LIMIT_EXCEEDED: Plugin stderr exceeded " + maxStderrBytes + " bytes")); + return; + } + stderrData += chunk; + logger.debug("Plugin stderr", { data: chunk }); }); childProcess.on("error", (error) => { - if (timedOut) return; + if (terminating) return; runningProcesses.delete(manifest.name); - rejectOnce(new Error(`PROCESS_ERROR: ${error.message}`)); + rejectOnce(new Error("PROCESS_ERROR: " + error.message)); }); childProcess.on("close", (code) => { - if (timedOut) return; + if (terminating) return; runningProcesses.delete(manifest.name); if (code !== 0) { - rejectOnce(new Error(`EXIT_CODE_${code}: Plugin exited with code ${code}. stderr: ${stderrData}`)); + rejectOnce(new Error("EXIT_CODE_" + code + ": Plugin exited with code " + code + ". stderr: " + stderrData)); return; } try { const output = JSON.parse(stdoutData) as PluginOutput; - - // Validate version if (output.version !== PLUGIN_OUTPUT_VERSION) { - rejectOnce(new Error(`INVALID_VERSION: Expected ${PLUGIN_OUTPUT_VERSION}, got ${output.version}`)); + rejectOnce(new Error("INVALID_VERSION: Expected " + PLUGIN_OUTPUT_VERSION + ", got " + output.version)); return; } - + validateOutputLimits(output, maxFindings, maxEvidencePerFinding); resolveOnce(output); - } catch (_parseError) { - rejectOnce(new Error(`PARSE_ERROR: Failed to parse plugin output. stdout: ${stdoutData.slice(0, 500)}`)); + } catch (error) { + if (error instanceof Error && /_LIMIT_EXCEEDED:/.test(error.message)) { + rejectOnce(error); + } else { + rejectOnce(new Error("PARSE_ERROR: Failed to parse plugin output. stdout: " + stdoutData.slice(0, 500))); + } } }); - // Write input to stdin childProcess.stdin?.write(inputJson); childProcess.stdin?.end(); - } catch (spawnError) { - rejectOnce(new Error(`SPAWN_ERROR: ${spawnError instanceof Error ? spawnError.message : "Unknown spawn error"}`)); + rejectOnce(new Error("SPAWN_ERROR: " + (spawnError instanceof Error ? spawnError.message : "Unknown spawn error"))); } }); } /** - * Kill running plugin processes + * Kill running plugin processes. */ export async function killRunningProcesses( runningProcesses: Map<string, ChildProcess>, @@ -199,7 +337,7 @@ export async function killRunningProcesses( ): Promise<void> { const processes = Array.from(runningProcesses.entries()); await Promise.all(processes.map(async ([name, childProcess]) => { - logger.info(`Killing running plugin: ${name}`); + logger.info("Killing running plugin: " + name); await terminateChildProcess(childProcess); })); runningProcesses.clear(); diff --git a/src/plugin/plugin-runner.ts b/src/plugin/plugin-runner.ts index 33be0a1..2e1dec6 100644 --- a/src/plugin/plugin-runner.ts +++ b/src/plugin/plugin-runner.ts @@ -44,7 +44,7 @@ export class PluginRunnerImpl implements PluginRunner { private sandboxConfig: SandboxConfig; private dockerRunner: DockerSandboxRunner | null; - constructor(sandboxMode: SandboxMode = "none", sandboxConfig: SandboxConfig = DEFAULT_SANDBOX_CONFIG) { + constructor(sandboxMode: SandboxMode = "process", sandboxConfig: SandboxConfig = DEFAULT_SANDBOX_CONFIG) { this.config = { timeout: PLUGIN_CONSTANTS.DEFAULT_TIMEOUT, retry: PLUGIN_CONSTANTS.DEFAULT_RETRY, @@ -136,10 +136,11 @@ export class PluginRunnerImpl implements PluginRunner { await this.runHooks("before_execute", entry, context); // Get timeout for this plugin - const timeout = this.timeoutOverrides.get(pluginName) ?? + const requestedTimeout = this.timeoutOverrides.get(pluginName) ?? manifest.entry.timeout ?? this.config.timeout ?? PLUGIN_CONSTANTS.DEFAULT_TIMEOUT; + const timeout = Math.min(requestedTimeout, this.sandboxConfig.timeout, 60); const maxRetry = manifest.entry.retry ?? this.config.retry ?? PLUGIN_CONSTANTS.DEFAULT_RETRY; let retryCount = 0; @@ -156,7 +157,17 @@ export class PluginRunnerImpl implements PluginRunner { inputJson, timeout * 1000, this.logger, - this.runningProcesses + this.runningProcesses, + { + pluginRoot: entry.path, + workDir: this.config.workDir, + allowedEnvVars: this.sandboxConfig.allowedEnvVars, + maxStdoutBytes: this.sandboxConfig.maxStdoutBytes, + maxStderrBytes: this.sandboxConfig.maxStderrBytes, + maxFindings: this.sandboxConfig.maxFindings, + maxEvidencePerFinding: this.sandboxConfig.maxEvidencePerFinding, + nodePermissionModel: this.sandboxMode === "process" && this.sandboxConfig.nodePermissionModel, + } ); // Validate output @@ -436,7 +447,7 @@ export function createPluginRunner( sandboxMode?: SandboxMode, sandboxConfig?: SandboxConfig ): PluginRunner { - const mode = sandboxMode ?? "none"; + const mode = sandboxMode ?? "process"; const config = sandboxConfig ?? DEFAULT_SANDBOX_CONFIG; return new PluginRunnerImpl(mode, config); } diff --git a/src/plugin/sandbox-config.ts b/src/plugin/sandbox-config.ts index ec38a02..b5e0450 100644 --- a/src/plugin/sandbox-config.ts +++ b/src/plugin/sandbox-config.ts @@ -62,6 +62,21 @@ export interface SandboxConfig { /** Maximum file size that can be written (MB) */ maxFileSizeMB: number; + /** Maximum captured stdout/result size */ + maxStdoutBytes: number; + + /** Maximum captured stderr size */ + maxStderrBytes: number; + + /** Maximum findings accepted from one plugin execution */ + maxFindings: number; + + /** Maximum evidence records accepted per finding */ + maxEvidencePerFinding: number; + + /** Apply the Node.js Permission Model to Node Process plugins */ + nodePermissionModel: boolean; + /** Whether to enforce strict security (seccomp, no new privileges) */ strictSecurity: boolean; @@ -73,7 +88,7 @@ export interface SandboxConfig { * Default sandbox configuration */ export const DEFAULT_SANDBOX_CONFIG: SandboxConfig = { - mode: "none", + mode: "process", timeout: 60, memoryLimit: 512, cpuLimit: 0.5, @@ -89,6 +104,11 @@ export const DEFAULT_SANDBOX_CONFIG: SandboxConfig = { pluginMountPath: "/plugin/code", ioMountPath: "/plugin/io", maxFileSizeMB: 10, + maxStdoutBytes: 10 * 1024 * 1024, + maxStderrBytes: 1024 * 1024, + maxFindings: 1000, + maxEvidencePerFinding: 10, + nodePermissionModel: true, strictSecurity: true, }; @@ -384,7 +404,10 @@ export function createSandboxConfigFromManifest( * Parse sandbox mode from string */ export function parseSandboxMode(value: string | undefined): SandboxMode { - if (!value || value === "none" || value === "disabled") { + if (!value) { + return "process"; + } + if (value === "none" || value === "disabled") { return "none"; } if (value === "docker") { @@ -393,8 +416,8 @@ export function parseSandboxMode(value: string | undefined): SandboxMode { if (value === "process") { return "process"; } - // Invalid value, default to none - return "none"; + // Invalid values fail toward the policy-gated Process mode. + return "process"; } /** @@ -407,8 +430,21 @@ export function validateSandboxConfig(config: SandboxConfig): { const errors: string[] = []; // Validate timeout - if (config.timeout <= 0 || config.timeout > 3600) { - errors.push("Timeout must be between 1 and 3600 seconds"); + if (config.timeout <= 0 || config.timeout > 60) { + errors.push("Timeout must be between 1 and 60 seconds"); + } + + if (config.maxStdoutBytes <= 0 || config.maxStdoutBytes > 10 * 1024 * 1024) { + errors.push("maxStdoutBytes must be between 1 and 10485760"); + } + if (config.maxStderrBytes <= 0 || config.maxStderrBytes > 1024 * 1024) { + errors.push("maxStderrBytes must be between 1 and 1048576"); + } + if (config.maxFindings <= 0 || config.maxFindings > 1000) { + errors.push("maxFindings must be between 1 and 1000"); + } + if (config.maxEvidencePerFinding <= 0 || config.maxEvidencePerFinding > 10) { + errors.push("maxEvidencePerFinding must be between 1 and 10"); } // Validate memory limit @@ -421,6 +457,13 @@ export function validateSandboxConfig(config: SandboxConfig): { errors.push("CPU limit must be between 0.1 and 4 (fraction or multiplier)"); } + if (config.mode === "docker" && config.networkAccess) { + errors.push("Docker sandbox requires networkAccess=false"); + } + if (config.mode === "docker" && config.strictSecurity === false) { + errors.push("Docker sandbox requires strictSecurity=true"); + } + // Validate Docker image if (config.mode === "docker" && !config.dockerImage) { errors.push("Docker image must be specified for Docker sandbox mode"); diff --git a/src/types/artifacts.ts b/src/types/artifacts.ts index 3d52408..6150e7d 100644 --- a/src/types/artifacts.ts +++ b/src/types/artifacts.ts @@ -150,6 +150,7 @@ export type UpstreamTool = | "eslint" | "sarif" | "codeql" + | "npm-audit" | "sonarqube" | "tsc" | "coverage" @@ -190,6 +191,38 @@ export interface FindingsArtifact extends ArtifactHeader { unsupported_claims: UnsupportedClaim[]; } +export interface ImportDiagnostic { + code: string; + message: string; + record_index?: number; +} + +export interface ImportManifestArtifact extends ArtifactHeader { + artifact: "import-manifest"; + schema: "import-manifest@v1"; + completeness: Completeness; + source: { + tool: Exclude<UpstreamTool, "native">; + format: string; + path: string; + path_kind: "repo_relative" | "external_redacted"; + sha256: `sha256:${string}`; + size_bytes: number; + producer: { name: string; version: string }; + format_version?: string; + repository_revision?: string; + }; + normalized: { + path: string; + sha256: `sha256:${string}`; + size_bytes: number; + schema: "findings@v1"; + }; + summary: { seen: number; accepted: number; dropped: number; errors: number }; + diagnostics: ImportDiagnostic[]; + generated_by: "ctg-import/v1"; +} + // === Risk Register === export type Likelihood = "low" | "medium" | "high" | "unknown"; @@ -1588,7 +1621,23 @@ export interface NormalizedRepoGraph extends ArtifactHeader { configs: unknown[]; entrypoints: unknown[]; diagnostics: unknown[]; - stats: { partial: boolean }; + stats: { + partial: boolean; + scan?: { + visitedFiles: number; + acceptedFiles: number; + acceptedBytes: number; + skippedFiles: number; + limits: { + maxFiles: number; + maxDepth: number; + maxFileSizeBytes: number; + maxTotalBytes: number; + deadlineMs: number; + }; + reasons: string[]; + }; + }; } // === Policy === diff --git a/src/types/graph.ts b/src/types/graph.ts index 1066a56..5f88036 100644 --- a/src/types/graph.ts +++ b/src/types/graph.ts @@ -131,6 +131,20 @@ export interface GraphDiagnostic { export interface GraphStats { partial: boolean; + scan?: { + visitedFiles: number; + acceptedFiles: number; + acceptedBytes: number; + skippedFiles: number; + limits: { + maxFiles: number; + maxDepth: number; + maxFileSizeBytes: number; + maxTotalBytes: number; + deadlineMs: number; + }; + reasons: string[]; + }; } export interface RepoModule { diff --git a/tests/integration/schema-coverage.test.ts b/tests/integration/schema-coverage.test.ts index 2466a1c..89a0a9a 100644 --- a/tests/integration/schema-coverage.test.ts +++ b/tests/integration/schema-coverage.test.ts @@ -269,6 +269,8 @@ describe("schema coverage integration", () => { "audit.schema.json", "shared-defs.schema.json", "invariants.schema.json", + "import-manifest.schema.json", + "plugin-execution-policy.schema.json", "test-seeds.schema.json", "test-plan.schema.json", "quality-pack.schema.json",