diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9736553a..48a9bbc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,16 +9,21 @@ permissions: contents: read jobs: - build: + build-and-test: + strategy: + fail-fast: false + matrix: + node: [20, 24] runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - - name: Set up Node.js - uses: actions/setup-node@v4 + - name: Set up Node.js ${{ matrix.node }} + uses: actions/setup-node@v7 with: - node-version: 24 + node-version: ${{ matrix.node }} + package-manager-cache: false - name: Install dependencies run: npm install @@ -26,5 +31,54 @@ jobs: - name: Build run: npm run build + - name: Typecheck + run: npm run typecheck + - name: Test run: npm test + + - name: Release readiness assessment + run: npm run release:check + + postgres-shared-state: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: synsec + POSTGRES_USER: synsec + POSTGRES_PASSWORD: synsec-ci-only + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U synsec -d synsec" + --health-interval=5s + --health-timeout=5s + --health-retries=10 + env: + SYNSEC_TEST_POSTGRES_URL: postgresql://synsec:synsec-ci-only@127.0.0.1:5432/synsec + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Node.js 24 + uses: actions/setup-node@v7 + with: + node-version: 24 + package-manager-cache: false + + - name: Install dependencies + run: npm install + + - name: Build + run: npm run build + + - name: PostgreSQL shared-state, ownership, and canonical conformance + run: node --test --test-concurrency=1 tests/postgres-shared-state.test.mjs tests/postgres-shared-state-conformance.test.mjs tests/postgres-shared-backend.test.mjs tests/postgres-lease-observer.test.mjs tests/postgres-hosted-installation-ownership.test.mjs + + - name: Enforced OCI scanner sandbox integration + run: | + OCI_IMAGE="$(docker image inspect postgres:16-alpine --format '{{index .RepoDigests 0}}')" + test -n "$OCI_IMAGE" + SYNSEC_TEST_OCI_IMAGE="$OCI_IMAGE" node --test tests/oci-scanner-sandbox.test.mjs diff --git a/README.md b/README.md index b235e0b3..791854f6 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,368 @@ # SynSec -SynSec is a repository-first security scanning platform for finding, correlating, and explaining vulnerabilities before they reach production. +SynSec is a repository-first security scanner that combines mature open-source security engines into one normalized, correlated report. -The project is designed around a simple idea: mature open-source scanners should do what they are already good at, while SynSec provides the orchestration, normalization, deduplication, repository context, remediation workflow, and developer experience around them. +Instead of replacing tools such as Opengrep, Trivy, Betterleaks, OSV-Scanner, Grype, Checkov, Syft, and OpenSSF Scorecard, SynSec runs them through a common adapter layer, merges overlapping results, preserves supporting artifacts such as SBOMs, adds repository context, tracks changes against baselines, exports developer-friendly reports, and can optionally send selected findings through an OpenAI-compatible model router for a separate review pass. -## Status +> **Current release line:** v0.2 development MVP. The repository is usable for local testing, but scanner adapters and report schemas may still change before v1.0. -Early development. +## What works now -## Initial scope +- Multi-scanner repository scans with bounded concurrency. +- Scanner failure isolation: one broken engine does not destroy the whole scan. +- Protection against false "clean" reports when no scanner successfully ran. +- Opengrep SAST integration. +- Betterleaks secret scanning, with Gitleaks retained as an optional fallback. +- OSV-Scanner dependency analysis. +- Trivy vulnerability, secret, and misconfiguration analysis. +- Grype dependency/package analysis. +- Checkov IaC analysis. +- Syft SBOM generation with normalized package, PURL, license, and location metadata. +- OpenSSF Scorecard repository-posture analysis. +- Generic SARIF 2.1 import for bringing third-party scanner findings into SynSec. +- Scanner-independent finding and artifact schemas. +- Deterministic cross-scanner correlation and deduplication. +- Repository language/framework inventory. +- Git commit, branch, and remote metadata discovery with credential redaction. +- Changed-file scan scope for pull-request and incremental workflows, with direct narrowing for supported scanners. +- Versioned JSON reports. +- Self-contained HTML security dashboard. +- SARIF 2.1.0 output for code-scanning systems. +- Baselines with new/fixed/persisting finding tracking. +- Configurable CI failure thresholds. +- Explicit opt-in AI finding review through an OpenAI-compatible endpoint. +- A seven-question AI review gate that keeps scanner evidence separate from model inference. +- Capability-scoped defensive review workflows for repository, dependency, secret, and infrastructure findings. -- Scan local repositories and Git repositories. -- Normalize findings from multiple security engines into one schema. -- Correlate duplicate findings instead of dumping raw scanner output. -- Track code vulnerabilities, vulnerable dependencies, leaked secrets, infrastructure-as-code issues, and repository security posture. -- Preserve evidence, confidence, source scanner, file/line location, CWE/CVE metadata, and remediation guidance. -- Add an AI review layer later for contextual triage and fix suggestions. +## Quick start -## Planned scanner integrations +Requirements: -SynSec will begin by integrating existing engines rather than rewriting them: +- Node.js 20 or newer (Node 24 recommended) +- npm +- at least one supported scanner binary in `PATH` -- Opengrep — static analysis / SAST -- Trivy — vulnerabilities, dependencies, containers, IaC, and secrets -- Gitleaks — secret detection and Git-history scanning -- OSV-Scanner — dependency vulnerability analysis -- Syft — SBOM generation -- Grype — package and container vulnerability analysis -- Checkov — infrastructure-as-code and CI configuration scanning -- OpenSSF Scorecard — repository security posture +```bash +git clone https://github.com/cmahmud/synsec.git +cd synsec +npm install +npm run build -Additional engines can be added through a scanner adapter interface. +# See which engines are installed +npm run synsec -- doctor . -## Repository model +# Scan a repository +npm run synsec -- scan /path/to/repository +``` + +See [`docs/INSTALL.md`](docs/INSTALL.md) for scanner installation notes. + +SynSec skips selected engines that are not installed and reports the missing coverage. If **none** of the selected engines can run, the scan fails instead of returning a misleading 100/100 score. + +A normal scan writes: + +```text +.synsec/ +├── report.json +├── report.html +└── report.sarif +``` + +The JSON report can also contain scanner artifacts such as a normalized Syft SBOM. Open `report.html` locally for the dashboard. + +## Commands + +```text +synsec init [path] +synsec doctor [path] +synsec scan [options] +synsec review [options] +synsec import-sarif [options] +synsec workflows +synsec render +synsec baseline [destination] +synsec version +``` + +Useful scan options: + +```text +--scanners opengrep,betterleaks,trivy +--parallel 3 +--timeout 900 +--changed +--changed-base main +--fail-on high +--baseline .synsec/baseline.json +--json +--no-write +``` + +Create a starter configuration with: + +```bash +npm run synsec -- init . +``` + +That creates `synsec.config.json`. + +## Default configuration + +```json +{ + "schemaVersion": 1, + "scanners": [ + "opengrep", + "betterleaks", + "osv-scanner", + "trivy", + "grype", + "checkov", + "syft", + "scorecard" + ], + "parallelism": 3, + "timeoutMs": 900000, + "failOn": "none", + "reports": { + "json": ".synsec/report.json", + "html": ".synsec/report.html", + "sarif": ".synsec/report.sarif" + }, + "ai": { + "enabled": false, + "provider": "openai-compatible", + "sendSourceContext": false + } +} +``` + +`failOn` can be `critical`, `high`, `medium`, `low`, `info`, `unknown`, or `none`. When a threshold is configured, a scan containing that severity or higher exits with code `2`, which is useful in CI. + +## Scanner engines + +| Engine | SynSec ID | Purpose | Default | +| --- | --- | --- | --- | +| Opengrep | `opengrep` | SAST / taint-aware static analysis | yes | +| Betterleaks | `betterleaks` | secrets and Git history | yes | +| Gitleaks | `gitleaks` | secrets and Git history fallback | no | +| OSV-Scanner | `osv-scanner` | open-source dependency vulnerabilities | yes | +| Trivy | `trivy` | dependencies, secrets, IaC/misconfiguration | yes | +| Grype | `grype` | package/dependency vulnerabilities | yes | +| Checkov | `checkov` | infrastructure-as-code | yes | +| Syft | `syft` | software bill of materials / package inventory | yes | +| OpenSSF Scorecard | `scorecard` | repository security posture | yes | + +Betterleaks is preferred for new installs because it is the actively developed successor maintained by the Gitleaks team. SynSec does **not** enable Betterleaks live credential validation; the adapter performs repository scanning with redacted report output only. + +Syft is an artifact-producing scanner in SynSec. It does not manufacture vulnerability findings: its package inventory is preserved as an SBOM artifact in the report and can be used by later dependency/reachability workflows. + +OpenSSF Scorecard results are treated as repository-posture findings rather than definitive vulnerabilities. Perfect 10/10 checks are not manufactured into findings; non-perfect checks retain their own score and reason as metadata. + +The engines stay separate projects with their own licenses. SynSec invokes installed binaries and parses their machine-readable output rather than copying their source into this repository. + +## Changed-file scans + +For pull-request or incremental analysis, SynSec can scope a report to files changed since a Git base ref: + +```bash +npm run synsec -- scan . --changed --changed-base main +``` + +When `--changed-base` is omitted, SynSec uses the GitHub pull-request base branch when `GITHUB_BASE_REF` is available and otherwise falls back to `HEAD~1`. + +The report records the scope and changed file list. File-located findings outside that diff are omitted, while repository-level findings that do not map to one file are retained. Opengrep and Betterleaks currently narrow execution directly to the changed files; other scanners may still perform their normal repository analysis before SynSec filters file-located results. This distinction is intentional so the report does not imply that every underlying engine has a native incremental mode. + +## Importing SARIF + +SynSec can ingest SARIF 2.1 output from another scanner and normalize it into the same finding/report model: + +```bash +npm run synsec -- import-sarif external-results.sarif --root . +``` + +By default this writes `.synsec/imported-report.json` and an adjacent HTML report. The importer preserves rule IDs, locations, severity, confidence when present, common identifiers, remediation text, source tool version, and a native partial fingerprint when supplied. + +This is an import path, not a command-execution plugin: SynSec reads the SARIF document and does not execute the producing scanner. + +## Correlation + +Raw scanner output is not the product. SynSec converts each result into a common model containing, where available: + +- category and severity; +- confidence; +- scanner and rule ID; +- file/line/column; +- CVE, CWE, GHSA, and OSV identifiers; +- evidence that is safe to retain; +- remediation guidance; +- scanner-specific metadata; +- native scanner fingerprint. + +SynSec then computes its own correlation fingerprint. This matters because two scanners often use different rule IDs, titles, and native fingerprints for the same issue. + +Current v0.2 correlation can merge: + +- dependency findings sharing advisory identifiers and package identity; +- secret findings at the same file/line without hashing or retaining the secret; +- SAST findings sharing file/line/CWE; +- conservative scanner-aware exact matches when stronger evidence is unavailable. + +The user sees one logical issue with multiple supporting sources instead of several copies of the same alert. + +## Baselines + +After a scan: + +```bash +npm run synsec -- baseline .synsec/report.json +``` + +A later scan can compare against it: + +```bash +npm run synsec -- scan . --baseline .synsec/baseline.json +``` + +The new report tracks new, fixed, and persisting findings. This makes SynSec useful as a regression detector rather than only a one-time scanner. + +## Optional AI review + +AI is a **second-pass reviewer**, not the source of truth. It is disabled by default. + +SynSec supports endpoints implementing the OpenAI-compatible `/chat/completions` shape, including local gateways and model routers. A self-hosted router or another compatible provider can therefore sit behind SynSec without tying the project to one model vendor. + +```bash +export SYNSEC_AI_BASE_URL="http://localhost:PORT/v1" +export SYNSEC_AI_MODEL="your/model-id" +export SYNSEC_AI_API_KEY="optional-key" + +npm run synsec -- scan . --ai +``` + +By default the AI reviewer receives normalized finding metadata but **not repository source code**. To explicitly allow a small bounded source excerpt around a finding: + +```bash +npm run synsec -- scan . --ai --ai-source +``` + +AI output is written separately to `.synsec/ai-review.json` so deterministic scanner evidence remains distinguishable from model inference. + +The review uses seven checks: + +1. Is there a concrete affected location? +2. Is untrusted input involved when required by the finding? +3. Is there a security-sensitive sink or invariant violation? +4. Is the path reachable rather than dead/example code? +5. Were relevant mitigations considered? +6. Is there actual scanner/code evidence? +7. Is there a specific remediation? + +Unknown evidence stays `unknown`; the reviewer is instructed not to invent proof. + +## Defensive workflows + +`npm run synsec -- workflows` lists the built-in review workflows. Current workflows are: + +- `repository-review` — broad review of normalized repository findings; +- `dependency-review` — dependencies, containers, supply chain, and license findings; +- `secrets-review` — redacted secret metadata only, with source context prohibited; +- `infrastructure-review` — IaC, configuration, and repository-posture findings. + +A workflow can be selected during AI review: + +```bash +npm run synsec -- scan . --ai --workflow dependency-review +``` + +Workflow definitions declare allowed capabilities. Repository modifications require an explicit approval boundary, and external network assessment is forbidden in these repository workflows. + +## Privacy and network behavior + +Repository contents stay local to SynSec and its local scanner processes unless the operator explicitly enables an integration or scanner behavior that communicates externally. + +Important exceptions to understand: + +- OSV-Scanner normally queries vulnerability/package services for dependency metadata unless configured for its offline mode externally. +- Opengrep's `auto` rules configuration may fetch rule configuration from the network. +- OpenSSF Scorecard can use Git hosting APIs and may need a GitHub token for complete/rate-limit-friendly results. +- AI review sends normalized finding metadata to the configured model endpoint when enabled. +- Source excerpts are only sent to the AI endpoint when `sendSourceContext` or `--ai-source` is explicitly enabled and the selected workflow permits them. + +Secret scanner output is requested with full redaction, and SynSec deliberately does not copy secret values into normalized findings. + +## Architecture ```text repository | - v -scanner adapters - | - +-- Opengrep - +-- Trivy - +-- Gitleaks - +-- ... - | - v -normalized findings - | - v -correlation / deduplication - | - v -contextual review + +--> repository inventory | - +-- dashboard - +-- CLI - +-- remediation workflow + +--> scanner adapters + | + +-- Opengrep + +-- Betterleaks / Gitleaks + +-- OSV-Scanner + +-- Trivy + +-- Grype + +-- Checkov + +-- Syft ----------> SBOM artifact + +-- OpenSSF Scorecard + | + v + normalized findings + | + v + correlation / deduplication + | + +-------+--------+ + | | + v v + reports optional AI review + JSON/HTML/SARIF + workflows + | + v + baseline diff ``` +The codebase is split into small packages: + +```text +apps/cli command-line product +packages/core domain model + correlation + artifact types +packages/config stable configuration format +packages/scanner-sdk scanner adapter/process boundary +packages/scanners built-in scanner integrations + SARIF importer +packages/repository safe repository inventory/context +packages/report JSON/SARIF/HTML + baselines + scan scope +packages/engine orchestration, incremental scope, failure isolation +packages/ai opt-in provider-agnostic review gate +packages/workflows capability-scoped defensive review workflows +``` + +See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for trust boundaries and package details and [`docs/ROADMAP.md`](docs/ROADMAP.md) for planned work. + ## Safety model -SynSec is being built primarily for defensive analysis of code and infrastructure that the operator owns or is authorized to assess. Repository scanning is the core product; external attack-surface and bug-bounty workflows are secondary and must remain explicitly authorized. +SynSec's primary job is defensive analysis of repositories the operator owns or is authorized to assess. Repository scanning does not execute the target project's application or build scripts. + +External attack-surface or bug-bounty functionality, if added later, will remain a separate explicitly authorized mode with scope controls rather than weakening the repository-first default. + +## Development + +```bash +npm install +npm run build +npm test +npm run typecheck +``` + +CI runs the build, typecheck, and test suite on Node 20 and Node 24. + +## Project status + +v0.2 is intended to be the first release worth hands-on testing. The next major work after scanner reliability is repository reachability/context, GitHub pull-request integration, stronger finding lifecycle management, fix-verification/report-writing workflows, model-routing policy, and a richer persistent web application. ## License -License has not been selected yet. +A SynSec project license has not been selected yet. Third-party scanner engines retain their own licenses and are not vendored into SynSec. diff --git a/action.yml b/action.yml new file mode 100644 index 00000000..20dd40f0 --- /dev/null +++ b/action.yml @@ -0,0 +1,74 @@ +name: SynSec Repository Security +description: Defensive repository security scanning with GitHub check annotations and optional SARIF upload. +author: SynSec +inputs: + github-token: + description: GitHub token used only for check/SARIF publication. + required: true + config-path: + description: Optional path to synsec.config.json relative to the checked-out repository. + required: false + default: "" + baseline-path: + description: Optional local SynSec baseline report, validated against the pull-request base commit. + required: false + default: "" + auto-baseline: + description: For pull requests without baseline-path, scan the exact local base commit in a detached worktree. Requires that commit to be present in the checkout (for example actions/checkout with fetch-depth 0). + required: false + default: "true" + changed-only: + description: auto (PR changed files, push full repo), true, or false. + required: false + default: auto + publish-sarif: + description: Upload the completed report to GitHub code scanning. + required: false + default: "false" +outputs: + security-score: + description: Latest SynSec security score. + value: ${{ steps.scan.outputs.security-score }} + finding-count: + description: Number of correlated findings in the completed scan. + value: ${{ steps.scan.outputs.finding-count }} + check-run-id: + description: Published GitHub check-run id. + value: ${{ steps.scan.outputs.check-run-id }} + sarif-upload-id: + description: GitHub SARIF upload id when publish-sarif is enabled. + value: ${{ steps.scan.outputs.sarif-upload-id }} + baseline-source: + description: Baseline provenance used for the scan (base-scan, file, provided, or none). + value: ${{ steps.scan.outputs.baseline-source }} + report-path: + description: Local path to the completed JSON report in RUNNER_TEMP for optional artifact retention. + value: ${{ steps.scan.outputs.report-path }} +runs: + using: composite + steps: + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: "20" + - name: Build SynSec action runtime + shell: bash + run: | + set -euo pipefail + cd "$GITHUB_ACTION_PATH" + npm install --ignore-scripts --no-audit --no-fund + npm run build + - name: Scan repository + id: scan + shell: bash + env: + SYNSEC_GITHUB_TOKEN: ${{ inputs.github-token }} + SYNSEC_CONFIG_PATH: ${{ inputs.config-path }} + SYNSEC_BASELINE_PATH: ${{ inputs.baseline-path }} + SYNSEC_AUTO_BASELINE: ${{ inputs.auto-baseline }} + SYNSEC_CHANGED_ONLY: ${{ inputs.changed-only }} + SYNSEC_PUBLISH_SARIF: ${{ inputs.publish-sarif }} + run: node "$GITHUB_ACTION_PATH/apps/github-action/dist/index.js" +branding: + icon: shield + color: blue diff --git a/apps/cli/package.json b/apps/cli/package.json index 72b19044..e8552295 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,10 +1,16 @@ { "name": "@synsec/cli", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "bin": { - "synsec": "dist/index.js" + "synsec": "dist/index.js", + "synsec-github-app": "dist/github-app-cli.js", + "synsec-github-app-provision": "dist/github-app-provision-cli.js", + "synsec-github-app-evidence": "dist/github-app-shared-state-evidence-cli.js", + "synsec-github-app-reload": "dist/github-app-credential-reload-cli.js", + "synsec-lifecycle-reviews": "dist/lifecycle-review-deadlines-cli.js", + "synsec-scanner-isolation": "dist/scanner-isolation-profile-cli.js" }, "scripts": { "build": "tsc -p tsconfig.json", @@ -12,8 +18,15 @@ "start": "node --enable-source-maps dist/index.js" }, "dependencies": { + "@synsec/ai": "0.2.0", + "@synsec/config": "0.2.0", "@synsec/core": "0.1.0", - "@synsec/scanner-sdk": "0.1.0", - "@synsec/scanners": "0.1.0" + "@synsec/engine": "0.2.0", + "@synsec/github": "0.2.0", + "@synsec/lifecycle": "0.2.0", + "@synsec/report": "0.2.0", + "@synsec/repository": "0.2.0", + "@synsec/scanners": "0.1.0", + "@synsec/workflows": "0.2.0" } } diff --git a/apps/cli/src/ai-options.ts b/apps/cli/src/ai-options.ts new file mode 100644 index 00000000..b026c158 --- /dev/null +++ b/apps/cli/src/ai-options.ts @@ -0,0 +1,80 @@ +import type { OpenAiCompatibleConfig } from "@synsec/ai"; + +const MAX_MODELS = 10; +const MAX_CONCURRENCY = 4; + +export interface AiReviewSelectionInput { + singleModel?: string; + multipleModels?: string; + configuredModel?: string; + environmentModel?: string; + baseUrl?: string; + apiKey?: string; + minimumReviewers?: number; + concurrency?: number; +} + +export interface AiReviewSelection { + mode: "single" | "consensus"; + models: string[]; + providers: OpenAiCompatibleConfig[]; + minimumReviewers: number; + concurrency: number; +} + +function cleanModel(value: string | undefined): string | undefined { + const normalized = value?.trim(); + if (!normalized) return undefined; + if (normalized.length > 200 || /[\r\n\0]/.test(normalized)) { + throw new Error("AI model id contains unsupported characters or exceeds 200 characters."); + } + return normalized; +} + +function parseMultiple(value: string): string[] { + const models = value.split(",").map((item) => cleanModel(item)).filter((item): item is string => Boolean(item)); + const unique = [...new Set(models)]; + if (unique.length < 2) throw new Error("--ai-models requires at least two unique model ids."); + if (unique.length > MAX_MODELS) throw new Error(`--ai-models supports at most ${MAX_MODELS} unique model ids.`); + return unique; +} + +function boundedInteger(value: number | undefined, fallback: number, minimum: number, maximum: number, name: string): number { + const normalized = value ?? fallback; + if (!Number.isSafeInteger(normalized) || normalized < minimum || normalized > maximum) { + throw new Error(`${name} must be an integer between ${minimum} and ${maximum}.`); + } + return normalized; +} + +export function resolveAiReviewSelection(input: AiReviewSelectionInput): AiReviewSelection { + const baseUrl = input.baseUrl?.trim(); + if (!baseUrl) throw new Error("AI review is enabled but no base URL is configured. Set SYNSEC_AI_BASE_URL or --ai-base-url."); + if (/[\r\n\0]/.test(baseUrl) || baseUrl.length > 2048) throw new Error("AI base URL is invalid."); + + const explicitSingle = cleanModel(input.singleModel); + const explicitMultiple = input.multipleModels?.trim(); + if (explicitSingle && explicitMultiple) { + throw new Error("Use either --ai-model or --ai-models, not both."); + } + + const models = explicitMultiple + ? parseMultiple(explicitMultiple) + : [explicitSingle ?? cleanModel(input.configuredModel) ?? cleanModel(input.environmentModel)].filter((item): item is string => Boolean(item)); + if (models.length === 0) { + throw new Error("AI review is enabled but no model is configured. Set SYNSEC_AI_MODEL, --ai-model, or --ai-models."); + } + + const mode = models.length > 1 ? "consensus" : "single"; + const minimumReviewers = mode === "consensus" + ? boundedInteger(input.minimumReviewers, 2, 2, models.length, "--ai-min-reviewers") + : 1; + const concurrency = mode === "consensus" + ? boundedInteger(input.concurrency, Math.min(2, models.length), 1, Math.min(MAX_CONCURRENCY, models.length), "--ai-review-concurrency") + : 1; + + const providers = models.map((model): OpenAiCompatibleConfig => input.apiKey + ? { baseUrl, model, apiKey: input.apiKey } + : { baseUrl, model }); + return { mode, models, providers, minimumReviewers, concurrency }; +} diff --git a/apps/cli/src/github-app-cli.ts b/apps/cli/src/github-app-cli.ts new file mode 100644 index 00000000..dbd525b5 --- /dev/null +++ b/apps/cli/src/github-app-cli.ts @@ -0,0 +1,309 @@ +#!/usr/bin/env node + +import { lstat, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + buildSynSecGitHubAppSetupContract, + buildSynSecGitHubAppSetupRecoveryPlan, + evaluateSynSecGitHubAppSetup, + type SynSecGitHubAppSetupOptions, +} from "@synsec/github/app-setup"; +import { + buildSynSecGitHubAppCredentialRotationPlan, + type SynSecGitHubAppCredentialRotationInput, +} from "@synsec/github/credential-rotation"; +import { + assessGitHubAppSharedStateCapabilities, + REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES, + type GitHubAppSharedStateCapabilities, +} from "@synsec/github/app-deployment"; + +const MAX_SETUP_FILE_BYTES = 256 * 1024; +const args = process.argv.slice(2); +const command = args[0] ?? "help"; + +function flag(name: string): boolean { + return args.includes(name); +} + +function setupOptions(): SynSecGitHubAppSetupOptions { + return { + publishSarif: flag("--sarif"), + enableRemediationPullRequests: flag("--remediation"), + }; +} + +function printHelp(): void { + console.log(`SynSec GitHub App setup diagnostics + +Usage: + synsec-github-app requirements [--sarif] [--remediation] [--json] + synsec-github-app evaluate [--sarif] [--remediation] [--json] [--strict] + synsec-github-app recover [--sarif] [--remediation] [--json] [--strict] + synsec-github-app rotation [--json] + synsec-github-app shared-state [--json] + +Commands: + requirements Print the minimum GitHub App permissions and webhook events for enabled features. + evaluate Compare an exported/declarative App setup with SynSec's minimum requirements. + recover Print deterministic operator actions for missing capability and least-privilege drift. + rotation Evaluate secret-free credential rotation acknowledgements before retiring an old credential. + shared-state Validate the declared transactional guarantees required for horizontal App replicas. + +Feature flags: + --sarif Require security_events:write for SARIF/code-scanning publication. + --remediation Require contents:write and pull_requests:write for operator-approved remediation PRs. + +Output flags: + --json Emit machine-readable JSON only. + --strict For evaluate/recover, exit 3 when least-privilege drift exists even if required capability is present. + +Evaluation/recovery exit codes: + 0 Required capability is present and, unless --strict is used, any extra privilege is advisory only. + 2 Required permissions or webhook events are missing. + 3 --strict was requested and extra write permissions or webhook events were detected. + +Rotation exit codes: + 0 Every required rotation acknowledgement is complete; the previous credential can be retired. + 2 One or more required rotation acknowledgements remain incomplete; keep the previous credential active. + +Shared-state exit codes: + 0 Every required transactional coordination guarantee is declared. + 2 One or more required guarantees are missing or false; do not horizontally scale the App runtime. + +The setup evaluator, recovery planner, rotation planner, and shared-state preflight are offline. They do +not contact GitHub, inspect installation tokens, read repositories, mutate App settings, reload services, +revoke keys, certify databases, or accept credential values. Runtime GitHub authorization, installation- +token permission checks, and real backend concurrency semantics remain authoritative. +`); +} + +function record(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +async function readBoundedJsonObject(path: string, label: string): Promise> { + const absolute = resolve(path); + const info = await lstat(absolute).catch(() => undefined); + if (!info || info.isSymbolicLink() || !info.isFile()) { + throw new Error(`${label} file must be a non-symlink regular file.`); + } + if (info.size > MAX_SETUP_FILE_BYTES) throw new Error(`${label} file exceeds ${MAX_SETUP_FILE_BYTES} bytes.`); + + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(absolute, "utf8")); + } catch { + throw new Error(`${label} file must contain valid JSON.`); + } + const root = record(parsed); + if (!root) throw new Error(`${label} file must contain a JSON object.`); + return root; +} + +async function readSetupFile(path: string): Promise<{ permissions: Record; events: string[] }> { + const root = await readBoundedJsonObject(path, "GitHub App setup"); + const rawPermissions = record(root.permissions); + if (!rawPermissions) throw new Error("GitHub App setup file must contain a permissions object."); + if (!Array.isArray(root.events)) throw new Error("GitHub App setup file must contain an events array."); + + const permissions: Record = {}; + for (const [name, level] of Object.entries(rawPermissions)) { + if (level !== "read" && level !== "write") throw new Error(`GitHub App permission ${name} must be read or write.`); + permissions[name] = level; + } + const events = root.events.map((value) => { + if (typeof value !== "string") throw new Error("GitHub App event names must be strings."); + return value; + }); + return { permissions, events }; +} + +async function readRotationStateFile(path: string): Promise { + const root = await readBoundedJsonObject(path, "GitHub App rotation state"); + const allowed = new Set([ + "kind", + "replacementActivated", + "runtimeReloaded", + "externalConfigurationUpdated", + "verificationSucceeded", + ]); + for (const key of Object.keys(root)) { + if (!allowed.has(key)) throw new Error(`GitHub App rotation state contains unsupported field ${key}. Credential values are not accepted.`); + } + if (root.kind !== "webhook-secret" && root.kind !== "app-private-key") { + throw new Error("GitHub App rotation state kind must be webhook-secret or app-private-key."); + } + const input: SynSecGitHubAppCredentialRotationInput = { kind: root.kind }; + for (const key of [ + "replacementActivated", + "runtimeReloaded", + "externalConfigurationUpdated", + "verificationSucceeded", + ] as const) { + const value = root[key]; + if (value === undefined) continue; + if (typeof value !== "boolean") throw new Error(`GitHub App rotation state ${key} must be boolean.`); + input[key] = value; + } + return input; +} + +async function readSharedStateCapabilitiesFile(path: string): Promise { + const root = await readBoundedJsonObject(path, "GitHub App shared-state capabilities"); + const allowed = new Set(REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES); + for (const key of Object.keys(root)) { + if (!allowed.has(key)) { + throw new Error(`GitHub App shared-state capabilities contain unsupported field ${key}. Backend credentials and connection details are not accepted.`); + } + } + + const capabilities = {} as GitHubAppSharedStateCapabilities; + for (const capability of REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES) { + const value = root[capability]; + if (value !== undefined && typeof value !== "boolean") { + throw new Error(`GitHub App shared-state capability ${capability} must be boolean.`); + } + capabilities[capability] = value === true; + } + return capabilities; +} + +function printRequirements(): void { + const contract = buildSynSecGitHubAppSetupContract(setupOptions()); + if (flag("--json")) { + console.log(JSON.stringify(contract, null, 2)); + return; + } + console.log("Required permissions:"); + for (const [permission, level] of Object.entries(contract.permissions)) console.log(` ${permission}: ${level}`); + console.log("Required webhook events:"); + for (const event of contract.events) console.log(` ${event}`); + console.log(`Remediation writes: ${contract.remediationWriteEnabled ? "enabled" : "disabled"}`); + for (const note of contract.notes) console.log(`Note: ${note}`); +} + +function applyEvaluationExitCode(input: { ready: boolean; hasLeastPrivilegeDrift: boolean }): void { + if (!input.ready) { + process.exitCode = 2; + return; + } + if (flag("--strict") && input.hasLeastPrivilegeDrift) process.exitCode = 3; +} + +async function evaluate(): Promise { + const path = args[1]; + if (!path || path.startsWith("--")) throw new Error("Usage: synsec-github-app evaluate [--sarif] [--remediation] [--json] [--strict]"); + const setup = await readSetupFile(path); + const evaluation = evaluateSynSecGitHubAppSetup({ ...setup, options: setupOptions() }); + if (flag("--json")) { + console.log(JSON.stringify(evaluation, null, 2)); + } else { + console.log(`Required capability: ${evaluation.ready ? "ready" : "missing"}`); + if (evaluation.missingPermissions.length > 0) { + console.log("Missing permissions:"); + for (const item of evaluation.missingPermissions) console.log(` ${item.permission}: required ${item.required}, actual ${item.actual ?? "absent"}`); + } + if (evaluation.missingEvents.length > 0) { + console.log("Missing webhook events:"); + for (const event of evaluation.missingEvents) console.log(` ${event}`); + } + if (evaluation.excessiveWritePermissions.length > 0) { + console.log("Least-privilege write drift:"); + for (const permission of evaluation.excessiveWritePermissions) console.log(` ${permission}: write`); + } + if (evaluation.extraEvents.length > 0) { + console.log("Unused webhook events:"); + for (const event of evaluation.extraEvents) console.log(` ${event}`); + } + console.log(`Interpretation: ${evaluation.interpretation}`); + } + applyEvaluationExitCode({ + ready: evaluation.ready, + hasLeastPrivilegeDrift: evaluation.excessiveWritePermissions.length > 0 || evaluation.extraEvents.length > 0, + }); +} + +async function recover(): Promise { + const path = args[1]; + if (!path || path.startsWith("--")) throw new Error("Usage: synsec-github-app recover [--sarif] [--remediation] [--json] [--strict]"); + const setup = await readSetupFile(path); + const plan = buildSynSecGitHubAppSetupRecoveryPlan({ ...setup, options: setupOptions() }); + if (flag("--json")) { + console.log(JSON.stringify(plan, null, 2)); + } else { + console.log(`Required capability: ${plan.ready ? "ready" : "missing"}`); + if (plan.requiredActions.length > 0) { + console.log("Required operator actions:"); + for (const action of plan.requiredActions) console.log(` - ${action}`); + } else console.log("Required operator actions: none"); + if (plan.leastPrivilegeReview.length > 0) { + console.log("Least-privilege review:"); + for (const action of plan.leastPrivilegeReview) console.log(` - ${action}`); + } else console.log("Least-privilege review: none"); + console.log(`Interpretation: ${plan.interpretation}`); + } + applyEvaluationExitCode({ ready: plan.ready, hasLeastPrivilegeDrift: plan.leastPrivilegeReview.length > 0 }); +} + +async function rotation(): Promise { + const path = args[1]; + if (!path || path.startsWith("--")) throw new Error("Usage: synsec-github-app rotation [--json]"); + const plan = buildSynSecGitHubAppCredentialRotationPlan(await readRotationStateFile(path)); + if (flag("--json")) { + console.log(JSON.stringify(plan, null, 2)); + } else { + console.log(`Credential: ${plan.kind}`); + console.log(`Previous credential retirement: ${plan.readyToRetirePrevious ? "ready" : "blocked"}`); + if (plan.completedSteps.length > 0) { + console.log("Completed acknowledgements:"); + for (const item of plan.completedSteps) console.log(` - ${item}`); + } + if (plan.requiredActions.length > 0) { + console.log("Required operator actions:"); + for (const item of plan.requiredActions) console.log(` - ${item}`); + } + console.log(`Interpretation: ${plan.interpretation}`); + } + if (!plan.readyToRetirePrevious) process.exitCode = 2; +} + +async function sharedState(): Promise { + const path = args[1]; + if (!path || path.startsWith("--")) throw new Error("Usage: synsec-github-app shared-state [--json]"); + const assessment = assessGitHubAppSharedStateCapabilities(await readSharedStateCapabilitiesFile(path)); + if (flag("--json")) { + console.log(JSON.stringify(assessment, null, 2)); + } else { + console.log(`Horizontal shared-state contract: ${assessment.complete ? "ready" : "incomplete"}`); + if (assessment.missing.length > 0) { + console.log("Missing guarantees:"); + for (const capability of assessment.missing) console.log(` - ${capability}`); + } else console.log("Missing guarantees: none"); + console.log("Interpretation: declaration-only-not-backend-certification"); + } + if (!assessment.complete) process.exitCode = 2; +} + +async function main(): Promise { + switch (command) { + case "requirements": printRequirements(); break; + case "evaluate": await evaluate(); break; + case "recover": await recover(); break; + case "rotation": await rotation(); break; + case "shared-state": await sharedState(); break; + case "help": + case "--help": + case "-h": printHelp(); break; + default: + printHelp(); + process.exitCode = 1; + } +} + +main().catch((error: unknown) => { + console.error(`SynSec GitHub App setup error: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; +}); diff --git a/apps/cli/src/github-app-credential-reload-cli.ts b/apps/cli/src/github-app-credential-reload-cli.ts new file mode 100644 index 00000000..c2f1a5d6 --- /dev/null +++ b/apps/cli/src/github-app-credential-reload-cli.ts @@ -0,0 +1,133 @@ +#!/usr/bin/env node + +import { lstat, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + assessSynSecGitHubAppCredentialReload, + type SynSecGitHubAppCredentialReloadInput, + type SynSecGitHubAppCredentialReloadReplica, +} from "@synsec/github/credential-reload"; + +const MAX_INPUT_BYTES = 256 * 1024; +const args = process.argv.slice(2); + +function record(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +async function readInput(path: string): Promise { + const absolute = resolve(path); + const info = await lstat(absolute).catch(() => undefined); + if (!info || info.isSymbolicLink() || !info.isFile()) { + throw new Error("Credential reload input must be a non-symlink regular file."); + } + if (info.size > MAX_INPUT_BYTES) throw new Error(`Credential reload input exceeds ${MAX_INPUT_BYTES} bytes.`); + + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(absolute, "utf8")); + } catch { + throw new Error("Credential reload input must contain valid JSON."); + } + + const root = record(parsed); + if (!root) throw new Error("Credential reload input must contain a JSON object."); + const allowed = new Set(["kind", "targetGeneration", "expectedReplicaIds", "replicas"]); + for (const key of Object.keys(root)) { + if (!allowed.has(key)) { + throw new Error(`Credential reload input contains unsupported field ${key}. Credential values are not accepted.`); + } + } + + if (root.kind !== "webhook-secret" && root.kind !== "app-private-key") { + throw new Error("Credential reload kind must be webhook-secret or app-private-key."); + } + if (typeof root.targetGeneration !== "string") throw new Error("targetGeneration must be a string identifier."); + if (!Array.isArray(root.expectedReplicaIds)) throw new Error("expectedReplicaIds must be an array."); + for (const replicaId of root.expectedReplicaIds) { + if (typeof replicaId !== "string") throw new Error("Every expectedReplicaId must be a string identifier."); + } + if (!Array.isArray(root.replicas)) throw new Error("replicas must be an array."); + + const replicas: SynSecGitHubAppCredentialReloadReplica[] = root.replicas.map((raw) => { + const replica = record(raw); + if (!replica) throw new Error("Every replica observation must be an object."); + const replicaAllowed = new Set(["replicaId", "loadedGeneration", "ready"]); + for (const key of Object.keys(replica)) { + if (!replicaAllowed.has(key)) { + throw new Error(`Replica observation contains unsupported field ${key}. Credential values are not accepted.`); + } + } + if (typeof replica.replicaId !== "string") throw new Error("replicaId must be a string identifier."); + if (typeof replica.loadedGeneration !== "string") throw new Error("loadedGeneration must be a string identifier."); + if (typeof replica.ready !== "boolean") throw new Error("replica.ready must be boolean."); + return { + replicaId: replica.replicaId, + loadedGeneration: replica.loadedGeneration, + ready: replica.ready, + }; + }); + + return { + kind: root.kind, + targetGeneration: root.targetGeneration, + expectedReplicaIds: root.expectedReplicaIds, + replicas, + }; +} + +function printHelp(): void { + console.log(`SynSec GitHub App credential reload verification + +Usage: + synsec-github-app-reload [--json] + +Exit codes: + 0 Every specifically expected replica is ready on the exact target configuration generation. + 2 The deployment reload is incomplete, stale, missing required replicas, or contains unexpected observations. + 1 Input or CLI usage is invalid. + +The input is credential-free deployment metadata. This command does not read credential values, +contact GitHub, inspect a secret manager, reload services, or revoke credentials. +`); +} + +async function main(): Promise { + if (args.includes("--help") || args.includes("-h")) { + printHelp(); + return; + } + const path = args[0]; + if (!path || path.startsWith("--")) { + printHelp(); + process.exitCode = 1; + return; + } + const supported = new Set([path, "--json"]); + for (const arg of args) { + if (!supported.has(arg)) throw new Error("Unsupported credential reload CLI option."); + } + + const assessment = assessSynSecGitHubAppCredentialReload(await readInput(path)); + if (args.includes("--json")) { + console.log(JSON.stringify(assessment, null, 2)); + } else { + console.log(`Credential: ${assessment.kind}`); + console.log(`Target generation: ${assessment.targetGeneration}`); + console.log(`Reload state: ${assessment.complete ? "complete" : "incomplete"}`); + console.log(`Matched expected replicas: ${assessment.matchedReplicaCount}/${assessment.expectedReplicaCount}`); + console.log(`Stale expected replicas: ${assessment.staleReplicaCount}`); + console.log(`Unready expected replicas: ${assessment.unreadyReplicaCount}`); + console.log(`Missing expected replicas: ${assessment.missingReplicaCount}`); + console.log(`Unexpected replicas: ${assessment.unexpectedReplicaCount}`); + console.log(`Interpretation: ${assessment.interpretation}`); + } + if (!assessment.complete) process.exitCode = 2; +} + +main().catch((error: unknown) => { + console.error(`SynSec credential reload error: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; +}); diff --git a/apps/cli/src/github-app-provision-cli.ts b/apps/cli/src/github-app-provision-cli.ts new file mode 100644 index 00000000..597fb1cd --- /dev/null +++ b/apps/cli/src/github-app-provision-cli.ts @@ -0,0 +1,127 @@ +#!/usr/bin/env node + +import { lstat, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + buildSynSecGitHubAppManifest, + createSynSecGitHubAppManifestRegistration, + type SynSecGitHubAppManifestOptions, +} from "@synsec/github/app-provisioning"; + +const MAX_CONFIG_BYTES = 64 * 1024; +const args = process.argv.slice(2); + +function usage(): string { + return "Usage: synsec-github-app-provision [--json]"; +} + +function record(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +function optionalString(root: Record, key: string): string | undefined { + const value = root[key]; + if (value === undefined) return undefined; + if (typeof value !== "string") throw new Error(`${key} must be a string.`); + return value; +} + +function optionalBoolean(root: Record, key: string): boolean | undefined { + const value = root[key]; + if (value === undefined) return undefined; + if (typeof value !== "boolean") throw new Error(`${key} must be boolean.`); + return value; +} + +async function readConfig(path: string): Promise<{ + options: SynSecGitHubAppManifestOptions; + organization?: string; +}> { + const absolute = resolve(path); + const info = await lstat(absolute).catch(() => undefined); + if (!info || info.isSymbolicLink() || !info.isFile()) { + throw new Error("GitHub App provisioning config must be a non-symlink regular file."); + } + if (info.size > MAX_CONFIG_BYTES) throw new Error(`GitHub App provisioning config exceeds ${MAX_CONFIG_BYTES} bytes.`); + + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(absolute, "utf8")); + } catch { + throw new Error("GitHub App provisioning config must contain valid JSON."); + } + const root = record(parsed); + if (!root) throw new Error("GitHub App provisioning config must contain a JSON object."); + + const allowed = new Set([ + "homepageUrl", + "webhookUrl", + "redirectUrl", + "setupUrl", + "name", + "description", + "public", + "setupOnUpdate", + "publishSarif", + "enableRemediationPullRequests", + "organization", + ]); + for (const key of Object.keys(root)) { + if (!allowed.has(key)) { + throw new Error(`GitHub App provisioning config contains unsupported field ${key}. Credentials and secrets are not accepted.`); + } + } + + for (const key of ["homepageUrl", "webhookUrl", "redirectUrl"] as const) { + if (typeof root[key] !== "string") throw new Error(`${key} is required and must be a string.`); + } + + return { + options: { + homepageUrl: root.homepageUrl as string, + webhookUrl: root.webhookUrl as string, + redirectUrl: root.redirectUrl as string, + ...(optionalString(root, "setupUrl") ? { setupUrl: optionalString(root, "setupUrl") } : {}), + ...(optionalString(root, "name") ? { name: optionalString(root, "name") } : {}), + ...(optionalString(root, "description") ? { description: optionalString(root, "description") } : {}), + ...(optionalBoolean(root, "public") !== undefined ? { public: optionalBoolean(root, "public") } : {}), + ...(optionalBoolean(root, "setupOnUpdate") !== undefined ? { setupOnUpdate: optionalBoolean(root, "setupOnUpdate") } : {}), + ...(optionalBoolean(root, "publishSarif") !== undefined ? { publishSarif: optionalBoolean(root, "publishSarif") } : {}), + ...(optionalBoolean(root, "enableRemediationPullRequests") !== undefined + ? { enableRemediationPullRequests: optionalBoolean(root, "enableRemediationPullRequests") } + : {}), + }, + ...(optionalString(root, "organization") ? { organization: optionalString(root, "organization") } : {}), + }; +} + +async function main(): Promise { + const path = args[0]; + if (!path || path.startsWith("--")) throw new Error(usage()); + const config = await readConfig(path); + const manifest = buildSynSecGitHubAppManifest(config.options); + const registration = createSynSecGitHubAppManifestRegistration({ + manifest, + ...(config.organization ? { organization: config.organization } : {}), + }); + + if (args.includes("--json")) { + console.log(JSON.stringify(registration, null, 2)); + return; + } + + console.log(`Registration method: ${registration.method}`); + console.log(`Registration endpoint: ${registration.action}`); + console.log(`CSRF state: ${registration.fields.state}`); + console.log("Manifest:"); + console.log(JSON.stringify(manifest, null, 2)); + console.log("Interpretation: registration request generated; GitHub App creation and manifest conversion are not yet complete."); + console.log("Store the state in short-lived server-side session state and verify it on the manifest callback before conversion."); +} + +main().catch((error: unknown) => { + console.error(`SynSec GitHub App provisioning error: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; +}); diff --git a/apps/cli/src/github-app-shared-state-evidence-cli.ts b/apps/cli/src/github-app-shared-state-evidence-cli.ts new file mode 100644 index 00000000..0e7550ee --- /dev/null +++ b/apps/cli/src/github-app-shared-state-evidence-cli.ts @@ -0,0 +1,94 @@ +#!/usr/bin/env node + +import { readFile, stat } from "node:fs/promises"; +import { resolve } from "node:path"; +import { assessGitHubAppSharedStateConformanceEvidence } from "@synsec/github/shared-state-evidence"; + +const MAX_EVIDENCE_FILE_BYTES = 1024 * 1024; +const args = process.argv.slice(2); + +function flag(name: string): boolean { + return args.includes(name); +} + +function printHelp(): void { + console.log(`SynSec GitHub App shared-state conformance evidence gate + +Usage: + synsec-github-app-evidence [--json] + +Exit codes: + 0 Evidence is structurally valid, complete, and bound to the exact backend implementation. + 2 Evidence is valid JSON but is not sufficient to approve horizontal shared-state readiness. + 1 Input usage, file, size, JSON parsing, or unsupported arguments failed. + +This command is offline and credential-free. It does not connect to a database, contact GitHub, +certify a backend, or accept connection strings. A ready result only means the supplied versioned +backend contract and portable conformance artifact pass SynSec's evidence-binding checks. +`); +} + +async function readBoundedJson(path: string, label: string): Promise { + const absolute = resolve(path); + const info = await stat(absolute).catch(() => undefined); + if (!info?.isFile()) throw new Error(`${label} is not a regular file: ${absolute}`); + if (info.size > MAX_EVIDENCE_FILE_BYTES) { + throw new Error(`${label} exceeds ${MAX_EVIDENCE_FILE_BYTES} bytes.`); + } + + try { + return JSON.parse(await readFile(absolute, "utf8")); + } catch { + throw new Error(`${label} must contain valid JSON.`); + } +} + +async function main(): Promise { + if (args.length === 0 || flag("--help") || flag("-h")) { + printHelp(); + return; + } + + const positional = args.filter((arg) => !arg.startsWith("--")); + const options = args.filter((arg) => arg.startsWith("--")); + const unsupported = options.filter((arg) => arg !== "--json"); + if (unsupported.length > 0 || positional.length !== 2 || options.filter((arg) => arg === "--json").length > 1) { + throw new Error("Usage: synsec-github-app-evidence [--json]"); + } + + const contractPath = positional[0]; + const reportPath = positional[1]; + if (!contractPath || !reportPath) { + throw new Error("Usage: synsec-github-app-evidence [--json]"); + } + + const [contract, report] = await Promise.all([ + readBoundedJson(contractPath, "Shared-state backend contract"), + readBoundedJson(reportPath, "Shared-state conformance report"), + ]); + const assessment = assessGitHubAppSharedStateConformanceEvidence(contract, report); + + if (flag("--json")) { + console.log(JSON.stringify(assessment, null, 2)); + } else { + console.log(`Shared-state evidence: ${assessment.ready ? "ready" : "blocked"}`); + if (assessment.issues.length > 0) { + console.log("Issues:"); + for (const issue of assessment.issues) console.log(` - ${issue.code}: ${issue.message}`); + } else { + console.log("Issues: none"); + } + if (assessment.missingScenarioIds.length > 0) { + console.log("Missing conformance scenarios:"); + for (const id of assessment.missingScenarioIds) console.log(` - ${id}`); + } + console.log("Interpretation: portable-evidence-binding-not-database-certification"); + } + + if (!assessment.ready) process.exitCode = 2; +} + +main().catch((error: unknown) => { + console.error(`SynSec shared-state evidence error: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; +}); diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 421174ea..c94c3d2a 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,118 +1,620 @@ #!/usr/bin/env node -import { stat } from "node:fs/promises"; -import { resolve } from "node:path"; -import { correlateFindings, type Finding } from "@synsec/core"; -import { builtInScanners } from "@synsec/scanners"; +import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { reviewFinding, type AiFindingReview } from "@synsec/ai"; +import { reviewFindingWithConsensus, type MultiReviewConsensusResult } from "@synsec/ai/consensus"; +import { + loadConfig, + resolveReportPaths, + SYNSEC_CONFIG_FILENAME, + writeDefaultConfig, + type SynSecConfig, +} from "@synsec/config"; +import type { CorrelatedFinding } from "@synsec/core"; +import { runScanEngine, scannerStatuses } from "@synsec/engine"; +import { + buildReport, + readReport, + renderHtml, + toSarif, + writeHtml, + writeReport, + writeSarif, + type SynSecReport, +} from "@synsec/report"; +import { writeMarkdown } from "@synsec/report/markdown"; +import { getFindingContext } from "@synsec/repository"; +import { writeRepositoryIndex } from "@synsec/repository/analysis"; +import { parseSarifJson } from "@synsec/scanners"; +import { + assertWorkflowSourceContextAllowed, + builtInWorkflows, + getWorkflow, + workflowFindings, + type WorkflowDefinition, +} from "@synsec/workflows"; +import { resolveAiReviewSelection } from "./ai-options.js"; +import { reconcileLifecycleFile, runTriage, runVerification } from "./lifecycle.js"; +const VERSION = "0.2.0"; const args = process.argv.slice(2); const command = args[0] ?? "help"; +type AiReviewValue = AiFindingReview | MultiReviewConsensusResult; + +interface AiReviewBundle { + mode: "single" | "consensus"; + models: string[]; + reviews: Record; +} + +function option(name: string): string | undefined { + const index = args.indexOf(name); + if (index >= 0) return args[index + 1]; + const prefix = `${name}=`; + const inline = args.find((value) => value.startsWith(prefix)); + return inline?.slice(prefix.length); +} + +function flag(name: string): boolean { + return args.includes(name); +} + +function integerOption(name: string): number | undefined { + const raw = option(name); + if (raw === undefined) return undefined; + const value = Number.parseInt(raw, 10); + if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be a positive integer.`); + return value; +} + +function severityOption(name: string): SynSecConfig["failOn"] | undefined { + const value = option(name); + if (value === undefined) return undefined; + if ( + value === "critical" || + value === "high" || + value === "medium" || + value === "low" || + value === "info" || + value === "unknown" || + value === "none" + ) return value; + throw new Error(`${name} must be one of critical, high, medium, low, info, unknown, none.`); +} + +function workflowOption(): WorkflowDefinition | undefined { + const id = option("--workflow"); + if (!id) return undefined; + const workflow = getWorkflow(id); + if (!workflow) { + throw new Error( + `Unknown workflow ${id}. Available workflows: ${builtInWorkflows().map((item) => item.id).join(", ")}`, + ); + } + return workflow; +} + function printHelp(): void { - console.log(`SynSec v0.1.0 + console.log(`SynSec v${VERSION} — repository-first security scanning Usage: - synsec doctor - synsec scan [--json] + synsec init [path] + synsec doctor [path] [--config ] + synsec scan [options] + synsec review [options] + synsec triage --list [--store ] + synsec triage [--note ] [--store ] + synsec verify [--fingerprint ] [--output ] + synsec import-sarif [options] + synsec workflows + synsec render [--html ] [--sarif ] [--markdown ] + synsec baseline [destination] + synsec version + +Scan options: + --config Use an explicit synsec.config.json. + --scanners Override enabled scanners for this run. + --parallel Maximum scanners running at once. + --timeout Per-scanner timeout. + --changed Keep findings in files changed since a Git base ref. + --changed-base Base ref for --changed (default: PR base or HEAD~1). + --fail-on Exit non-zero when this severity or higher is found. + --baseline Compare against a previous SynSec report. + --json Print the report JSON to stdout. + --no-write Do not write reports, lifecycle state, or the repository index. + --ai Run optional AI triage after deterministic scanning. + --workflow Restrict AI triage to a built-in defensive workflow. + --ai-source Allow source excerpts when the selected workflow permits it. + --ai-limit Maximum findings to review (default: 10). + --ai-base-url OpenAI-compatible API base URL. + --ai-model Single model ID for AI triage. + --ai-models Two to ten unique model IDs for consensus review. + --ai-min-reviewers Minimum successful reviewers required for consensus (default: 2). + --ai-review-concurrency Concurrent model reviews, maximum 4 (default: 2). + +Review options: + --root Repository root when it differs from the saved report path. + --output AI review output path. + --workflow Restrict review to a built-in defensive workflow. + --ai-source Allow bounded source excerpts when the workflow permits it. + --ai-limit Maximum findings to review. + --ai-base-url OpenAI-compatible API base URL. + --ai-model Single model ID. + --ai-models Two to ten unique model IDs for consensus review. + --ai-min-reviewers Minimum successful reviewers required for consensus (default: 2). + --ai-review-concurrency Concurrent model reviews, maximum 4 (default: 2). + +Triage states: + new, confirmed, false-positive, accepted-risk, fixed, regressed -Commands: - doctor Show which scanner engines are available. - scan Scan a local repository with all available built-in scanners. +Verify options: + --fingerprint Verify one finding fingerprint. + --fingerprints Verify a specific set of finding fingerprints. + --output Write machine-readable verification JSON. + +SARIF import options: + --root Repository root represented by the imported findings (default: .). + --output SynSec JSON report path (default: .synsec/imported-report.json). + --html HTML report path (default: next to the JSON report). + --scanner Override the source scanner name for all imported findings. + +AI environment variables: + SYNSEC_AI_BASE_URL + SYNSEC_AI_API_KEY + SYNSEC_AI_MODEL + +SynSec never enables AI review by default. Source excerpts are only sent when +sendSourceContext is enabled in config or --ai-source is explicitly supplied. +Workflow capability rules can further prohibit source context. Multi-model consensus +is model inference only and is never promoted to deterministic scanner evidence. `); } +async function ensureDirectory(path: string): Promise { + const root = resolve(path); + const info = await stat(root).catch(() => undefined); + if (!info?.isDirectory()) throw new Error(`Not a directory: ${root}`); + return root; +} + +async function configFor(root: string): Promise<{ config: SynSecConfig; path?: string }> { + const loaded = await loadConfig(root, option("--config")); + const config = structuredClone(loaded.config); + + const scanners = option("--scanners"); + if (scanners) config.scanners = scanners.split(",").map((value) => value.trim()).filter(Boolean); + + const parallelism = integerOption("--parallel"); + if (parallelism) config.parallelism = parallelism; + + const timeoutSeconds = integerOption("--timeout"); + if (timeoutSeconds) config.timeoutMs = timeoutSeconds * 1000; + + const failOn = severityOption("--fail-on"); + if (failOn) config.failOn = failOn; + + if (flag("--ai")) config.ai.enabled = true; + if (flag("--ai-source")) config.ai.sendSourceContext = true; + const baseUrl = option("--ai-base-url"); + if (baseUrl) config.ai.baseUrl = baseUrl; + const model = option("--ai-model"); + if (model) config.ai.model = model; + + return loaded.path ? { config, path: loaded.path } : { config }; +} + +async function init(): Promise { + const root = await ensureDirectory(args[1] && !args[1].startsWith("--") ? args[1] : "."); + const path = resolve(root, SYNSEC_CONFIG_FILENAME); + try { + await writeDefaultConfig(path); + } catch (error) { + const code = typeof error === "object" && error !== null && "code" in error ? String((error as { code?: unknown }).code) : ""; + if (code === "EEXIST") throw new Error(`${path} already exists.`); + throw error; + } + console.log(`Created ${path}`); +} + async function doctor(): Promise { - console.log("SynSec scanner availability\n"); + const root = await ensureDirectory(args[1] && !args[1].startsWith("--") ? args[1] : "."); + const { config, path } = await configFor(root); + console.log(`SynSec v${VERSION}`); + console.log(`Config: ${path ?? "defaults"}`); + console.log(`Parallelism: ${config.parallelism}\n`); - for (const scanner of builtInScanners()) { - const status = await scanner.checkAvailability(); - const marker = status.available ? "OK" : "MISSING"; - const detail = status.version ?? status.reason ?? ""; - console.log(`${marker.padEnd(8)} ${scanner.displayName.padEnd(18)} ${detail}`); + const statuses = await scannerStatuses(config); + for (const status of statuses) { + const marker = !status.selected ? "DISABLED" : status.availability.available ? "OK" : "MISSING"; + const detail = status.availability.version ?? status.availability.reason ?? ""; + console.log(`${marker.padEnd(9)} ${status.displayName.padEnd(20)} ${detail}`); } + + console.log("\nAI review:"); + console.log(` ${config.ai.enabled ? "enabled" : "disabled"} (source context ${config.ai.sendSourceContext ? "allowed" : "not allowed"})`); } -function printFinding(finding: Finding): void { +function listWorkflows(): void { + console.log("SynSec defensive workflows\n"); + for (const workflow of builtInWorkflows()) { + const categories = workflow.categories === "all" ? "all findings" : workflow.categories.join(", "); + console.log(`${workflow.id}`); + console.log(` ${workflow.description}`); + console.log(` categories: ${categories}`); + console.log(` source context: ${workflow.sourceContextAllowed ? "may be explicitly enabled" : "prohibited"}`); + console.log(` external network assessment: ${workflow.externalNetworkAssessment}\n`); + } +} + +function printFinding(group: CorrelatedFinding): void { + const finding = group.primary; const location = finding.location ? `${finding.location.path}${finding.location.startLine ? `:${finding.location.startLine}` : ""}` : "repository"; - + const sources = group.sources.map((source) => source.name).join(", "); console.log(`[${finding.severity.toUpperCase()}] ${finding.title}`); console.log(` ${location}`); - console.log(` source: ${finding.scanner.name}${finding.scanner.ruleId ? ` / ${finding.scanner.ruleId}` : ""}`); + console.log(` sources: ${sources}`); if (finding.remediation) console.log(` fix: ${finding.remediation}`); console.log(""); } +async function reviewGroups( + report: SynSecReport, + root: string, + config: SynSecConfig, + limit: number, + workflow?: WorkflowDefinition, +): Promise { + if (workflow) assertWorkflowSourceContextAllowed(workflow, config.ai.sendSourceContext); + const reviews: Record = {}; + const eligible = workflow ? workflowFindings(report.findings, workflow) : report.findings; + const candidates = eligible.slice(0, limit); + if (candidates.length === 0) return { mode: "single", models: [], reviews }; + + const selection = resolveAiReviewSelection({ + singleModel: option("--ai-model"), + multipleModels: option("--ai-models"), + configuredModel: config.ai.model, + environmentModel: process.env.SYNSEC_AI_MODEL, + baseUrl: config.ai.baseUrl ?? process.env.SYNSEC_AI_BASE_URL, + apiKey: process.env.SYNSEC_AI_API_KEY, + minimumReviewers: integerOption("--ai-min-reviewers"), + concurrency: integerOption("--ai-review-concurrency"), + }); + const singleProvider = selection.mode === "single" ? selection.providers[0] : undefined; + if (selection.mode === "single" && !singleProvider) throw new Error("AI review model selection produced no provider."); + + for (let index = 0; index < candidates.length; index += 1) { + const group = candidates[index]; + if (!group) continue; + const workflowLabel = workflow ? ` [${workflow.id}]` : ""; + const modeLabel = selection.mode === "consensus" ? ` consensus (${selection.models.length} models)` : ""; + console.error(`AI${modeLabel} review${workflowLabel} ${index + 1}/${candidates.length}: ${group.primary.title}`); + const context = config.ai.sendSourceContext + ? await getFindingContext(root, group.primary) + : undefined; + if (selection.mode === "single") { + reviews[group.fingerprint] = await reviewFinding( + group.primary, + singleProvider as NonNullable, + context, + workflow?.reviewInstructions, + ); + } else { + reviews[group.fingerprint] = await reviewFindingWithConsensus( + group.primary, + selection.providers, + context, + workflow?.reviewInstructions, + { + minimumReviewers: selection.minimumReviewers, + concurrency: selection.concurrency, + }, + ); + } + } + return { mode: selection.mode, models: selection.models, reviews }; +} + +async function writeAiReviews( + path: string, + report: SynSecReport, + bundle: AiReviewBundle, + workflow?: WorkflowDefinition, +): Promise { + await mkdir(dirname(path), { recursive: true }); + const base = { + reportId: report.reportId, + generatedAt: new Date().toISOString(), + workflow: workflow ? { id: workflow.id, version: workflow.version } : null, + }; + const payload = bundle.mode === "consensus" + ? { + schemaVersion: 2, + ...base, + reviewMode: "consensus", + models: bundle.models, + interpretation: "model-consensus-not-scanner-evidence", + reviews: bundle.reviews, + } + : { + schemaVersion: 1, + ...base, + reviews: bundle.reviews, + }; + await writeFile(path, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); +} + async function scan(): Promise { const targetArg = args[1]; - if (!targetArg || targetArg.startsWith("--")) { - throw new Error("Usage: synsec scan [--json]"); + if (!targetArg || targetArg.startsWith("--")) throw new Error("Usage: synsec scan [options]"); + const root = await ensureDirectory(targetArg); + const { config, path: configPath } = await configFor(root); + + const baselinePath = option("--baseline") ?? config.baseline; + const baseline = baselinePath ? await readReport(resolve(root, baselinePath)) : undefined; + + if (!flag("--json")) { + console.error(`SynSec v${VERSION}`); + console.error(`Target: ${root}`); + console.error(`Config: ${configPath ?? "defaults"}`); + console.error(`Scanners: ${config.scanners.join(", ")}\n`); } - const targetPath = resolve(targetArg); - const info = await stat(targetPath).catch(() => undefined); - if (!info?.isDirectory()) { - throw new Error(`Scan target is not a directory: ${targetPath}`); + const outcome = await runScanEngine({ + rootPath: root, + config, + baseline, + toolVersion: VERSION, + changedOnly: flag("--changed"), + changedBase: option("--changed-base"), + }); + + const paths = resolveReportPaths(root, config); + const repositoryIndexPath = resolve(root, ".synsec/repository-index.json"); + const persist = !flag("--no-write"); + const lifecycle = await reconcileLifecycleFile(outcome.report, root, persist); + + if (persist) { + await Promise.all([ + writeReport(paths.json, outcome.report), + writeHtml(paths.html, outcome.report), + writeSarif(paths.sarif, outcome.report), + writeMarkdown(paths.markdown, outcome.report), + writeRepositoryIndex(repositoryIndexPath, outcome.repositoryIndex), + ]); } - const scanners = builtInScanners(); - const available = []; + if (config.ai.enabled) { + const limit = integerOption("--ai-limit") ?? 10; + const workflow = workflowOption(); + const reviewBundle = await reviewGroups(outcome.report, root, config, limit, workflow); + const aiPath = resolve(root, ".synsec/ai-review.json"); + await writeAiReviews(aiPath, outcome.report, reviewBundle, workflow); + if (!flag("--json")) console.error(`AI reviews: ${aiPath}`); + } else if (option("--workflow")) { + throw new Error("--workflow is an AI review option. Enable review with --ai or in synsec.config.json."); + } - for (const scanner of scanners) { - const status = await scanner.checkAvailability(); - if (status.available) { - available.push(scanner); - } else if (!args.includes("--json")) { - console.error(`Skipping ${scanner.displayName}: ${status.reason ?? "not installed"}`); + if (flag("--json")) { + process.stdout.write(`${JSON.stringify(outcome.report, null, 2)}\n`); + } else { + console.log(`Security score: ${outcome.report.securityScore}/100`); + console.log( + `Findings: ${outcome.report.findingCount} correlated (${outcome.report.rawFindingCount} raw) — ` + + `${outcome.report.summary.critical} critical, ${outcome.report.summary.high} high, ` + + `${outcome.report.summary.medium} medium, ${outcome.report.summary.low} low\n`, + ); + console.log( + `Lifecycle: ${lifecycle.summary.new} new, ${lifecycle.summary.confirmed} confirmed, ` + + `${lifecycle.summary.falsePositive} false positive, ${lifecycle.summary.acceptedRisk} accepted risk, ` + + `${lifecycle.summary.fixed} fixed, ${lifecycle.summary.regressed} regressed\n`, + ); + if (outcome.changedFiles) { + console.log(`Changed-file scope: ${outcome.changedFiles.length} file(s) since ${outcome.changedBase ?? "base"}\n`); } - } + const sbomPackages = (outcome.report.artifacts ?? []) + .filter((artifact) => artifact.type === "sbom") + .reduce((total, artifact) => total + artifact.packageCount, 0); + if (sbomPackages > 0) console.log(`SBOM: ${sbomPackages} package(s) inventoried\n`); + console.log( + `Repository index: ${outcome.repositoryIndex.indexedFileCount} file(s), ` + + `${outcome.repositoryIndex.moduleEdges.length} module edge(s), ${outcome.repositoryIndex.routes.length} route signal(s), ` + + `${outcome.repositoryIndex.authSignals.length} auth signal(s), ${outcome.repositoryIndex.sinks.length} sink signal(s)\n`, + ); + + if (outcome.report.baseline) { + console.log( + `Since baseline: ${outcome.report.baseline.new.length} new, ${outcome.report.baseline.fixed.length} fixed, ${outcome.report.baseline.persisting.length} persisting\n`, + ); + } + + for (const group of outcome.report.findings) printFinding(group); - if (available.length === 0) { - throw new Error("No supported scanner engines are available. Run `synsec doctor` for details."); + for (const failure of outcome.failures) { + console.error(`Scanner failed: ${failure.scanner}: ${failure.message}`); + } + const missing = outcome.statuses.filter((status) => status.selected && !status.availability.available); + for (const status of missing) { + console.error(`Scanner unavailable: ${status.displayName}: ${status.availability.reason ?? "not installed"}`); + } + + if (persist) { + console.log(`JSON: ${paths.json}`); + console.log(`HTML: ${paths.html}`); + console.log(`SARIF: ${paths.sarif}`); + console.log(`MARKDOWN: ${paths.markdown}`); + console.log(`INDEX: ${repositoryIndexPath}`); + console.log(`LIFECYCLE: ${lifecycle.path}`); + } } - const findings: Finding[] = []; - const scans = []; + if (outcome.shouldFail) process.exitCode = 2; +} - for (const scanner of available) { - if (!args.includes("--json")) console.error(`Running ${scanner.displayName}...`); - const result = await scanner.scan({ target: { path: targetPath } }); - scans.push(result); - findings.push(...result.findings); +async function review(): Promise { + const reportArg = args[1]; + if (!reportArg || reportArg.startsWith("--")) throw new Error("Usage: synsec review [options]"); + const reportPath = resolve(reportArg); + const report = await readReport(reportPath); + const root = await ensureDirectory(option("--root") ?? report.target.path); + const { config } = await configFor(root); + config.ai.enabled = true; + if (flag("--ai-source")) config.ai.sendSourceContext = true; + const baseUrl = option("--ai-base-url"); + if (baseUrl) config.ai.baseUrl = baseUrl; + const model = option("--ai-model"); + if (model) config.ai.model = model; + const workflow = workflowOption(); + const limit = integerOption("--ai-limit") ?? report.findings.length; + const reviewBundle = await reviewGroups(report, root, config, limit, workflow); + const explicitOutput = option("--output"); + const outputPath = explicitOutput ? resolve(explicitOutput) : resolve(dirname(reportPath), "ai-review.json"); + await writeAiReviews(outputPath, report, reviewBundle, workflow); + const label = reviewBundle.mode === "consensus" ? "AI consensus review" : "AI review"; + console.log(`Wrote ${Object.keys(reviewBundle.reviews).length} ${label}(s) to ${outputPath}`); +} + +async function triage(): Promise { + const reportArg = args[1]; + if (!reportArg || reportArg.startsWith("--")) { + throw new Error("Usage: synsec triage --list or synsec triage [--note ] [--store ]"); } + const lines = await runTriage({ + reportPath: reportArg, + fingerprint: args[2] && !args[2].startsWith("--") ? args[2] : undefined, + state: args[3] && !args[3].startsWith("--") ? args[3] : undefined, + note: option("--note"), + storePath: option("--store"), + listOnly: flag("--list"), + }); + for (const line of lines) console.log(line); +} - const correlated = correlateFindings(findings); +async function verify(): Promise { + const beforeArg = args[1]; + const afterArg = args[2]; + if (!beforeArg || beforeArg.startsWith("--") || !afterArg || afterArg.startsWith("--")) { + throw new Error("Usage: synsec verify [--fingerprint ] [--fingerprints ] [--output ]"); + } + const requested = [ + ...(option("--fingerprint") ? [option("--fingerprint") as string] : []), + ...(option("--fingerprints")?.split(",").map((value) => value.trim()).filter(Boolean) ?? []), + ]; + const result = await runVerification({ + beforeReportPath: beforeArg, + afterReportPath: afterArg, + fingerprints: requested.length > 0 ? requested : undefined, + outputPath: option("--output"), + }); + for (const line of result.lines) console.log(line); + if (result.verification.summary.persisting > 0) process.exitCode = 2; + else if (result.verification.summary.inconclusive > 0 || result.verification.summary.missingBaseline > 0) process.exitCode = 3; +} - if (args.includes("--json")) { - console.log( - JSON.stringify( - { - target: targetPath, - scanners: scans.map((result) => result.scanner), - rawFindingCount: findings.length, - correlatedFindingCount: correlated.length, - findings: correlated, - }, - null, - 2, - ), - ); - return; +async function importSarif(): Promise { + const inputArg = args[1]; + if (!inputArg || inputArg.startsWith("--")) { + throw new Error("Usage: synsec import-sarif [--root ] [--output ] [--scanner ]"); } + const inputPath = resolve(inputArg); + const root = await ensureDirectory(option("--root") ?? "."); + const raw = await readFile(inputPath, "utf8"); + const scannerOverride = option("--scanner"); + const findings = parseSarifJson(raw, scannerOverride); + const now = new Date().toISOString(); + const report = buildReport({ + target: { path: root }, + scans: [{ + scanner: scannerOverride ?? "sarif-import", + startedAt: now, + completedAt: now, + target: { path: root }, + findings, + diagnostics: [], + }], + toolVersion: VERSION, + }); + + const outputPath = resolve(option("--output") ?? resolve(root, ".synsec/imported-report.json")); + const htmlPath = resolve(option("--html") ?? outputPath.replace(/\.json$/i, ".html")); + await Promise.all([ + writeReport(outputPath, report), + writeHtml(htmlPath, report), + ]); + console.log(`Imported ${findings.length} SARIF finding(s).`); + console.log(`JSON: ${outputPath}`); + console.log(`HTML: ${htmlPath}`); +} - console.log(`\n${correlated.length} correlated finding(s) (${findings.length} raw)\n`); - for (const finding of correlated) printFinding(finding.primary); +async function render(): Promise { + const reportArg = args[1]; + if (!reportArg || reportArg.startsWith("--")) throw new Error("Usage: synsec render [--html ] [--sarif ] [--markdown ]"); + const reportPath = resolve(reportArg); + const report = await readReport(reportPath); + const htmlPath = resolve(option("--html") ?? reportPath.replace(/\.json$/i, ".html")); + const sarifPath = resolve(option("--sarif") ?? reportPath.replace(/\.json$/i, ".sarif")); + const markdownPath = resolve(option("--markdown") ?? reportPath.replace(/\.json$/i, ".md")); + await Promise.all([ + mkdir(dirname(htmlPath), { recursive: true }).then(() => writeFile(htmlPath, renderHtml(report), "utf8")), + mkdir(dirname(sarifPath), { recursive: true }).then(() => writeFile(sarifPath, `${JSON.stringify(toSarif(report), null, 2)}\n`, "utf8")), + writeMarkdown(markdownPath, report), + ]); + console.log(`HTML: ${htmlPath}`); + console.log(`SARIF: ${sarifPath}`); + console.log(`MARKDOWN: ${markdownPath}`); +} + +async function baseline(): Promise { + const source = args[1]; + if (!source || source.startsWith("--")) throw new Error("Usage: synsec baseline [destination]"); + await readReport(resolve(source)); + const destination = resolve(args[2] && !args[2].startsWith("--") ? args[2] : ".synsec/baseline.json"); + await mkdir(dirname(destination), { recursive: true }); + await copyFile(resolve(source), destination); + console.log(`Baseline saved to ${destination}`); } async function main(): Promise { switch (command) { + case "init": + await init(); + break; case "doctor": await doctor(); break; case "scan": await scan(); break; + case "review": + await review(); + break; + case "triage": + await triage(); + break; + case "verify": + await verify(); + break; + case "import-sarif": + await importSarif(); + break; + case "workflows": + listWorkflows(); + break; + case "render": + await render(); + break; + case "baseline": + await baseline(); + break; + case "version": + case "--version": + case "-v": + console.log(VERSION); + break; case "help": case "--help": case "-h": @@ -127,4 +629,4 @@ async function main(): Promise { main().catch((error: unknown) => { console.error(`SynSec error: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; -}); +}); \ No newline at end of file diff --git a/apps/cli/src/lifecycle-review-deadlines-cli.ts b/apps/cli/src/lifecycle-review-deadlines-cli.ts new file mode 100644 index 00000000..56d263bc --- /dev/null +++ b/apps/cli/src/lifecycle-review-deadlines-cli.ts @@ -0,0 +1,129 @@ +#!/usr/bin/env node + +import { lstat } from "node:fs/promises"; +import { resolve } from "node:path"; +import { readLifecycleStore } from "@synsec/lifecycle"; +import { + assessLifecycleReviewDeadlines, + type LifecycleReviewDeadlineAssessment, +} from "@synsec/lifecycle/review-deadlines"; +import { + evaluateLifecycleReviewPolicy, + type LifecycleReviewPolicyResult, +} from "@synsec/lifecycle/review-policy"; + +const MAX_INPUT_BYTES = 1024 * 1024; +const DAY_MS = 24 * 60 * 60 * 1000; +const args = process.argv.slice(2); + +function option(name: string): string | undefined { + const index = args.indexOf(name); + if (index >= 0) return args[index + 1]; + const prefix = `${name}=`; + const inline = args.find((value) => value.startsWith(prefix)); + return inline?.slice(prefix.length); +} + +function flag(name: string): boolean { + return args.includes(name); +} + +function validateArguments(): void { + const supportedFlags = new Set([ + "--json", + "--summary-only", + "--fail-overdue", + "--fail-unscheduled", + ]); + const supportedOptions = new Set(["--now", "--due-soon-days"]); + + for (let index = 1; index < args.length; index += 1) { + const value = args[index]; + if (!value?.startsWith("--")) continue; + const name = value.includes("=") ? value.slice(0, value.indexOf("=")) : value; + if (supportedFlags.has(name)) continue; + if (supportedOptions.has(name)) { + if (!value.includes("=")) index += 1; + continue; + } + throw new Error("Unsupported lifecycle review option."); + } +} + +function dueSoonWindowMs(): number | undefined { + const raw = option("--due-soon-days"); + if (raw === undefined) return undefined; + if (!/^\d+$/.test(raw)) throw new Error("--due-soon-days must be an integer between 0 and 365."); + const days = Number.parseInt(raw, 10); + if (!Number.isSafeInteger(days) || days < 0 || days > 365) { + throw new Error("--due-soon-days must be an integer between 0 and 365."); + } + return days * DAY_MS; +} + +function renderText(path: string, assessment: LifecycleReviewDeadlineAssessment): string[] { + const lines = [ + `Lifecycle store: ${path}`, + `Reviewable exceptions: ${assessment.summary.reviewable}`, + `Overdue: ${assessment.summary.overdue}`, + `Due soon: ${assessment.summary.dueSoon}`, + `Scheduled: ${assessment.summary.scheduled}`, + `Unscheduled: ${assessment.summary.unscheduled}`, + ]; + for (const item of assessment.items) { + lines.push(`[${item.status.toUpperCase()}] ${item.fingerprint} ${item.state} ${item.reviewAt}`); + } + return lines; +} + +function renderSummaryText(result: LifecycleReviewPolicyResult): string[] { + return [ + `Reviewable exceptions: ${result.summary.reviewable}`, + `Overdue: ${result.summary.overdue}`, + `Due soon: ${result.summary.dueSoon}`, + `Scheduled: ${result.summary.scheduled}`, + `Unscheduled: ${result.summary.unscheduled}`, + `Policy ready: ${result.ready ? "yes" : "no"}`, + `Policy violations: ${result.violations.length > 0 ? result.violations.join(", ") : "none"}`, + ]; +} + +async function main(): Promise { + validateArguments(); + const input = args[0]; + if (!input || input.startsWith("--")) { + throw new Error("Usage: synsec-lifecycle-reviews [--now ] [--due-soon-days <0-365>] [--json] [--summary-only] [--fail-overdue] [--fail-unscheduled]"); + } + + const path = resolve(input); + const info = await lstat(path); + if (!info.isFile()) throw new Error("Lifecycle review input must be a non-symlink regular file."); + if (info.size > MAX_INPUT_BYTES) throw new Error(`Lifecycle review input exceeds ${MAX_INPUT_BYTES} bytes.`); + + const store = await readLifecycleStore(path); + const windowMs = dueSoonWindowMs(); + const assessment = assessLifecycleReviewDeadlines(store, { + now: option("--now"), + ...(windowMs === undefined ? {} : { dueSoonWindowMs: windowMs }), + }); + const policyResult = evaluateLifecycleReviewPolicy(assessment, { + failOnOverdue: flag("--fail-overdue"), + failOnUnscheduled: flag("--fail-unscheduled"), + }); + const summaryOnly = flag("--summary-only"); + + if (flag("--json")) { + process.stdout.write(`${JSON.stringify(summaryOnly ? policyResult : assessment, null, 2)}\n`); + } else { + const lines = summaryOnly ? renderSummaryText(policyResult) : renderText(path, assessment); + for (const line of lines) console.log(line); + } + + if (policyResult.violations.includes("overdue")) process.exitCode = 2; + else if (policyResult.violations.includes("unscheduled")) process.exitCode = 3; +} + +main().catch((error: unknown) => { + console.error(`SynSec lifecycle review error: ${error instanceof Error ? error.message : "unexpected failure"}`); + process.exitCode = 1; +}); diff --git a/apps/cli/src/lifecycle.ts b/apps/cli/src/lifecycle.ts new file mode 100644 index 00000000..fa66e5a6 --- /dev/null +++ b/apps/cli/src/lifecycle.ts @@ -0,0 +1,186 @@ +import { dirname, resolve } from "node:path"; +import { + currentLifecycleRecords, + isFindingState, + lifecycleSummary, + readLifecycleStore, + reconcileLifecycle, + setFindingOwner, + setFindingReviewAt, + setFindingState, + verifyRemediation, + writeLifecycleStore, + writeRemediationVerification, + type FindingLifecycleStore, + type LifecycleSummary, + type RemediationVerification, +} from "@synsec/lifecycle"; +import { + addFindingReviewComment, + commentsForFinding, + readFindingReviewCommentStore, + writeFindingReviewCommentStore, +} from "@synsec/lifecycle/review-comments"; +import { readReport, type SynSecReport } from "@synsec/report"; + +export interface LifecycleFileResult { + path: string; + store: FindingLifecycleStore; + summary: LifecycleSummary; +} + +export async function reconcileLifecycleFile( + report: SynSecReport, + root: string, + persist: boolean, +): Promise { + const path = resolve(root, ".synsec/lifecycle.json"); + const previous = await readLifecycleStore(path); + const store = reconcileLifecycle(report, previous); + if (persist) await writeLifecycleStore(path, store); + return { path, store, summary: lifecycleSummary(store) }; +} + +function triagePaths(reportPath: string, requestedStore?: string): { lifecycle: string; comments: string } { + const lifecycle = resolve(requestedStore ?? dirname(reportPath), requestedStore ? "." : "lifecycle.json"); + return { + lifecycle, + comments: resolve(dirname(lifecycle), "review-comments.json"), + }; +} + +function reviewAtValue(value: string | undefined): string | null | undefined { + if (value === undefined) return undefined; + const normalized = value.trim(); + if (!normalized) return undefined; + return normalized.toLowerCase() === "clear" ? null : normalized; +} + +/** + * Triage CLI operations intentionally mutate only bounded human review metadata. + * `owner`, `comment`, and `review-at` are explicit operator actions, not scanner states or inferred evidence. + */ +export async function runTriage(input: { + reportPath: string; + fingerprint?: string; + state?: string; + note?: string; + reviewAt?: string; + storePath?: string; + listOnly?: boolean; +}): Promise { + const reportPath = resolve(input.reportPath); + const report = await readReport(reportPath); + const paths = triagePaths(reportPath, input.storePath); + let store = reconcileLifecycle(report, await readLifecycleStore(paths.lifecycle)); + const comments = await readFindingReviewCommentStore(paths.comments); + + if (input.listOnly) { + const records = currentLifecycleRecords(report, store); + const lines = records.map((record) => { + const finding = report.findings.find((item) => item.fingerprint === record.fingerprint); + const owner = record.owner ? ` owner:${record.owner}` : ""; + const review = record.reviewAt ? ` review:${record.reviewAt}` : ""; + const commentCount = commentsForFinding(comments, record.fingerprint).length; + const commentSummary = commentCount > 0 ? ` comments:${commentCount}` : ""; + return `${record.fingerprint} ${record.state.padEnd(14)} ${finding?.primary.title ?? "finding"}${owner}${review}${commentSummary}`; + }); + return [ + `Lifecycle store: ${paths.lifecycle}`, + `Review comments: ${paths.comments}`, + ...lines, + ]; + } + + if (!input.fingerprint || !input.state) { + throw new Error("Usage: synsec triage [--note ] [--store ] or synsec triage --list"); + } + const exists = report.findings.some((finding) => finding.fingerprint === input.fingerprint); + if (!exists) throw new Error(`Finding fingerprint is not present in report ${report.reportId}: ${input.fingerprint}`); + + if (input.state === "owner") { + if (input.note === undefined) { + throw new Error("Ownership triage requires --note ; use an empty --note value to clear ownership."); + } + store = setFindingOwner(store, input.fingerprint, input.note); + await writeLifecycleStore(paths.lifecycle, store); + const owner = store.records[input.fingerprint]?.owner; + return [ + owner ? `Assigned ${input.fingerprint} -> ${owner}` : `Cleared owner for ${input.fingerprint}`, + `Lifecycle store: ${paths.lifecycle}`, + ]; + } + + if (input.state === "review-at") { + const requestedReviewAt = input.reviewAt ?? input.note; + if (requestedReviewAt === undefined) { + throw new Error("Review deadline triage requires --note ."); + } + store = setFindingReviewAt(store, input.fingerprint, reviewAtValue(requestedReviewAt)); + await writeLifecycleStore(paths.lifecycle, store); + const reviewAt = store.records[input.fingerprint]?.reviewAt; + return [ + reviewAt ? `Review deadline ${input.fingerprint} -> ${reviewAt}` : `Cleared review deadline for ${input.fingerprint}`, + `Lifecycle store: ${paths.lifecycle}`, + ]; + } + + if (input.state === "comment") { + if (!input.note?.trim()) throw new Error("Comment triage requires --note ."); + const updated = addFindingReviewComment(comments, input.fingerprint, input.note); + await writeFindingReviewCommentStore(paths.comments, updated); + const added = commentsForFinding(updated, input.fingerprint).at(-1); + return [ + `Added review comment to ${input.fingerprint}${added ? ` (${added.id.slice(0, 12)})` : ""}`, + `Review comments: ${paths.comments}`, + ]; + } + + if (!isFindingState(input.state)) { + throw new Error("Triage state must be one of new, confirmed, false-positive, accepted-risk, fixed, regressed; or use owner/comment/review-at actions."); + } + + store = setFindingState(store, input.fingerprint, input.state, { + note: input.note, + reportId: report.reportId, + reviewAt: reviewAtValue(input.reviewAt), + }); + await writeLifecycleStore(paths.lifecycle, store); + return [ + `Updated ${input.fingerprint} -> ${input.state}`, + ...(store.records[input.fingerprint]?.reviewAt ? [`Review by: ${store.records[input.fingerprint]?.reviewAt}`] : []), + `Lifecycle store: ${paths.lifecycle}`, + ]; +} + +export async function runVerification(input: { + beforeReportPath: string; + afterReportPath: string; + fingerprints?: string[]; + outputPath?: string; +}): Promise<{ verification: RemediationVerification; lines: string[]; outputPath?: string }> { + const beforePath = resolve(input.beforeReportPath); + const afterPath = resolve(input.afterReportPath); + const [before, after] = await Promise.all([readReport(beforePath), readReport(afterPath)]); + const verification = verifyRemediation(before, after, input.fingerprints); + const lines = [ + `Verification: ${verification.summary.fixed} fixed, ${verification.summary.persisting} persisting, ${verification.summary.inconclusive} inconclusive, ${verification.summary.newFindings} new finding(s)`, + ]; + + for (const item of verification.items) { + lines.push(`[${item.status.toUpperCase()}] ${item.title ?? item.fingerprint}`); + for (const reason of item.reasons) lines.push(` ${reason}`); + } + if (verification.newFindings.length > 0) { + lines.push("New finding fingerprints:"); + for (const fingerprint of verification.newFindings) lines.push(` ${fingerprint}`); + } + + let outputPath: string | undefined; + if (input.outputPath) { + outputPath = resolve(input.outputPath); + await writeRemediationVerification(outputPath, verification); + lines.push(`Verification JSON: ${outputPath}`); + } + return outputPath ? { verification, lines, outputPath } : { verification, lines }; +} diff --git a/apps/cli/src/scanner-isolation-profile-cli.ts b/apps/cli/src/scanner-isolation-profile-cli.ts new file mode 100644 index 00000000..e90c96d8 --- /dev/null +++ b/apps/cli/src/scanner-isolation-profile-cli.ts @@ -0,0 +1,155 @@ +#!/usr/bin/env node + +import { lstat, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + assessSynSecScannerIsolationProfile, + type SynSecScannerIsolationProfile, +} from "@synsec/github/scanner-isolation-profile"; + +const MAX_PROFILE_BYTES = 64 * 1024; +const args = process.argv.slice(2); + +function printHelp(): void { + console.log(`SynSec scanner isolation profile verifier + +Usage: + synsec-scanner-isolation [--json] + +Exit codes: + 0 Every required scanner isolation control is declared. + 2 One or more required controls are missing or unsafe. + 1 The profile or command line is invalid. + +This command is offline and credential-free. It validates a declaration of externally enforced +container/sandbox controls; it does not inspect or certify the host runtime. +`); +} + +function record(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +async function readProfile(path: string): Promise> { + const absolute = resolve(path); + const info = await lstat(absolute).catch(() => undefined); + if (!info?.isFile() || info.isSymbolicLink()) { + throw new Error("Scanner isolation profile must be a non-symlink regular file."); + } + if (info.size > MAX_PROFILE_BYTES) { + throw new Error(`Scanner isolation profile exceeds ${MAX_PROFILE_BYTES} bytes.`); + } + + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(absolute, "utf8")); + } catch { + throw new Error("Scanner isolation profile must contain valid JSON."); + } + const root = record(parsed); + if (!root) throw new Error("Scanner isolation profile must contain a JSON object."); + + const allowed = new Set([ + "schemaVersion", + "runtime", + "cpuLimit", + "memoryLimit", + "networkPolicy", + "repositoryReadOnly", + "rootFilesystemReadOnly", + "scratchSeparated", + "credentialsExcluded", + "durableStateExcluded", + "privileged", + "allowPrivilegeEscalation", + "runAsNonRoot", + "capabilitiesDropped", + "hostNetwork", + "hostPid", + "hostIpc", + "hostSocketMounts", + ]); + if (Object.keys(root).some((key) => !allowed.has(key))) { + // Never reflect an attacker-controlled JSON key. Field names are log data too and can contain + // credentials or terminal/control sequences just like field values. + throw new Error("Scanner isolation profile contains an unsupported field. Credentials, paths, image registries, and connection details are not accepted."); + } + + const profile: Partial = {}; + if (root.schemaVersion !== undefined) { + if (root.schemaVersion !== 1) throw new Error("Scanner isolation profile schemaVersion must be 1."); + profile.schemaVersion = 1; + } + if (root.runtime !== undefined) { + if (root.runtime !== "container" && root.runtime !== "sandbox") { + throw new Error("Scanner isolation profile runtime must be container or sandbox."); + } + profile.runtime = root.runtime; + } + if (root.networkPolicy !== undefined) { + if (root.networkPolicy !== "none" && root.networkPolicy !== "egress-filtered") { + throw new Error("Scanner isolation profile networkPolicy must be none or egress-filtered."); + } + profile.networkPolicy = root.networkPolicy; + } + + for (const key of [ + "cpuLimit", + "memoryLimit", + "repositoryReadOnly", + "rootFilesystemReadOnly", + "scratchSeparated", + "credentialsExcluded", + "durableStateExcluded", + "privileged", + "allowPrivilegeEscalation", + "runAsNonRoot", + "capabilitiesDropped", + "hostNetwork", + "hostPid", + "hostIpc", + "hostSocketMounts", + ] as const) { + const value = root[key]; + if (value === undefined) continue; + if (typeof value !== "boolean") throw new Error(`Scanner isolation profile ${key} must be boolean.`); + profile[key] = value as never; + } + return profile; +} + +async function main(): Promise { + if (args.includes("--help") || args.includes("-h")) { + printHelp(); + return; + } + const unsupported = args.filter((arg, index) => index > 0 && arg !== "--json"); + if (unsupported.length > 0) throw new Error("Unsupported scanner isolation verifier option."); + + const path = args[0]; + if (!path || path.startsWith("--")) { + printHelp(); + process.exitCode = 1; + return; + } + + const assessment = assessSynSecScannerIsolationProfile(await readProfile(path)); + if (args.includes("--json")) { + console.log(JSON.stringify(assessment, null, 2)); + } else { + console.log(`Scanner isolation declaration: ${assessment.complete ? "complete" : "incomplete"}`); + if (assessment.missing.length > 0) { + console.log("Missing or unsafe controls:"); + for (const control of assessment.missing) console.log(` - ${control}`); + } else console.log("Missing or unsafe controls: none"); + console.log(`Interpretation: ${assessment.interpretation}`); + } + if (!assessment.complete) process.exitCode = 2; +} + +main().catch((error: unknown) => { + console.error(`SynSec scanner isolation profile error: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; +}); diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 57307bd5..832e8c9c 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -6,9 +6,16 @@ "rootDir": "src" }, "references": [ + { "path": "../../packages/ai" }, + { "path": "../../packages/config" }, { "path": "../../packages/core" }, - { "path": "../../packages/scanner-sdk" }, - { "path": "../../packages/scanners" } + { "path": "../../packages/engine" }, + { "path": "../../packages/github" }, + { "path": "../../packages/lifecycle" }, + { "path": "../../packages/report" }, + { "path": "../../packages/repository" }, + { "path": "../../packages/scanners" }, + { "path": "../../packages/workflows" } ], "include": ["src/**/*.ts"] } diff --git a/apps/github-action/package.json b/apps/github-action/package.json new file mode 100644 index 00000000..5de8eafd --- /dev/null +++ b/apps/github-action/package.json @@ -0,0 +1,16 @@ +{ + "name": "@synsec/github-action", + "version": "0.2.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@synsec/config": "0.2.0", + "@synsec/github": "0.2.0", + "@synsec/report": "0.2.0" + } +} diff --git a/apps/github-action/src/index.ts b/apps/github-action/src/index.ts new file mode 100644 index 00000000..b7c3743a --- /dev/null +++ b/apps/github-action/src/index.ts @@ -0,0 +1,80 @@ +import { appendFile, chmod } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { loadConfig } from "@synsec/config"; +import { runGitHubActionsRepositoryScan } from "@synsec/github/actions-runner"; +import { writeReport } from "@synsec/report"; +import { + booleanInput, + changedOnlyInput, + nonEmpty, + resolveWorkspaceFileInput, +} from "./inputs.js"; +import { writeStepSummary } from "./summary.js"; + +async function writeOutput(name: string, value: string | number | undefined): Promise { + const path = nonEmpty(process.env.GITHUB_OUTPUT); + if (!path || value === undefined) return; + const normalized = String(value).replace(/[\r\n]/g, ""); + await appendFile(path, `${name}=${normalized}\n`, "utf8"); +} + +async function main(): Promise { + const workspace = resolve(nonEmpty(process.env.GITHUB_WORKSPACE) ?? process.cwd()); + const token = nonEmpty(process.env.SYNSEC_GITHUB_TOKEN); + if (!token) throw new Error("The SynSec GitHub Action requires a GitHub token."); + + const configInput = nonEmpty(process.env.SYNSEC_CONFIG_PATH); + const configPath = configInput + ? await resolveWorkspaceFileInput(workspace, configInput, "config-path") + : undefined; + const { config } = await loadConfig(workspace, configPath); + const baselineInput = nonEmpty(process.env.SYNSEC_BASELINE_PATH); + const baselinePath = baselineInput + ? await resolveWorkspaceFileInput(workspace, baselineInput, "baseline-path") + : undefined; + const autoBaseline = booleanInput(process.env.SYNSEC_AUTO_BASELINE, true); + const publishSarif = booleanInput(process.env.SYNSEC_PUBLISH_SARIF, false); + const changedOnly = changedOnlyInput(process.env.SYNSEC_CHANGED_ONLY); + + const result = await runGitHubActionsRepositoryScan(token, { + config, + rootPath: workspace, + baselinePath, + autoBaseline: baselinePath ? false : autoBaseline, + changedOnly, + publishSarif, + threshold: config.failOn, + }); + + const reportPath = resolve(nonEmpty(process.env.RUNNER_TEMP) ?? tmpdir(), "synsec-report.json"); + await writeReport(reportPath, result.outcome.report); + await chmod(reportPath, 0o600).catch(() => undefined); + await writeStepSummary( + nonEmpty(process.env.GITHUB_STEP_SUMMARY), + result.outcome.report, + result.baselineSource ?? "none", + ); + + await Promise.all([ + writeOutput("security-score", result.outcome.report.securityScore), + writeOutput("finding-count", result.outcome.report.findingCount), + writeOutput("check-run-id", result.publication.publication.id), + writeOutput("sarif-upload-id", result.sarifPublication?.id), + writeOutput("baseline-source", result.baselineSource ?? "none"), + writeOutput("report-path", reportPath), + ]); + + console.log( + `SynSec scanned ${result.outcome.report.scope?.mode === "changed-files" ? "changed files" : "the repository"}: ` + + `${result.outcome.report.findingCount} finding(s), security score ${result.outcome.report.securityScore}/100.` + + ` Baseline: ${result.baselineSource ?? "none"}. Report: ${reportPath}.`, + ); + if (result.outcome.shouldFail) process.exitCode = 1; +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`SynSec GitHub Action failed: ${message.replace(/[\r\n]+/g, " ").slice(0, 1_000)}`); + process.exitCode = 1; +}); diff --git a/apps/github-action/src/inputs.ts b/apps/github-action/src/inputs.ts new file mode 100644 index 00000000..4f1ccb53 --- /dev/null +++ b/apps/github-action/src/inputs.ts @@ -0,0 +1,55 @@ +import { lstat, realpath } from "node:fs/promises"; +import { isAbsolute, relative, resolve, sep } from "node:path"; + +export function nonEmpty(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed || undefined; +} + +export function booleanInput(value: string | undefined, fallback: boolean): boolean { + const normalized = value?.trim().toLowerCase(); + if (!normalized) return fallback; + if (normalized === "true" || normalized === "1" || normalized === "yes") return true; + if (normalized === "false" || normalized === "0" || normalized === "no") return false; + throw new Error(`Expected a boolean action input, received: ${normalized.slice(0, 32)}`); +} + +export function changedOnlyInput(value: string | undefined): boolean | undefined { + const normalized = value?.trim().toLowerCase(); + if (!normalized || normalized === "auto") return undefined; + if (normalized === "true" || normalized === "1" || normalized === "yes") return true; + if (normalized === "false" || normalized === "0" || normalized === "no") return false; + throw new Error("changed-only must be auto, true, or false."); +} + +function insideRoot(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +/** + * Resolve an explicitly configured Action file input and bind it to the checked-out workspace. + * realpath() is used for both sides so a repository symlink cannot redirect config/baseline reads + * into runner-global files outside the checkout. + */ +export async function resolveWorkspaceFileInput( + workspace: string, + input: string, + label: string, +): Promise { + const root = await realpath(resolve(workspace)); + const lexicalCandidate = resolve(root, input); + if (!insideRoot(root, lexicalCandidate)) { + throw new Error(`${label} must resolve inside GITHUB_WORKSPACE.`); + } + + const candidate = await realpath(lexicalCandidate).catch(() => undefined); + if (!candidate || !insideRoot(root, candidate)) { + throw new Error(`${label} must reference an existing file inside GITHUB_WORKSPACE.`); + } + const info = await lstat(candidate).catch(() => undefined); + if (!info?.isFile()) { + throw new Error(`${label} must reference a regular file inside GITHUB_WORKSPACE.`); + } + return candidate; +} diff --git a/apps/github-action/src/summary.ts b/apps/github-action/src/summary.ts new file mode 100644 index 00000000..e6fe87d4 --- /dev/null +++ b/apps/github-action/src/summary.ts @@ -0,0 +1,47 @@ +import { appendFile } from "node:fs/promises"; +import type { SynSecReport } from "@synsec/report"; + +export function renderStepSummary(report: SynSecReport, baselineSource: string): string { + const delta = report.baseline; + const lines = [ + "## SynSec repository security", + "", + `**Security score:** ${report.securityScore}/100 `, + `**Findings:** ${report.findingCount} `, + `**Scope:** ${report.scope?.mode === "changed-files" ? "changed files" : "full repository"} `, + `**Baseline:** ${baselineSource}`, + "", + "| Severity | Count |", + "| --- | ---: |", + `| Critical | ${report.summary.critical} |`, + `| High | ${report.summary.high} |`, + `| Medium | ${report.summary.medium} |`, + `| Low | ${report.summary.low} |`, + `| Info | ${report.summary.info} |`, + `| Unknown | ${report.summary.unknown} |`, + ]; + if (delta) { + lines.push( + "", + "### Baseline delta", + "", + `New: **${delta.new.length}** · Fixed: **${delta.fixed.length}** · Persisting: **${delta.persisting.length}**`, + ); + } + lines.push( + "", + "_This summary intentionally contains aggregate metadata only. Review the normalized report/check annotations for finding details._", + "", + ); + return `${lines.join("\n")}\n`; +} + +export async function writeStepSummary( + path: string | undefined, + report: SynSecReport, + baselineSource: string, +): Promise { + const target = path?.trim(); + if (!target) return; + await appendFile(target, renderStepSummary(report, baselineSource), "utf8"); +} diff --git a/apps/github-action/tsconfig.json b/apps/github-action/tsconfig.json new file mode 100644 index 00000000..0e7f1ac0 --- /dev/null +++ b/apps/github-action/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [ + { "path": "../../packages/config" }, + { "path": "../../packages/github" } + ], + "include": ["src/**/*.ts"] +} diff --git a/deploy/systemd/synsec-intake.service b/deploy/systemd/synsec-intake.service new file mode 100644 index 00000000..bd2b573d --- /dev/null +++ b/deploy/systemd/synsec-intake.service @@ -0,0 +1,36 @@ +[Unit] +Description=SynSec GitHub App intake +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=synsec +Group=synsec +WorkingDirectory=/opt/synsec/current +LoadCredential=postgres.url:/etc/synsec/secrets/postgres.url +Environment=SYNSEC_POSTGRES_URL_FILE=%d/postgres.url +ExecStart=/usr/bin/node /opt/synsec/current/scripts/github-app-intake-host.mjs --profile /etc/synsec/intake-profile.json --conformance /etc/synsec/shared-state-conformance.json +Restart=on-failure +RestartSec=5s +TimeoutStopSec=45s +UMask=0077 +NoNewPrivileges=true +PrivateTmp=true +PrivateDevices=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true +ProtectControlGroups=true +ProtectClock=true +ProtectHostname=true +RestrictSUIDSGID=true +LockPersonality=true +RestrictRealtime=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +ReadWritePaths=/var/lib/synsec/workspaces + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/synsec-worker.service b/deploy/systemd/synsec-worker.service new file mode 100644 index 00000000..2fb410c2 --- /dev/null +++ b/deploy/systemd/synsec-worker.service @@ -0,0 +1,41 @@ +[Unit] +Description=SynSec GitHub App worker +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=synsec +Group=synsec +WorkingDirectory=/opt/synsec/current +LoadCredential=postgres.url:/etc/synsec/secrets/postgres.url +Environment=SYNSEC_POSTGRES_URL_FILE=%d/postgres.url +ExecStart=/usr/bin/node /opt/synsec/current/scripts/github-app-worker-host.mjs --profile /etc/synsec/worker-profile.json --conformance /etc/synsec/shared-state-conformance.json --config /etc/synsec/worker-config.json +Restart=on-failure +RestartSec=5s +TimeoutStopSec=90s +UMask=0077 +NoNewPrivileges=true +PrivateTmp=true +PrivateDevices=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true +ProtectControlGroups=true +ProtectClock=true +ProtectHostname=true +RestrictSUIDSGID=true +LockPersonality=true +RestrictRealtime=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +ReadWritePaths=/var/lib/synsec/workspaces + +# The worker must be given only the minimum host-side OCI runtime access needed by +# scannerRuntimeCommand. Do not add Docker/Podman control sockets here blindly: +# such access is a privileged host orchestration boundary even though scanner +# containers themselves run with SynSec's enforced isolation profile. + +[Install] +WantedBy=multi-user.target diff --git a/docs/AI_REVIEW.md b/docs/AI_REVIEW.md new file mode 100644 index 00000000..eb5c4cde --- /dev/null +++ b/docs/AI_REVIEW.md @@ -0,0 +1,53 @@ +# AI finding review and consensus + +SynSec AI review is an optional advisory layer over deterministic repository scanner findings. It is disabled by default, does not create scanner evidence, does not authorize repository writes, and does not expand scanning to live external targets. + +## Single-model review + +Existing single-model CLI behavior remains available with `--ai-model ` or the configured/environment model. `synsec review` and `synsec scan --ai` write the existing schema-version-1 review artifact so current consumers do not receive a silent format change. + +A model reviews one normalized finding against SynSec's seven-question evidence gate. Source context is omitted by default and can be enabled only when configuration plus the selected defensive workflow permit it. Secret findings cannot receive source excerpts at the AI provider boundary. + +## Multi-model consensus + +Use an explicit comma-separated model set to request independent reviews: + +```text +synsec review .synsec/report.json \ + --ai-base-url \ + --ai-models reviewer-a,reviewer-b,reviewer-c \ + --ai-min-reviewers 2 \ + --ai-review-concurrency 2 +``` + +`--ai-models` requires between two and ten unique model ids. It cannot be combined with `--ai-model`. `--ai-min-reviewers` must be between two and the selected model count. Review concurrency is bounded between one and four and can never exceed the model count. Invalid values fail instead of being silently clamped. + +Each model receives the same bounded finding/context input independently. Provider failures are isolated and credential text is redacted from returned failure diagnostics. If fewer than the required number of distinct reviewers succeed, consensus is `insufficient` with an `uncertain` verdict rather than silently lowering the requirement. + +Consensus requires an actual majority for a non-uncertain aggregate verdict. Ties or split reviewer outcomes remain `uncertain`. The output preserves individual reviews, provider failures, agreeing/dissenting model ids, gate vote counts, aggregate severity/confidence, and agreement status. + +## Consensus output + +Multi-model mode writes schema version 2 and is explicitly labeled: + +```json +{ + "schemaVersion": 2, + "reviewMode": "consensus", + "models": ["reviewer-a", "reviewer-b", "reviewer-c"], + "interpretation": "model-consensus-not-scanner-evidence", + "reviews": {} +} +``` + +Each finding entry includes the independent model reviews plus the aggregate consensus. The aggregate itself repeats the `model-consensus-not-scanner-evidence` interpretation. Downstream lifecycle or remediation logic must not reinterpret a model majority as proof that a scanner finding is confirmed, exploitable, reachable, or fixed. + +## Source and workflow boundaries + +Workflow capability checks run before review. A workflow that prohibits source context continues to prohibit it regardless of how many models are selected. The multi-review orchestration layer separately refuses source context for secret findings even when a custom reviewer is injected. + +The OpenAI-compatible endpoint is explicitly operator-configured AI infrastructure. Model review does not derive URLs from findings, source code, scanner output, webhook payloads, or repository metadata, and it is not used for autonomous external security assessment. + +## Operational guidance + +Use multiple reviewers when independent model disagreement is useful for human triage, not as a replacement for deterministic scanning or code review. For production use, keep API credentials outside repository files, bound model cost and concurrency, select workflows deliberately, and retain scanner/lifecycle evidence separately from AI artifacts. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 86d0e860..d55a2cdd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,120 +2,220 @@ ## Goal -SynSec should act as the orchestration and intelligence layer around mature security scanners rather than reimplementing every detection engine. +SynSec is the orchestration, normalization, correlation, reporting, and contextual-review layer around mature security engines. Detection engines remain replaceable external tools rather than being copied into the application. -The core pipeline is: +The v0.2 pipeline is: ```text repository | - v -scanner adapters - | - v -raw scanner results - | - v -normalization + +----> safe inventory --------------------------+ + | | + v | +scanner availability | + | | + v | +bounded concurrent scanner runner | + | | + +-- Opengrep | + +-- Betterleaks / Gitleaks | + +-- OSV-Scanner | + +-- Trivy | + +-- Grype | + +-- Checkov | + | | + v | +normalized findings <-----------------------------+ | v correlation / deduplication | - v -repository context and reachability + +--------------------+ + | | + v v +versioned report optional AI review +JSON/HTML/SARIF separate output/evidence | v -triage / remediation - | - +-- CLI - +-- web dashboard - +-- CI checks - +-- pull-request remediation +baseline comparison +new / fixed / persisting ``` -## Packages +## Package boundaries ### `@synsec/core` -Owns scanner-independent domain types and correlation logic. Scanner-specific response shapes should never leak into the rest of the application. +Owns scanner-independent domain types and deterministic correlation. + +Scanner-native fingerprints are retained as source metadata on individual findings, while the correlation layer computes its own fingerprint so two different engines can corroborate the same issue. + +Current stronger deterministic signals include: + +- shared vulnerability advisory IDs plus package identity for dependency findings; +- same file and line for redacted secret findings; +- same file, line, and CWE for SAST findings; +- conservative scanner-aware fallbacks when there is not enough evidence to merge alerts safely. + +### `@synsec/config` + +Owns the stable `synsec.config.json` schema. It controls scanner selection, concurrency, timeouts, CI failure thresholds, report locations, baselines, and AI privacy behavior. ### `@synsec/scanner-sdk` -Defines the adapter contract used by all scanner integrations and provides shared process-execution primitives. +Defines the adapter contract and the shared process runner. -Scanner adapters are expected to: +Important process properties: -1. report whether the underlying engine is available; -2. execute it without invoking a shell; -3. parse its native output; -4. emit the normalized SynSec finding schema. +- `spawn` is used without a shell; +- arguments are passed as an array rather than interpolated into command text; +- timeouts and abort signals are supported; +- scanner stdout/stderr remain separate. ### `@synsec/scanners` -Contains built-in integrations. The first integration is Trivy. +Contains built-in adapters for external engines. + +An adapter must: + +1. report binary availability; +2. invoke only its intended scanner binary; +3. request machine-readable output; +4. normalize the result into `Finding` objects; +5. avoid retaining secrets where the engine can redact them; +6. treat documented scanner "findings found" exit codes separately from execution failures. + +The current engines are Opengrep, Betterleaks, Gitleaks, OSV-Scanner, Trivy, Grype, and Checkov. + +### `@synsec/repository` + +Provides bounded repository intelligence without executing the project under analysis. + +The v0.2 implementation: + +- walks files while excluding common generated/vendor directories; +- skips symlinks; +- caps inventory and per-file analysis size; +- detects languages and common frameworks; +- builds a structural local module graph for supported JavaScript/TypeScript/Python imports; +- builds bounded lexical call-graph, route, authentication, sink, test-ownership, and coverage context; +- expands incremental scan scope through bounded known local dependents; +- retrieves bounded text around a finding only after verifying that the path remains inside the repository root; +- refuses large/binary context files. -Planned adapters include Opengrep, Gitleaks, OSV-Scanner, Syft, Grype, Checkov, and OpenSSF Scorecard. +Module resolution deliberately fails closed when identity is ambiguous. JavaScript/TypeScript local edges require relative specifiers. Python relative imports are resolved when a unique repository module/package exists. Absolute Python imports such as `service.db` are treated as repository-local only when the indexed repository contains an explicit top-level `service/__init__.py` package and exactly one matching module/package target. A standalone `requests.py` therefore does not cause `import requests` to be guessed as local, and a simultaneous `service/db.py` plus `service/db/__init__.py` remains unresolved. + +This module graph is structural import evidence, not function-level data flow or production reachability. Its immediate execution consequence is conservative incremental coverage: changing a uniquely resolved local Python dependency can include bounded importing modules, while unresolved imports do not manufacture dependent scope. + +### `@synsec/report` + +Owns the versioned SynSec report model and presentation formats: + +- JSON (`schemaVersion: 1.0`); +- SARIF 2.1.0; +- self-contained HTML; +- baseline comparison. + +The HTML renderer escapes finding-controlled content before insertion. + +### `@synsec/engine` + +Coordinates a scan. + +Responsibilities include: + +- Git repository metadata discovery; +- credential stripping from remote URLs; +- scanner availability checks; +- bounded concurrency; +- failure isolation; +- repository inventory and structural context; +- conservative dependency-aware incremental planning; +- report construction; +- CI severity threshold evaluation. + +The engine refuses to generate a reassuring "clean" report if no selected scanner was able to run. If every available scanner fails, the scan itself fails. + +### `@synsec/ai` + +Provides the optional contextual review boundary. + +The first implementation deliberately uses an OpenAI-compatible protocol rather than importing a model-vendor SDK. A local or remote model router can therefore sit behind the same interface. + +AI review is not part of deterministic detection. It writes a separate review artifact and is governed by a seven-question evidence gate. Source excerpts are disabled by default and only retrieved/sent when explicitly enabled. ### `@synsec/cli` -Provides the local developer workflow. The initial commands are `doctor` and `scan`. +Provides local product workflows: + +- `init` +- `doctor` +- `scan` +- `review` +- `render` +- `baseline` + +The CLI is intentionally useful without a hosted backend. ## Finding model -Each normalized finding preserves: +A normalized finding can preserve: - category; - severity; - confidence; - scanner and rule ID; -- code/file location; -- CVE/CWE/OSV/GHSA identifiers where available; -- evidence; +- source location; +- CVE/CWE/OSV/GHSA identifiers; +- scanner evidence when safe; - remediation guidance; -- scanner-specific metadata. +- scanner-specific metadata; +- native scanner fingerprint. + +A correlated finding adds a SynSec fingerprint, a selected primary representation, duplicate/corroborating results, and the contributing scanner sources. + +## Failure semantics + +Security tooling must not confuse missing coverage with a clean bill of health. -The model is intentionally scanner-independent so multiple engines can contribute evidence to one logical vulnerability. +SynSec therefore distinguishes: -## Correlation +- scanner not selected; +- scanner selected but binary unavailable; +- scanner ran successfully with zero findings; +- scanner ran successfully with findings; +- scanner execution failed. -The first correlation implementation uses a deterministic fingerprint derived from the finding category, rule/identifier context, location, and title. This is only the bootstrap implementation. +At least one selected scanner must complete successfully for a report to be created. -Later versions should use progressively stronger correlation: +## AI/privacy boundary -1. exact fingerprints; -2. shared CVE/CWE/OSV/GHSA identifiers; -3. overlapping code locations; -4. equivalent source/sink data-flow paths; -5. semantic similarity; -6. repository graph context. +AI is disabled by default. -The result presented to the user should be one logical finding with multiple supporting scanner sources, not several duplicate alerts. +When enabled without source context, the provider receives normalized finding metadata only. Enabling `sendSourceContext` or `--ai-source` permits a small bounded excerpt around the affected line. Whole repositories are not sent by default. -## AI review layer +AI conclusions never overwrite or delete deterministic scanner evidence. This is important for auditing false positives, model disagreements, and future reviewer/verifier consensus. -The model layer should receive selected repository context rather than an entire repository by default. +## Reusable workflow direction -Context retrieval should eventually include: +The model-facing layer should evolve into small reusable defensive workflows instead of one giant prompt. Examples include dependency review, secrets review, IaC review, remediation review, and report drafting. -- imports and module relationships; -- route and controller ownership; -- authentication and authorization middleware; -- source-to-sink call paths; -- database access; -- configuration and deployment files; -- tests covering the affected code; -- version-control history relevant to the finding. +A workflow should declare the evidence it may read and the actions it may request. Repository-changing actions should require an explicit approval boundary. External network-assessment workflows belong to a separate authorized mode rather than inheriting repository permissions implicitly. -AI-generated conclusions must remain distinguishable from deterministic scanner evidence. Findings should preserve scanner evidence even when the AI layer changes severity, confidence, exploitability assessment, or remediation guidance. +## Current execution trust model -## Execution model +Repositories under analysis are untrusted input. v0.2 avoids directly executing their application/build scripts, skips symlinks in repository inventory, and invokes scanner binaries without a shell. Scanner subprocesses also use a credential-minimized environment and constrained command lookup as defense in depth. -Local development starts with scanner binaries installed on the host. Containerized workers can be added later for isolation and reproducibility. +External scanners still have their own parsers, archive handlers, network behavior, and implementation risks. Hosted production operation therefore requires externally enforced disposable container/sandbox controls rather than treating the Node child-process boundary as isolation. SynSec's production isolation profile can fail closed on a reviewed declaration of those controls, but it does not itself create or certify the sandbox. -Long term, scan workers should be disposable and should receive the minimum credentials necessary to clone or inspect the requested repository. +## Repository graph direction -## Security boundaries +The current structural graph already includes bounded local module relationships, same-file lexical calls, conservative route/handler mapping, authentication and sensitive-sink signals, test ownership, and coverage context. The next intelligence steps should deepen these signals without overstating them, including: -SynSec is repository-first and defensive by default. External attack-surface scanning or bug-bounty workflows are secondary modes and require explicit authorization boundaries. +- framework-aware cross-module handler resolution where imports/exports are unambiguous; +- bounded cross-module call relationships; +- source-to-sink/data-flow analysis with explicit uncertainty handling; +- dependency reachability beyond observed imports; +- richer framework middleware/controller composition; +- stronger linkage to relevant tests and version-control context. -Repositories under analysis must be treated as untrusted input. Scanner workers should eventually use sandboxing because repositories can contain malicious build scripts, symlinks, archives, and configuration designed to affect analysis tooling. +These graph layers should improve triage and fix suggestions without requiring the model to ingest an entire repository for each finding and without authorizing live-target probing. diff --git a/docs/CROSS_MODULE_CALL_EVIDENCE.md b/docs/CROSS_MODULE_CALL_EVIDENCE.md new file mode 100644 index 00000000..bf9be5d3 --- /dev/null +++ b/docs/CROSS_MODULE_CALL_EVIDENCE.md @@ -0,0 +1,92 @@ +# Cross-module call evidence + +SynSec's lexical call graph deliberately resolves only unambiguous same-file function calls. Cross-module call evidence is a separate, stricter layer so repository intelligence can improve without turning import syntax into a claim of runtime reachability. + +## Supported evidence + +`@synsec/repository/import-call-links` can connect an unresolved lexical call to another indexed repository file only when all of the following are true: + +1. the repository module graph already resolved the import to exactly one local file; +2. the source line contains an explicit supported import binding; +3. the call uses that exact local binding; +4. the imported function name maps to exactly one lexical function node in the resolved target file; +5. the imported local binding has not been conservatively detected as shadowed inside the calling function before the call; and +6. the operation remains inside the configured file, source-size, binding, and link bounds. + +The initial supported forms are deliberately narrow: + +- JavaScript/TypeScript named imports, including `as` aliases; +- JavaScript/TypeScript namespace imports followed by one direct member call; +- CommonJS destructured `require()` bindings; +- Python `from ... import ...` bindings, including `as` aliases; and +- Python module imports followed by one direct member call when the module graph has already proven the module is repository-local. + +Default imports, star imports, re-export chains, computed member access, nested member chains, dynamic import bindings, ambiguous local aliases, ambiguous target functions, and unresolved/external modules are omitted rather than guessed. Local declarations, assignments, and function parameters that can shadow a supported import binding also cause that call link to be omitted. The shadowing check intentionally prefers false negatives over manufacturing a repository-local call edge. + +## Imported Node route handlers + +The composed route-flow analyzer can also resolve a simple named Node HTTP route handler when the handler is imported from another repository-local file. This applies only after ordinary same-file route-handler resolution has failed. + +The imported route-handler path is narrower than general JavaScript module semantics. It currently accepts only: + +- ES named imports, including a single `as` alias; and +- destructured CommonJS `require()` bindings, including a simple property alias. + +The module graph must already resolve the import to exactly one repository file, the imported name must map to exactly one lexical function node in that file, and the target module must contain explicit matching named-export evidence. For ES modules SynSec accepts a direct named export or a same-name export list; for CommonJS it requires a matching `exports.` / `module.exports.` assignment or a same-name object export. A merely same-named unexported local function is not sufficient evidence. + +SynSec must also not observe a declaration, assignment, or function parameter that could shadow the imported local name before the route registration. Multiple viable targets remain unresolved. + +Default imports, namespace-member route handlers, re-exports, dynamic expressions, external packages, unresolved module targets, missing export evidence, and ambiguous target functions are intentionally not inferred. Resolved imported handlers are labeled `imported-named-function`; that label denotes static repository evidence only. + +This lets a pattern such as an Express router importing `listUsers` from a handlers module participate in route-to-sink analysis without pretending that arbitrary framework wiring or dependency injection has been resolved. + +## Composed analysis + +`@synsec/repository/route-flow-analysis` is the composition API for callers that need route-flow context. Given an existing repository index and module graph, it builds the bounded lexical call graph, explicit local import-call links, route entrypoints, and route-to-sink contexts using one consistent call-depth/node budget. + +Before lexical source analysis, the composition layer independently revalidates the supplied file list against the repository root. It accepts only regular non-symlink files inside the root and uses the actual filesystem size instead of trusting caller-supplied size metadata. The aggregate result records `inputFileCount`, `analyzedFileCount`, `skippedUnsafeFileCount`, and `truncatedFileCount`. + +File coverage is also explicit: + +- `complete-input` means every supplied inventory entry was considered within the configured file-count bound, although unsafe entries may still have been rejected and are separately counted; +- `bounded-input` means one or more supplied inventory entries were outside the configured analysis bound and were not lexically analyzed. + +The default and absolute `maxFiles` ceiling is 5,000. Callers may choose a lower positive bound, but invalid or excessive bounds fail closed instead of being silently clamped. A `bounded-input` result must not be presented as proof that the entire repository was analyzed. This is especially important when route-flow evidence is used for finding prioritization. + +This preflight is defense in depth for integrations that do not obtain their file inventory through SynSec's normal repository walker. The composition API performs no network access and does not execute repository code. A caller is still responsible for passing an index and module graph derived from the same repository revision and inventory. + +## Scan-engine integration + +The normal `runScanEngine()` enrichment path now uses the composed route-flow analyzer whenever the repository index contains both routes and sinks. That means exact sink findings can receive bounded evidence through supported explicit repository-local imports instead of being limited to same-file lexical calls. + +The engine still treats this as additive context only. It does not lower scanner findings because a route link is absent, and it does not convert structural evidence into a claim of exploitability or runtime reachability. Secret findings remain excluded from repository-context enrichment entirely. + +## Route-to-sink use + +`@synsec/repository/route-sink-flow` may optionally consume the import-call graph. When it does, bounded route traversal can cross one of the explicit local import links and then continue through ordinary same-file lexical calls in the imported module. + +Route-flow output records `callScope` as either: + +- `same-file`; or +- `same-file-and-explicit-imports`. + +Finding enrichment still requires an exact repository path and sink line match. Source text, import source text, scanner diagnostics, and credentials are not copied into the route-flow metadata. + +## Security interpretation + +All of this remains static structural evidence. The interpretation strings are intentionally explicit: + +- `cross-module-import-call-evidence-only`; +- `repository-structural-route-flow-evidence-only`; and +- `structural-route-call-sink-evidence-only`. + +A linked import/call or imported route handler does **not** prove that: + +- the route is deployed or internet-accessible; +- a request can reach the call at runtime; +- attacker-controlled data reaches the sink; +- branch conditions permit the path; +- dependency injection or monkey-patching did not replace the target; or +- the sink is exploitable. + +SynSec must not use this evidence to authorize live-target probing, exploitation, secret retrieval, persistence, or expansion beyond the repository scan target. Its purpose is defensive review prioritization and more useful repository-local context. diff --git a/docs/DEPENDENCY_USAGE.md b/docs/DEPENDENCY_USAGE.md new file mode 100644 index 00000000..67c6058b --- /dev/null +++ b/docs/DEPENDENCY_USAGE.md @@ -0,0 +1,36 @@ +# Dependency usage evidence + +SynSec can attach repository import context to dependency, container, and supply-chain findings. This context is intended to help triage vulnerable packages; it is not a claim that a package executes in production or that a vulnerable code path is reachable. + +## Resolver-aware evidence + +The scan engine builds the repository module graph once and uses it for both conservative incremental planning and dependency usage enrichment. `findExternalDependencyUsage()` compares raw import records with that graph before labeling a package `observed-import`. + +Imports that SynSec has uniquely resolved to repository files are excluded from third-party dependency evidence. This matters especially in Python, where an absolute import can name either a local package or an installed distribution. For example, when the repository contains an explicit `service/__init__.py` and `from service.db import load` uniquely resolves to `service/db.py`, a dependency finding whose package is named `service` does not receive an `observed-import` signal from that local edge. + +Resolution fails closed. If local module identity is ambiguous, SynSec does not guess that the import is local; the unresolved import remains eligible third-party evidence. A simultaneous `service/db.py` and `service/db/__init__.py`, for example, is not used to suppress dependency usage evidence. + +## Report shape + +Resolver-aware usage contains the existing package name, status, and bounded import evidence plus: + +- `excludedRepositoryLocalImportCount`: how many matching imports were removed because they uniquely resolved to repository files; +- `interpretation: observed-import-evidence-not-runtime-reachability`. + +Evidence is capped at 100 records even when a caller requests a larger limit. The normal engine path uses the smaller default bound. + +## Security meaning + +`observed-import` means only that SynSec observed unresolved/external import syntax matching the dependency name. It does not establish that: + +- the imported module executes on a real request or job; +- the vulnerable function or version-specific code path is used; +- attacker-controlled data can reach the package; +- a route is deployed or externally accessible; +- the scanner finding is exploitable. + +Those questions require stronger call/data-flow, framework, runtime, or scanner-native reachability evidence. SynSec keeps the import signal separate so triage can benefit from repository context without promoting syntax into a vulnerability proof. + +## Defensive scope + +Dependency usage analysis reads repository index data only. It does not import project modules, execute package code, contact package services, probe live targets, or expand the selected repository scope. diff --git a/docs/DJANGO_ROUTE_RESOLUTION.md b/docs/DJANGO_ROUTE_RESOLUTION.md new file mode 100644 index 00000000..f40b7ef4 --- /dev/null +++ b/docs/DJANGO_ROUTE_RESOLUTION.md @@ -0,0 +1,54 @@ +# Django URLConf structural resolution + +SynSec can connect a narrow class of Django URL registrations to repository-local function views so existing route-to-sink, request-input, authorization-context, and exact finding-correlation analysis can continue past `urls.py`. + +This is static structural evidence only. A resolved URLConf entry does not prove that Django loads the module, that the route is reachable in a deployed URL tree, that middleware executes, that a request value is attacker-controlled, or that a sink is exploitable. + +## Supported shape + +SynSec resolves only an explicit function identifier in a Django `path()` registration: + +```python +from .views import create_user as create_user_view + +path("users/", create_user_view, name="create-user") +``` + +The identifier must resolve uniquely to either: + +- one Python function in the same URLConf file; or +- one unshadowed `from module import function [as alias]` binding whose module graph target is a unique repository-local Python file containing one matching function declaration. + +The existing module-graph rules still apply. Relative imports must remain inside the supplied repository inventory. Absolute imports count as repository-local only when their first segment is an explicit top-level Python package in that inventory. + +## Deliberately unresolved forms + +SynSec fails closed for forms whose runtime meaning would require more interpretation than the current evidence model can justify, including: + +- dotted members such as `views.create_user`; +- class-based views such as `AdminView.as_view()`; +- lambdas, wrappers, factories, and other call expressions; +- wildcard imports; +- parenthesized/multiline import lists; +- imports shadowed before the route registration; +- duplicate same-name local/imported candidates; +- missing, symlinked, oversized, or path-escaping source files; +- external or ambiguous module targets. + +These cases remain `unresolved`; SynSec does not select a likely view. + +## Downstream evidence + +Once a view is resolved, the ordinary bounded call graph is used. This allows the same structural route evidence already used elsewhere in SynSec to include Django function views. For example, an explicit `request.POST` access passed directly into one resolved call can participate in the existing request-source/call/sink model, and a scanner finding can correlate only when the finding location exactly matches an already-linked sink line. + +The interpretation labels do not change: + +- route/call relationships remain `structural-route-call-evidence-only`; +- request source/call/sink relationships remain `structural-request-source-call-sink-evidence-only`; +- aggregate repository route analysis remains `repository-structural-route-flow-evidence-only`. + +None of those labels imply runtime reachability, exploitability, effective authentication/authorization, or absence of vulnerabilities. + +## Resource and trust boundaries + +Django resolution does not import or execute repository Python code and performs no network access. Source reads are restricted to the already-supplied repository file inventory, reject path escape and symlinks, and retain the repository analysis source-size bounds. Repository text, import statements, route metadata, and downstream scanner findings remain untrusted input. diff --git a/docs/DJANGO_URLCONF_COMPOSITION.md b/docs/DJANGO_URLCONF_COMPOSITION.md new file mode 100644 index 00000000..b9f678b5 --- /dev/null +++ b/docs/DJANGO_URLCONF_COMPOSITION.md @@ -0,0 +1,29 @@ +# Django URLConf include composition + +SynSec can compose a bounded structural route identity through an explicit Django URLConf include such as: + +```python +path("api/", include("accounts.urls")) +``` + +when the literal module name maps to exactly one supplied repository Python file and that target URLConf already contains a uniquely resolved function-view route. The composed identity can then participate in the existing exact route-to-sink and request-input/source correlation pipeline. + +For example, a parent `path("api/", include("accounts.urls"))` and a child `path("users/", create_user)` can produce structural route evidence for `api/users/`. Nested literal includes are followed only to a bounded depth and the total number of composed routes is bounded. + +## Fail-closed boundary + +The composition layer does not import or execute repository Python code. It follows only literal `include("module.name")` values that map to exactly one supplied `module/name.py` or `module/name/__init__.py` file. Ambiguous module files, dynamic include expressions, callable include targets, tuple/list URLConfs, cycles without a structural root, unsafe files, symlinks, and inputs beyond the configured bounds do not produce composed evidence. + +Existing direct child entrypoints are intentionally retained. Static repository analysis cannot prove which URLConf Django selects as `ROOT_URLCONF`, whether a URLConf is mounted in more than one deployment configuration, or whether a repository fragment is active at runtime. + +## Security interpretation + +A composed Django route is labeled as `Django URLConf include` structural evidence. It is **not** proof of: + +- `ROOT_URLCONF` selection or Django settings activation; +- execution of `include()` or namespace behavior; +- middleware execution or effective authorization; +- runtime or attacker reachability; +- exploitability or absence of a vulnerability. + +The feature exists to make repository review and exact finding correlation more useful without converting static URL relationships into runtime security claims. diff --git a/docs/EXPRESS_ROUTER_COMPOSITION.md b/docs/EXPRESS_ROUTER_COMPOSITION.md new file mode 100644 index 00000000..848cc5af --- /dev/null +++ b/docs/EXPRESS_ROUTER_COMPOSITION.md @@ -0,0 +1,19 @@ +# Express router composition + +SynSec performs a bounded structural analysis for a narrow subset of Express router mounting so repository findings can retain exact route context across files. + +Supported shapes include explicit `express()` application roots, explicit `express.Router()` or unaliased `Router()` router declarations, literal `app.use("/prefix", childRouter)` / `router.use("/prefix", childRouter)` mounts, and repository-local default imports or CommonJS `require()` bindings whose target exports exactly one router as the module default value. + +For a successfully resolved chain such as `app.use("/api", usersRouter)` plus `router.get("/users/:id", getUser)`, SynSec may emit a composed `/api/users/:id` structural entrypoint while preserving the already-resolved handler and bounded call/sink evidence. + +## Fail-closed cases + +No composed evidence is emitted for dynamic prefixes, router factories, member-expression child routers, unresolved or ambiguous module edges, non-default export shapes, shadowed imported bindings, use-before-declaration, cycles beyond the configured bound, unsafe/symlinked files, or unsupported multiline/dynamic registration forms. + +The default mount-depth bound is 8 and can be lowered through `maxExpressMountDepth`; composed output is separately bounded through `maxExpressComposedRoutes`. + +## Security interpretation + +The composition label is `structural-express-router-composition-not-runtime-reachability`. + +It is evidence about source structure only. It does not prove that Express loads the module, executes the mount, serves the route, preserves the observed middleware order, makes the route externally reachable, accepts attacker-controlled input, or is exploitable or non-exploitable. Runtime deployment configuration and authorization remain separate trust boundaries. diff --git a/docs/FASTAPI_ROUTER_COMPOSITION.md b/docs/FASTAPI_ROUTER_COMPOSITION.md new file mode 100644 index 00000000..cf2ff975 --- /dev/null +++ b/docs/FASTAPI_ROUTER_COMPOSITION.md @@ -0,0 +1,58 @@ +# FastAPI router composition + +SynSec can compose a deliberately narrow subset of FastAPI `APIRouter` prefix wiring so repository findings can retain a more exact structural route identity across repository-local router modules. + +## Supported evidence + +The analyzer accepts only explicit one-line forms that can be checked without importing or executing repository code: + +```python +from fastapi import APIRouter +router = APIRouter(prefix="/users") + +@router.get("/{user_id}") +def get_user(user_id): + ... +``` + +and a repository-local include such as: + +```python +from .users import router as users_router +app.include_router(users_router, prefix="/api") +``` + +For that shape SynSec may attach `/api/users/{user_id}` to the already-resolved handler and its existing bounded call/sink evidence. Nested `router.include_router(...)` relationships are also followed within configured depth and output limits. + +Imported routers must use one explicit named Python import that resolves through SynSec's repository module graph to exactly one supplied file containing one matching `APIRouter` declaration. Imported bindings must remain unshadowed at the include site. Same-file routers must be declared before they are used. + +## Fail-closed cases + +SynSec does not compose a route when it encounters unsupported or ambiguous wiring, including: + +- dynamic or computed prefixes; +- router factories or other call expressions used as the included router; +- dotted/member router references; +- aliased or shadowed `APIRouter` constructors; +- wildcard or parenthesized imports; +- duplicate matching router declarations; +- repository imports that do not resolve uniquely; +- a same-file router used before its declaration; +- traversal beyond configured include depth or output bounds; +- repeated router nodes in a nested include path. + +Unsupported wiring stays unresolved instead of being guessed. + +## Security interpretation + +Composed routes carry the label: + +`structural-fastapi-router-composition-not-runtime-reachability` + +This means SynSec observed a bounded static source relationship. It does **not** prove that Python imports succeed, that FastAPI executes an `include_router()` call, that a specific application object is deployed, that the route is reachable, that dependencies or middleware run, or that any request is attacker-controlled or exploitable. + +The composition layer reuses existing handler, call, request-input, and sink evidence. Those layers keep their own structural-only interpretation and bounds; a composed prefix does not upgrade any of them into runtime proof. + +## Resource bounds + +`buildRepositoryRouteFlowAnalysis()` exposes `maxFastApiIncludeDepth` and `maxFastApiComposedRoutes`. The router analyzer also inherits the aggregate repository analysis file-safety checks: path escapes, symlinks, missing/non-regular files, oversized source files, and files outside the supplied inventory are not analyzed. diff --git a/docs/FASTAPI_ROUTE_DEPENDENCIES.md b/docs/FASTAPI_ROUTE_DEPENDENCIES.md new file mode 100644 index 00000000..0faca45e --- /dev/null +++ b/docs/FASTAPI_ROUTE_DEPENDENCIES.md @@ -0,0 +1,74 @@ +# FastAPI route dependency evidence + +SynSec performs a deliberately narrow structural analysis of explicit FastAPI dependencies. The goal is to improve repository review context without converting framework syntax or authentication-looking names into claims about runtime security. + +## Supported evidence + +For a Python route decorator already recognized by the repository index, SynSec can inspect two bounded forms. + +Literal route-level dependency lists: + +```python +from fastapi import Depends +from .auth import require_user + +@router.get("/account", dependencies=[Depends(require_user)]) +def account(): + ... +``` + +And explicit one-line handler-parameter dependencies: + +```python +from fastapi import Depends, Security +from .auth import require_user, require_admin + +@router.get("/account") +def account(user = Depends(require_user)): + ... + +@router.get("/admin") +def admin(user: object = Security(require_admin)): + ... +``` + +The dependency wrapper must be an unaliased `Depends` or `Security` name explicitly imported from `fastapi` before the use site and must remain unshadowed there. Each dependency expression must be exactly `Depends(name)` or `Security(name)` with a simple Python identifier. Handler-parameter evidence additionally records the parameter name and whether the evidence came from the handler signature or the route-level list. + +A dependency identifier resolves only when SynSec finds exactly one of these targets: + +- one unique same-file Python function that is already defined before the dependency use site; or +- one unshadowed explicit repository-local `from module import name [as alias]` binding whose module graph resolves to one repository file containing one matching function. + +For a resolved dependency, SynSec may collect existing lexical authentication, authorization, session, or token signals from that function and its bounded same-file call neighborhood. This evidence is labeled `structural-fastapi-dependency-evidence-not-runtime-protection`. + +## Fail-closed cases + +SynSec deliberately does not resolve: + +- dependency factories such as `Depends(build_guard())`; +- dotted or member expressions such as `Depends(auth.require_user)`; +- dynamically constructed dependency lists; +- multiline handler signatures containing dependency injection; +- lambda or nested expressions; +- wildcard or parenthesized repository imports; +- ambiguous same-name functions or module targets; +- imported dependency names that are reassigned, redeclared, rebound by another import, or otherwise shadowed before the use site; +- `Depends` or `Security` wrappers that are aliased or shadowed locally. + +Unsupported or ambiguous dependency syntax contributes no positive runtime-security conclusion. Multiline signatures are intentionally omitted until SynSec has a parser-backed model that can preserve the same fail-closed semantics without approximating Python syntax. + +## Security semantics + +This analysis is structural repository evidence only. It does **not** prove that: + +- the route is registered or reachable at runtime; +- FastAPI executes the dependency for a particular deployment; +- a dependency successfully authenticates or authorizes a request; +- a security token is valid; +- parameter injection produces a particular runtime value; +- a request cannot bypass another runtime path; +- the route, dependency, or downstream call is exploitable or non-exploitable. + +Authentication-looking function or parameter names are not evidence by themselves. SynSec reports auth-related context only when an existing lexical auth signal appears inside the resolved bounded dependency call scope. + +This layer intentionally complements, rather than replaces, runtime tests, framework configuration review, deployment verification, and application-specific authorization analysis. diff --git a/docs/FINDING_LIFECYCLE.md b/docs/FINDING_LIFECYCLE.md new file mode 100644 index 00000000..98b66c6b --- /dev/null +++ b/docs/FINDING_LIFECYCLE.md @@ -0,0 +1,92 @@ +# Finding lifecycle and review governance + +SynSec keeps human triage decisions separate from scanner evidence. A scanner report says what tools observed. The lifecycle store records what reviewers decided about those normalized finding fingerprints. + +## Lifecycle states + +SynSec supports these finding states: + +- `new` — observed without an earlier lifecycle decision. +- `confirmed` — a reviewer has confirmed the finding should be addressed. +- `false-positive` — a reviewer has decided the normalized finding is not actionable as reported. +- `accepted-risk` — a reviewer has explicitly accepted the risk for now. +- `fixed` — a previously actionable finding disappeared after SynSec had enough repeated scan coverage to conclude absence. +- `regressed` — a finding previously recorded as fixed has returned. + +Human decisions such as `false-positive` and `accepted-risk` are preserved when later scans omit a finding. SynSec does not silently rewrite them merely because one report no longer contains the fingerprint. + +Incremental scans also do not call an out-of-scope finding fixed. A disappearance is meaningful only when the affected path was covered and a detecting scanner reran. + +## Ownership, notes, and comments + +Lifecycle records may contain a bounded owner and note. Review comments live in a separate append-only local store. These fields are human triage metadata and are deliberately not merged into scanner evidence. + +The local triage view/dashboard exposes current finding title/severity plus lifecycle state, owner, note, review deadline, and bounded review comments. It intentionally excludes source excerpts, raw scanner diagnostics, credentials, and arbitrary repository URLs. + +## Review deadlines + +A lifecycle record can carry an optional `reviewAt` ISO timestamp. The deadline is useful for decisions that should not remain permanent without reconsideration, especially `accepted-risk` records. + +`reviewAt` does **not**: + +- change finding state automatically; +- mark a finding fixed, confirmed, or regressed; +- change scan severity or confidence; +- affect GitHub check conclusions; +- count as scanner evidence; or +- trigger repository writes. + +It is governance metadata only. + +The sanitized triage view derives a presentation-only review status: + +- `scheduled` before the deadline; +- `due` at or after the deadline. + +A due deadline leaves the underlying lifecycle state unchanged. The local HTML triage dashboard labels it `Review overdue` so reviewers can prioritize it without SynSec manufacturing a security conclusion. + +## CLI usage + +The existing triage command can assign a review deadline using the `review-at` action and the bounded `--note` value already used for human triage metadata: + +```text +synsec triage report.json review-at --note 2026-11-01T12:00:00.000Z +``` + +Clear the deadline explicitly: + +```text +synsec triage report.json review-at --note clear +``` + +List current lifecycle records: + +```text +synsec triage report.json --list +``` + +Records with a deadline include `review:` in the CLI listing. The lifecycle API also exposes `setFindingReviewAt(...)` for callers that integrate SynSec programmatically. + +A state update may preserve an existing review deadline. Programmatic callers can explicitly set or clear the deadline while changing state through `setFindingState(...)`. + +## Accepted-risk review pattern + +A conservative workflow is: + +1. confirm that the finding identity and current repository context are understood; +2. record `accepted-risk` only through an explicit human triage action; +3. record the rationale as a bounded note/comment; +4. assign an owner when one is known; +5. set a concrete `reviewAt` date; +6. revisit the decision when the deadline becomes due; and +7. rescan/verify normally if remediation is performed. + +The review date is not a substitute for remediation verification. A finding is called fixed only through evidence-aware scan comparison, not because its risk-acceptance review date passed or a reviewer changed metadata. + +## Persistence and privacy + +The lifecycle store remains schema version 1; `reviewAt` is optional and therefore backward-compatible with existing records. Invalid timestamps fail validation rather than being accepted as ambiguous review metadata. + +Lifecycle and review-comment files are local bounded metadata stores. Remediation-verification JSON is written through a private atomic-shaped output path: a restrictive temporary file is completed before rename and the final file mode is repaired to `0600` where POSIX permissions are available. + +This lifecycle system does not authorize live-target testing, target expansion, persistence, secret exfiltration, or automatic repository modification. Repository-changing remediation remains a separate explicit approval-consuming workflow. diff --git a/docs/FLASK_BLUEPRINT_COMPOSITION.md b/docs/FLASK_BLUEPRINT_COMPOSITION.md new file mode 100644 index 00000000..312b585b --- /dev/null +++ b/docs/FLASK_BLUEPRINT_COMPOSITION.md @@ -0,0 +1,41 @@ +# Flask Blueprint composition + +SynSec performs a bounded structural analysis of explicit Flask `Blueprint` registration so repository findings can be correlated with composed route identities without executing application code. + +## Accepted shape + +The analyzer accepts only narrow, auditable forms: + +- one-line, unaliased `from flask import Flask, Blueprint` imports; +- exact `app = Flask(__name__)` application roots; +- exact `name = Blueprint("literal", __name__)` declarations, optionally with one literal `url_prefix`; +- exact `receiver.register_blueprint(name)` calls, optionally with one literal `url_prefix`; +- repository-local named Python imports that resolve uniquely to one matching Blueprint declaration and remain unshadowed before registration; +- named Blueprint route decorators such as `@users.get("/42")` and `@users.route("/42")` followed by one uniquely nearest Python function. + +Nested Blueprint registration is followed only up to configured depth and output limits. The repository root, file type, regular-file status, symlink status, and source-size bound are revalidated before source analysis. + +## Fail-closed cases + +SynSec produces no composed evidence for dynamic prefixes, Blueprint factories, dotted Blueprint references, imported Flask application roots, wildcard or parenthesized imports, ambiguous declarations, shadowed imports, use-before-definition, unresolved repository modules, cycles, or routes whose handler cannot be resolved uniquely inside the declaration bound. + +A `.route(...)` decorator is reported with method `ANY`; SynSec does not infer `methods=` semantics from arbitrary decorator arguments. Framework runtime behavior is not executed or simulated. + +## Security interpretation + +Composed entries carry: + +`structural-flask-blueprint-composition-not-runtime-reachability` + +This means the evidence supports a repository-level statement such as: a literal registration chain structurally connects a Flask app root to this Blueprint route and its bounded static call/sink evidence. + +It does **not** prove that: + +- Flask imports or registers the Blueprint in a deployed process; +- the route is externally reachable; +- a reverse proxy exposes the route; +- authentication or authorization is effective; +- request data reaches a sink at runtime; +- a finding is exploitable or non-exploitable. + +Dynamic or ambiguous application behavior remains unresolved rather than being converted into a security claim. diff --git a/docs/GIN_REQUEST_INPUT_FLOW.md b/docs/GIN_REQUEST_INPUT_FLOW.md new file mode 100644 index 00000000..73a54b66 --- /dev/null +++ b/docs/GIN_REQUEST_INPUT_FLOW.md @@ -0,0 +1,66 @@ +# Gin request-input flow evidence + +SynSec exposes two deliberately narrow Go/Gin request-source analyses. Direct evidence is available through `@synsec/repository/gin-request-input-flow`; one-local forwarding evidence is available through `@synsec/repository/gin-request-input-forwarding`. The default `buildRepositoryRouteFlowAnalysis()` path produces both as `ginRequestInputFlows` and `ginRequestInputForwardingFlows`. + +## Direct source-to-sink evidence + +The direct analyzer can produce structural source-to-sink evidence only when all of these conditions hold: + +- the source file is a bounded, regular, non-symlink `.go` file inside the repository root; +- the file imports `github.com/gin-gonic/gin` without an alias; +- the source-owning function declaration line contains exactly one explicit `*gin.Context` parameter; +- the source is one of the direct context accessors `Query`, `PostForm`, `Param`, `GetHeader`, or `Cookie` on that exact context binding; +- the access occurs on the exact sink line, or on the same line as a call-graph edge directly to the sink-owning function; +- the route has already been resolved as a Gin route by SynSec's existing bounded route and call analysis. + +Direct evidence is labeled: + +`structural-gin-context-source-direct-call-sink-evidence-only` + +## Single-use local forwarding + +The separate forwarding analyzer recognizes only a bounded shape equivalent to: + +```go +term := c.Query("q") +runQuery(term) +``` + +The source accessor is limited to the single-value `Query`, `PostForm`, `Param`, and `GetHeader` methods. The local must be introduced with `:=`, have exactly one later identifier occurrence in the containing function, remain within the configured forward-line bound, and be passed unchanged as the sole argument of one exact call. That call must either be the sink line itself or resolve directly to the sink-owning function. + +Forwarding evidence is labeled: + +`structural-gin-context-source-single-use-local-call-sink-evidence-only` + +This is evidence for one syntactically unchanged local hop. It is not a claim that Go locals are immutable by language semantics. Reassignment, a second use, aliasing, transformation, or another occurrence anywhere later in the function makes the analyzer fail closed. + +Finding correlation for both layers is exact on the sink path and line. Request keys, values, source expressions, SQL text, and other repository content are not copied into finding evidence. + +## Deliberate exclusions + +These analyses are not a general Go taint engine. In particular, SynSec does not infer directional flow for: + +- `ShouldBind`, `ShouldBindJSON`, `Bind`, `BindJSON`, or other APIs that populate an object through mutation; +- `Cookie` values stored in locals, because Gin's cookie accessor has multi-value return semantics; +- locals that are reassigned, used more than once, or forwarded beyond the configured line bound; +- transformed, concatenated, indexed, destructured, or aliased values; +- calls beyond one exact source-bearing outbound edge after the optional one-local hop; +- aliased or dot-imported Gin packages; +- values merely named `ctx`, `context`, `request`, or similar; +- custom types exposing methods named `Query`, `Param`, and so on; +- request-source-looking text outside a uniquely identified `*gin.Context` function. + +Those cases require additional bounded data-flow semantics and are omitted rather than guessed. + +## Security interpretation + +A result is repository-structural review evidence. It does **not** prove: + +- that Gin registers or serves the route in a running deployment; +- that the request value is attacker-controlled in a concrete execution; +- that middleware or authorization succeeds or fails; +- that a database/process/filesystem/network operation is exploitable; +- that sanitization is absent; +- that a missing result implies safety. + +Use this evidence to prioritize review of an exact repository path, not as a runtime-security verdict. diff --git a/docs/GIN_ROUTER_COMPOSITION.md b/docs/GIN_ROUTER_COMPOSITION.md new file mode 100644 index 00000000..ee272d38 --- /dev/null +++ b/docs/GIN_ROUTER_COMPOSITION.md @@ -0,0 +1,30 @@ +# Gin router composition + +SynSec can derive bounded structural route evidence for a deliberately narrow subset of Go services using `github.com/gin-gonic/gin`. + +## Accepted shape + +The analyzer requires one unaliased Gin import, a direct root declaration using `name := gin.Default()` or `name := gin.New()`, and one-line route/group construction. Literal groups may be nested to a bounded depth: + +```go +router := gin.Default() +api := router.Group("/api", requireUser) +jobs := api.Group("/jobs") +jobs.POST("/run", audit, runJob) +``` + +The final plain identifier in a route registration is treated as the handler. Preceding route callbacks and inherited plain-identifier group callbacks are retained separately as middleware attachment evidence. + +Go lexical call-graph support is also bounded. SynSec recognizes ordinary functions and methods with brace-delimited bodies, records direct same-file calls only when one unique function name exists, and resolves a Gin handler only when one unique Go function with that name exists in the same package directory. A route can therefore participate in exact sink correlation through its bounded same-file call neighborhood. + +## Fail-closed cases + +SynSec emits no composed Gin evidence for dynamic group prefixes, aliased Gin imports, router factories, member-expression or transformed handlers, transformed middleware, reassigned router/group bindings, ambiguous same-package handler names, unsafe/symlinked/oversized files, unsupported syntax, or composition beyond configured bounds. + +Same-package resolution is a directory-level structural approximation. SynSec does not infer build tags, generated files, module replacement behavior, runtime registration order, or whether a particular binary includes the analyzed files. + +## Security interpretation + +Gin route evidence is static repository evidence only. `structural-route-call-sink-evidence-only` does not establish runtime reachability, attacker control, exploitability, or effective authorization. `structural-gin-route-middleware-attachment-not-runtime-protection` records only that middleware identifiers are syntactically attached through accepted Gin route/group forms; it does not prove Gin executes them or that they successfully protect a request. + +Repository content remains untrusted. Source reads are bounded and confined to regular non-symlink files inside the supplied repository root. Dynamic or ambiguous shapes are omitted rather than guessed. diff --git a/docs/GITHUB.md b/docs/GITHUB.md new file mode 100644 index 00000000..cf899507 --- /dev/null +++ b/docs/GITHUB.md @@ -0,0 +1,164 @@ +# GitHub integration + +SynSec's GitHub integration is intentionally split into two layers: + +1. **Repository security analysis** stays inside the normal scanner/report pipeline. +2. **GitHub publication** converts a completed SynSec report into GitHub-native checks, annotations, and optional SARIF/code-scanning output. + +This keeps GitHub credentials out of scanners and prevents repository analysis from silently expanding into unrelated network targets. + +## Current integration primitives + +`@synsec/github` provides: + +- GitHub Actions context detection from environment variables. +- Bounded parsing of `GITHUB_EVENT_PATH`. +- Correct pull-request head SHA selection from the event payload instead of the synthetic merge SHA. +- Pull-request number, base branch/SHA, and head branch resolution. +- Conversion of a `SynSecReport` into a check-run result. +- Source annotations for findings with file/line locations. +- Severity-aware annotation levels. +- Baseline-aware annotation filtering so PR checks can focus on new findings. +- A hard 50-annotation cap per generated payload. +- CI threshold evaluation, including an explicit `none` threshold that never fails a check. +- A narrow Checks API publisher with an injectable transport for testing. +- Completed-report publication orchestration with report/head commit binding. +- A GitHub Actions repository scan runner that reuses the normal scan engine. +- Bounded local baseline loading with optional PR-base commit validation. +- Automatic PR-base baseline generation from an exact commit already present in the local checkout. +- Fixed-host gzip/base64 SARIF publication to GitHub code scanning. +- A completed JSON report artifact path suitable for explicit retention by the caller. + +The root `action.yml` packages these primitives as a composite GitHub Action. It builds the checked-in SynSec runtime, scans only the repository represented by `GITHUB_WORKSPACE`, and publishes the completed report using the caller-provided GitHub token. + +## Pull-request SHA handling + +GitHub Actions commonly sets `GITHUB_SHA` to a synthetic merge commit for `pull_request` workflows. Publishing a check against that SHA can make the check appear on the wrong commit or disappear when the synthetic merge ref changes. + +For PR events, SynSec therefore prefers `pull_request.head.sha` from the local Actions event payload and also retains `pull_request.base.sha` for baseline validation. `loadGitHubContext()` reads `GITHUB_EVENT_PATH`, rejects non-files, refuses event payloads larger than 2 MiB, parses JSON locally, and then resolves the effective repository/commit context. + +No network request is required for context detection. + +## Repository scan runner + +`runGitHubActionsRepositoryScan()` accepts a normal `SynSecConfig`, optional baseline, checkout root, publication settings, and caller-supplied token. The scan path deliberately reuses `runScanEngine()` rather than creating GitHub-specific scanners. + +For pull requests, changed-file scanning defaults to `origin/...HEAD`. Callers can override changed-file mode or the base ref explicitly. Push, schedule, workflow-dispatch, and other non-PR contexts default to a full repository scan. + +Before publication, the runner requires the scan report to identify its commit. The publication layer refuses a report whose commit differs from the GitHub commit being annotated. This prevents a stale report from being attached to a newer PR head. + +The runner does not clone arbitrary targets, expand repository scope, perform live-target probing, or create repository writes. + +## Baselines + +A caller can provide a baseline report in memory or as a local `baselinePath`. `loadValidatedGitHubBaseline()` bounds local baseline files to 20 MiB, parses them through the normal report reader, and by default requires the baseline report's commit to match the pull-request base SHA from the event payload. An explicit expected commit can be supplied for non-PR or synthetic contexts. + +For PR runs without an explicit baseline, `autoBaseline` can generate one from the exact `pull_request.base.sha`. `scanGitHubBaseCommit()` first proves that SHA is a local Git commit, creates a temporary detached Git worktree at that commit, runs a full repository scan there, requires the generated report to identify the same commit, and removes the worktree afterward. It never invokes `git fetch`, follows a repository-supplied remote URL, or mutates the caller's checkout. + +The composite Action enables this mode by default. Because SynSec intentionally does not perform an implicit network fetch, the PR base commit must already exist locally. Use `actions/checkout` with `fetch-depth: 0`, or otherwise fetch the exact base commit before SynSec. A shallow checkout that lacks the base commit fails with an explicit setup error instead of silently producing a baseline against the wrong revision. + +A missing baseline commit, missing expected base commit, stale baseline, or base-scan report whose commit does not match the requested base SHA fails before head publication. SynSec does not silently treat unverifiable baseline evidence as trustworthy. + +## Check conclusions + +The generated check conclusion follows the configured severity threshold: + +- `failure` when at least one finding meets or exceeds an enabled threshold. +- `neutral` when findings exist but none meets the enabled threshold, or `failOn` is `none`. +- `success` when the report contains no findings. + +This is deliberately separate from individual scanner process exit codes. Scanner failures and scan completeness remain engine/report concerns; GitHub publishing consumes the completed normalized report. + +## Inline annotations + +Only findings with a concrete repository path and start line can become GitHub annotations. Paths are normalized to forward slashes and leading `./` is removed. High/critical findings map to `failure`, medium/low to `warning`, and informational/unknown findings to `notice`. + +When a report includes a baseline, `buildGitHubCheck()` defaults to annotating only newly introduced findings. Persisting findings remain represented in the report summary without repeatedly flooding pull-request annotations. + +## Checks API publication + +`publishGitHubCheck()` posts completed check runs only to `https://api.github.com/repos///check-runs`. The repository comes from validated GitHub context, scanner output cannot control the request URL, redirects are rejected, and bearer tokens are not copied into returned errors. + +`publishSynSecReportToGitHub()` is the higher-level completed-report path. It resolves bounded local Actions context, validates report/head commit binding, builds the deterministic check, and invokes the fixed-host publisher. It never runs scanners or discovers targets itself. + +## SARIF/code scanning + +`publishGitHubSarif()` converts the already-completed SynSec report to SARIF 2.1, gzip-compresses and base64-encodes it, and posts it only to `https://api.github.com/repos///code-scanning/sarifs`. + +The publisher: + +- requires the report commit to match the selected GitHub commit; +- uses `refs/pull//head` for pull requests so the ref corresponds to the PR head rather than the synthetic merge ref; +- requires a fully qualified ref outside PR contexts; +- enforces a 10 MiB compressed-payload bound; +- rejects redirects; +- keeps the token in the authorization header and redacts it from reflected error text. + +SARIF publication is opt-in in the Actions runner and composite Action because repositories may not grant `security-events: write`. + +## Composite GitHub Action + +The root `action.yml` exposes: + +- `github-token` — required publication token; +- `config-path` — optional path to `synsec.config.json` in the checked-out repository; +- `baseline-path` — optional local commit-bound baseline report; +- `auto-baseline` — PR-only local base-commit scan when no baseline path is supplied; defaults to `true`; +- `changed-only` — `auto`, `true`, or `false`; +- `publish-sarif` — optional code-scanning publication. + +It returns the security score, finding count, check-run id, optional SARIF upload id, `baseline-source` (`base-scan`, `file`, `provided`, or `none`), and `report-path`. The completed JSON report is written under `RUNNER_TEMP` rather than into the checked-out repository and is chmodded to `0600` where supported. Retention remains explicit: callers decide whether to upload or discard it. + +The Action intentionally does **not** silently download third-party scanner binaries. Selected scanners must already be available on `PATH`; this keeps scanner installation/version pinning explicit and avoids hiding supply-chain downloads inside the security scanner itself. A future containerized worker can improve scanner provisioning while retaining pinned artifacts and isolation. + +A minimal workflow should give SynSec only the permissions it needs and preserve base history for provenance-safe PR baselines: + +```yaml +permissions: + contents: read + checks: write + security-events: write # only needed when publish-sarif is true + +steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + # Install/pin the scanners selected by synsec.config.json here. + - uses: cmahmud/synsec@ + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + publish-sarif: "true" +``` + +Use the normal `pull_request` event for scanning pull-request code. Do **not** switch to `pull_request_target` merely to obtain a write-capable token: that event executes in the base-repository security context and can expose elevated credentials to workflows that inspect untrusted contributor code. For fork pull requests where GitHub intentionally withholds write permissions, publication should be treated as unavailable rather than weakening the trust boundary. + +## Scheduled repository scans + +Non-PR contexts already run a full repository scan, so scheduled scans use the same engine/publication path instead of a second orchestration implementation. `docs/examples/synsec-scheduled.yml` provides a cron + manual-dispatch template that checks out full history, runs SynSec with changed-file mode and PR auto-baselines disabled, optionally publishes SARIF, and retains the completed JSON report with `actions/upload-artifact`. + +The report is not uploaded automatically by SynSec. This keeps retention policy visible in the repository workflow and lets teams choose artifact duration rather than silently persisting security evidence. Scanner installation remains explicit and should be pinned by the repository owner. + +## Security boundaries + +GitHub integration must preserve the repository-first defensive model: + +- Tokens belong to the GitHub transport layer, never scanner input. +- Report and annotation generation must not require network access. +- Scanner output must never choose the GitHub API host or arbitrary publication URL. +- A report must not be published onto a different commit than the one it represents. +- A baseline must not be trusted for PR comparison without validated commit identity. +- Auto-baseline mode may inspect only the exact PR base commit already present in the local checkout and must not perform implicit remote fetches. +- Source excerpts are not added to GitHub annotations unless already present in normalized deterministic finding fields. +- Secret values must remain redacted before publication. +- Repository writes remain outside the scan/publication path and require explicit approval. +- Repository installation must not authorize live-target exploitation, target expansion, persistence, or secret exfiltration. + +## Next implementation steps + +The remaining Phase 5 work is primarily hosting/authentication and explicit remediation orchestration: + +1. Add a GitHub App installation/authentication layer using the same runner/publication primitives. +2. Add explicitly approved remediation pull requests. +3. Add GitLab and Bitbucket adapters without coupling the scanner core to one host. + +The deterministic packages should remain usable from both a GitHub App and GitHub Actions so the scanning core does not become hosting-provider-specific. diff --git a/docs/GITHUB_APP.md b/docs/GITHUB_APP.md new file mode 100644 index 00000000..f32ae4fe --- /dev/null +++ b/docs/GITHUB_APP.md @@ -0,0 +1,83 @@ +# GitHub App integration contract + +SynSec's GitHub App support is a transport and orchestration layer around the same repository-first scan engine used by the CLI and GitHub Action. Installing the App must not authorize live-target probing, arbitrary network assessment, secret exfiltration, persistence, or silent target expansion. + +## Implemented primitives + +`@synsec/github/app` currently provides: + +- constant-time `X-Hub-Signature-256` verification over the exact request bytes; +- a 10 MiB webhook-body bound before event processing; +- normalization for `pull_request`, `push`, `installation`, and `installation_repositories` events only; +- repository identity from `repository.full_name` rather than payload-controlled clone/API URLs; +- required installation and commit identity for scan-bearing events; +- an explicit scan-trigger policy: pushes and only `opened`, `reopened`, `synchronize`, and `ready_for_review` pull-request actions may enqueue scans; +- installation-management events are bookkeeping only and never scan triggers; +- short-lived RS256 GitHub App JWT creation; +- installation-token exchange only through `https://api.github.com/app/installations//access_tokens` with redirects rejected; +- bounded validation of returned expiration, repository-selection, and permission metadata; +- token/API errors that do not echo the App JWT. + +`@synsec/github/app-token-provider` is the concrete memory-only credential composition for workers. It signs a fresh short-lived App JWT for each repository operation, exchanges it through the fixed GitHub installation-token endpoint, rejects tokens that are too close to expiry, and can enforce purpose-specific permissions before returning the credential. The local runtime requires `contents:read` for acquisition, `checks:write` for publication, and `security_events:write` when SARIF upload is enabled. A `write` grant satisfies a corresponding `read` requirement, but a read-only grant never satisfies a write requirement. Tokens are not cached or persisted. + +`@synsec/github/replay-store` provides a durable local delivery-id replay store suitable for a single host or multiple worker processes sharing one filesystem. It uses bounded delivery identifiers, SHA-256-derived filenames, restrictive marker permissions, fully written/fsynced temporary records, and an atomic hard-link claim so two concurrent processes cannot both accept the same delivery or observe a partial canonical record. Retention is bounded between one hour and 30 days, expired markers can be pruned, and malformed existing records fail closed instead of being silently ignored. An accepted claim can also be released only when its exact delivery id and `receivedAt` still match the current unexpired marker; this lets a webhook handler return an error and allow GitHub retry after downstream durable processing fails without letting a stale worker delete a newer re-claim. + +`@synsec/github/installation-store` provides bounded durable installation authorization state. It persists only installation id, account identity/type, repository-selection mode, selected `owner/name` repository identifiers when selection is limited, suspension state, and update time. It deliberately has no fields for installation tokens, App private keys, webhook secrets, clone URLs, or repository credentials. Suspended or absent installations cannot authorize a repository scan. + +`@synsec/github/installation-sync` verifies and normalizes installation-management payloads into the minimal authorization model. Creation, deletion, suspension, unsuspension, and selected-repository add/remove events update durable state without persisting GitHub URLs, permissions, tokens, or arbitrary payload fields. Repository deltas fail closed when stored installation/account state is missing or inconsistent. A fresh `created` event replaces any stale selected-repository list rather than inheriting authorization left from an older record. + +`@synsec/github/scan-queue` provides a bounded durable local queue for commit-pinned scan work. Jobs contain only delivery id, installation/repository identity, exact head/base commit identity, PR identity when applicable, queue timestamps, lease state, and retry count. They do not contain GitHub tokens, clone URLs, App credentials, scanner output, source snippets, or arbitrary outbound targets. Workers lease jobs for a bounded period; expired leases can be reclaimed, failed jobs are retained for operator visibility, and attempt counts are bounded. + +`@synsec/github/app-handler` composes signature verification, replay claiming, installation-state synchronization, durable authorization, and queue dispatch in one tested boundary. Duplicate authenticated deliveries do not mutate installation state or enqueue duplicate work; installation-management events remain bookkeeping-only. If durable synchronization or queue dispatch fails after an accepted replay claim, the handler releases exactly that still-current claim before propagating the error so the delivery can be retried instead of being silently consumed. + +`@synsec/github/app-http` provides a framework-free Node HTTP request handler for mounting behind an HTTPS terminator or server. It accepts only POST requests at one configured path, requires JSON plus GitHub signature/event/delivery headers, bounds the raw body to 10 MiB before durable handling, emits `no-store` minimal responses, returns `202` only for queued scans, and does not reflect internal failure details. Durable-processing failures surface as generic `500` responses after replay-claim release so GitHub can retry. TLS termination, server-level connection/request timeouts, health endpoints, and deployment supervision remain hosting responsibilities rather than being silently embedded in this request handler. + +`@synsec/github/repository-acquisition` materializes exact commits from a strict `owner/name` identity through a fixed `https://github.com//.git` transport. It rejects URL-shaped repository identities before URL construction, disables system/global Git configuration and `file://` transport so local rewrite rules cannot redirect the request, keeps the installation token out of argv and repository config, skips Git LFS smudging/submodule initialization, checks out detached `FETCH_HEAD`, verifies each resulting HEAD against the requested SHA, and removes failed temporary workspaces. Pull-request acquisition can materialize the exact queued head and exact queued base into separate isolated workspaces with all-or-nothing cleanup. + +`@synsec/github/app-worker` consumes at most one leased queue job, rechecks installation authorization at execution time, acquires a short-lived token only for transport, verifies exact head/base acquisition provenance, scans through an injected repository-scan runner, requires the resulting head report to bind to the queued head SHA, obtains a fresh publication token, publishes through an injected GitHub transport, and acknowledges the queue only after publication succeeds. A repository removed or suspended after queueing is failed before credentials or source are acquired. Other worker failures return the job to the bounded retry queue. + +`@synsec/github/app-worker-runner` is the production-oriented local composition over that worker boundary. Push jobs run the existing `runScanEngine()` against the exact acquired head. Pull-request jobs first scan the exact queued base commit, require that baseline report to identify the queued base SHA, then scan the exact queued head using that report as the deterministic baseline. The resulting head report must identify the queued head SHA before fixed-host Checks/SARIF publication. These hosted PR scans are still full-repository scans at both commits; SynSec does not approximate changed-file execution from a branch name or perform an unbounded history fetch. + +`@synsec/github/app-runtime` composes the single-host local service primitives without opening a listener. It creates separate durable state and scanner-workspace directory trees, wires replay/installation/queue stores, the memory-only credential provider, the bounded webhook handler, and the configured worker. State and workspaces are forbidden from overlapping so repository source is not placed inside durable authorization/queue storage. The caller still owns TLS/listener binding, process/container isolation, network policy, deployment supervision, and operational secret injection/rotation. + +These primitives now form an end-to-end local hosting chain, but they still do **not** constitute a complete production hosted GitHub App service by themselves. Production TLS/runtime deployment, process/container isolation, operational secret management, setup UX, and shared transactional persistence for multi-host deployments remain required. + +## Webhook boundary + +Webhook consumers must preserve the raw request bytes until signature verification is complete. Do not parse and reserialize JSON before verifying the signature. + +After verification, callers should use the normalized event rather than payload URLs as the security boundary. Repository checkout and API publication derive from validated GitHub installation/repository identity through fixed GitHub transports. A `clone_url`, `html_url`, scanner-provided URL, finding text, or other repository-controlled field must never become an arbitrary outbound target. + +The preferred local HTTP boundary is `createGitHubAppWebhookHttpHandler()` mounted behind HTTPS. It bounds the body while reading it and delegates durable handling to `handleGitHubAppWebhook()`: signature verification and event normalization happen before the replay claim; duplicate authenticated deliveries stop before synchronization/dispatch; installation-management events synchronize durable authorization state; and scan-bearing events must pass `isRepositoryAllowed()` before queueing. Installation creation/removal and repository-selection changes never authorize immediate scanner execution by themselves. A transient durable-processing error releases only the handler's exact accepted replay claim and is then propagated so the HTTP layer can return failure to GitHub and receive a retry. + +## Queue and worker boundary + +Queue records are commit-pinned descriptors, not checkout instructions supplied by repository content. `runNextGitHubAppScanJob()` rechecks authorization after leasing so stale queued work cannot outlive a repository removal or installation suspension. + +Repository acquisition accepts only a strict validated `owner/name`, installation context supplied by the worker, and exact commit SHAs. The acquisition transport is fixed to `github.com`, and Git system/global configuration is disabled to prevent `url.*.insteadOf` or other host-local configuration from silently widening the destination. Missing/unavailable head or base provenance is a job failure rather than permission to substitute the default branch, a nearby commit, a webhook clone URL, or a scanner-suggested URL. + +The scanner receives checked-out workspaces and the queue descriptor, not the installation token. For pull requests, the exact base report is commit-bound before it can become the head baseline. Before publication, the worker requires `report.target.commitSha` to equal the queued head SHA and obtains a fresh installation token for the publication operation. `runConfiguredGitHubAppWorkerOnce()` then uses the same repository scan engine as the CLI/Action and fixed-host Checks/SARIF publishers. Successful workspace cleanup occurs after scan/publication handling. + +Leases prevent normal duplicate processing but the local queue is not a multi-host transactional lock. Horizontally scaled workers should use a shared queue with atomic claim/lease semantics. + +## Authentication and permission boundary + +`createGitHubAppJwt()` signs a short-lived RS256 token from the configured App id and private key. The private key belongs to the hosted transport/runtime and must never be exposed to scanners, reports, repository code, workflow prompts, logs, or persisted finding evidence. + +`createGitHubInstallationToken()` exchanges that JWT at GitHub's fixed API host. It requests no additional repository selection or permission expansion in the token request body. `createGitHubAppInstallationTokenProvider()` signs/exchanges afresh for each operation and keeps the resulting installation token in memory only. Repository acquisition supplies its token to Git only through a child-process environment and disables inherited Git configuration; publication likewise keeps credentials outside scanner inputs and reports. + +The local runtime validates GitHub-reported permission metadata before giving a credential to a worker operation. Missing or insufficient grants fail with the required permission names rather than falling through to a later scanner or publication failure. This is a runtime diagnostic, not a setup UI, and SynSec does not dynamically request broader permissions. + +## Required hosted-service work + +A production hosted App still needs: + +1. TLS/listener deployment around the bounded HTTP handler, with request/server timeouts, health handling, and supervision; +2. process/container workspace isolation around scans, including OS CPU/memory limits and network policy; +3. native changed-file execution for hosted PR scans if that optimization is enabled; exact base/head baseline provenance is already available and must remain authoritative; +4. explicit retention policy for reports, failed queue records, temporary artifacts, and operator diagnostics; +5. installation/setup UX, permission diagnostics/recovery, and operator-facing configuration validation; +6. operational secret injection and rotation for webhook secrets and App private keys; +7. transactional shared replay/installation/queue backends when horizontally scaled replicas do not share one durable filesystem. + +Until those pieces exist, the GitHub Action remains the complete packaged integration path and the App modules should be treated as tested single-host hosting foundations rather than a production multi-tenant service. diff --git a/docs/GITHUB_APP_ADMISSION_CONTROL.md b/docs/GITHUB_APP_ADMISSION_CONTROL.md new file mode 100644 index 00000000..b2da90ea --- /dev/null +++ b/docs/GITHUB_APP_ADMISSION_CONTROL.md @@ -0,0 +1,36 @@ +# GitHub App webhook admission control + +SynSec's hosted GitHub App listener bounds the amount of webhook application work that one process can execute concurrently. This is transport-level resource protection for the repository-security service; it does not replace signature verification, replay protection, installation authorization, durable queue fencing, process/container limits, or upstream rate limiting. + +## Runtime contract + +`createGitHubAppServer()` accepts `maxConcurrentWebhooks`. The default is 100 concurrent webhook handlers and the configured value must be an integer from 1 through 1,000. + +```ts +const server = createGitHubAppServer({ + host: "127.0.0.1", + port: 3210, + tlsMode: "terminated-upstream", + maxConcurrentWebhooks: 50, + webhookHandler: runtime.webhookHandler, + getStatus: runtime.getStatus, +}); +``` + +Only non-health requests consume a webhook handler slot. The aggregate-only health endpoint stays available while all webhook slots are occupied so a supervisor can still observe local runtime status. + +When the process has reached its configured webhook limit, the listener does not invoke the webhook handler. It returns HTTP `503` with `Retry-After: 1` and the fixed body `{ "status": "busy" }`. The response does not contain repository identity, delivery ids, request bodies, scanner output, credentials, queue records, or exception text. A refused request therefore cannot create a replay claim or queue record, and GitHub can retry delivery later. + +The slot is released in a `finally` path after the handler resolves or rejects. Handler exceptions continue to use the existing sanitized error boundary. + +## Deployment guidance + +Choose a limit based on the memory/CPU budget of the listener process and the capacity of its durable queue path. A higher number is not a substitute for horizontal scaling. The current filesystem-backed App runtime remains a single-host design; multiple hosts still require transactional shared authorization/replay/queue state with atomic insertion, fenced claims and renewals, and compare-and-set terminal transitions. + +An ingress or reverse proxy should also enforce its own connection, request-body, and rate limits. SynSec's in-process admission limit starts after Node has accepted an HTTP request, so externally enforced limits remain necessary against connection floods or clients that never complete request bodies. + +## Security boundary + +Admission control never expands repository scope and never grants capabilities. Requests admitted to the handler still pass through exact-body HMAC verification, durable replay protection, installation/repository authorization, commit-pinned dispatch, and execution-time authorization checks. Refused requests do not bypass those controls; they do not enter the handler at all. + +This feature does not implement scanner sandboxing, host firewall policy, shared multi-host persistence, or autonomous target assessment. \ No newline at end of file diff --git a/docs/GITHUB_APP_CREDENTIAL_RELOAD.md b/docs/GITHUB_APP_CREDENTIAL_RELOAD.md new file mode 100644 index 00000000..19d417d6 --- /dev/null +++ b/docs/GITHUB_APP_CREDENTIAL_RELOAD.md @@ -0,0 +1,37 @@ +# GitHub App runtime credential reload + +SynSec can now keep GitHub App credentials in a validated, memory-only generation that is atomically replaced without reconstructing the webhook handler or installation-token provider. + +## Integration boundary + +`createGitHubAppRuntimeCredentialSource()` accepts an initial credential snapshot and exposes a `reload()` callback boundary. The callback is where hosting code may read from an operator-approved secret manager, mounted credential file, supervisor IPC channel, or equivalent deployment mechanism. SynSec does not implement vendor-specific secret retrieval, write credentials to durable state, or send credentials to scanners. + +A snapshot contains the App private key, one webhook secret or a two-secret rotation overlap, and a bounded non-secret generation identifier. Status surfaces only the generation, webhook-secret count, and successful reload count. + +## Safe rollout sequence + +For a webhook-secret rotation, publish a generation containing `[new, previous]`, reload each application replica, and use the existing credential-reload assessment to verify that every expected replica reports the target generation and is ready. Only after that observation should the operator update GitHub to the new secret and later publish a second generation containing only the new secret. Reload all replicas again before retiring the previous value from the external secret manager. + +For a private-key rotation, provision the replacement key in the external secret source, publish a new generation, reload replicas, and verify the complete replica set before revoking the previous key in GitHub. A generation match is deployment evidence only; it is not proof that GitHub accepted the credential. + +## Failure behavior + +Reload operations are serialized within a process. The candidate generation is fully validated before the active snapshot changes. Loader or validation failure leaves the previous credential generation active. Reusing the currently active generation is rejected so a supervisor cannot accidentally report progress without changing deployment metadata. + +The webhook HTTP path resolves its current secret immediately before signature verification. The installation-token provider resolves its current private key immediately before each App JWT signature. If either supplier fails, the operation fails closed rather than silently using an empty credential or persisting a fallback. + +## Service-manager orchestration + +A service manager should treat secret material and reload signaling as separate concerns: + +1. Stage the new secret material with restrictive filesystem/secret-manager permissions outside repository workspaces and scanner sandboxes. +2. Atomically update the external secret source or mounted secret projection. +3. Trigger application-owned reload logic that calls the credential source's `reload()` method. +4. Read only secret-free generation/readiness status from each replica. +5. Use SynSec's deployment-wide reload assessment before moving to the credential-revocation step. + +Do not pass private keys, webhook secrets, GitHub tokens, database credentials, host control sockets, or durable SynSec state into scanner containers. Do not place credential files beneath the repository workspace tree. + +## Security interpretation + +Successful reload means the process validated and activated the supplied in-memory generation. It does not attest the external secret manager, prove GitHub-side activation, prove replica health beyond the operator's readiness signal, or authorize any repository operation. Existing installation authorization and least-privilege token checks remain separate trust boundaries. diff --git a/docs/GITHUB_APP_CREDENTIAL_RELOAD_FRESHNESS.md b/docs/GITHUB_APP_CREDENTIAL_RELOAD_FRESHNESS.md new file mode 100644 index 00000000..b0083960 --- /dev/null +++ b/docs/GITHUB_APP_CREDENTIAL_RELOAD_FRESHNESS.md @@ -0,0 +1,64 @@ +# GitHub App credential reload freshness + +SynSec treats deployment-wide credential reload observations as short-lived evidence. Exact replica membership, matching configuration generations, and readiness are necessary, but they are not sufficient if the observations are old enough that the deployment may have changed since they were collected. + +`@synsec/github/credential-reload-freshness` adds a stricter production gate on top of `@synsec/github/credential-reload`. + +## Fresh assessment + +`assessSynSecGitHubAppFreshCredentialReload()` requires: + +- the exact expected replica identifiers; +- the target credential configuration generation; +- one unique observation per replica containing `loadedGeneration`, `ready`, and canonical UTC `observedAt` metadata; +- a canonical UTC `assessedAt` timestamp from the trusted host/orchestration clock; and +- an optional observation-age bound between 10 seconds and 1 hour. The default is 5 minutes. + +Every structural rule from the base reload assessment still applies. In addition, every required observation must be within the configured age bound. Observations more than 30 seconds in the future fail closed to avoid treating materially skewed timestamps as current evidence. + +Example: + +```ts +import { + buildSynSecGitHubAppCredentialRotationWithFreshReloadAssessment, +} from "@synsec/github/credential-reload-freshness"; + +const result = buildSynSecGitHubAppCredentialRotationWithFreshReloadAssessment({ + rotation: { + kind: "webhook-secret", + replacementActivated: true, + externalConfigurationUpdated: true, + verificationSucceeded: true, + }, + reload: { + kind: "webhook-secret", + targetGeneration: "webhook-2026-08-23-b", + expectedReplicaIds: ["synsec-0", "synsec-1"], + replicas: [ + { + replicaId: "synsec-0", + loadedGeneration: "webhook-2026-08-23-b", + ready: true, + observedAt: "2026-08-23T14:29:30.000Z", + }, + { + replicaId: "synsec-1", + loadedGeneration: "webhook-2026-08-23-b", + ready: true, + observedAt: "2026-08-23T14:29:35.000Z", + }, + ], + assessedAt: "2026-08-23T14:30:00.000Z", + }, +}); +``` + +`runtimeReloaded` is derived internally from the fresh assessment. A structurally complete but expired fleet observation therefore cannot make the rotation planner report `readyToRetirePrevious: true`. + +## Trust boundary + +Timestamps are deployment metadata, not credential values. They must come from a trusted supervisor/orchestration integration; accepting an attacker-controlled `assessedAt` would defeat the freshness check. The API intentionally does not query Kubernetes, a service mesh, a secret manager, GitHub, or the host clock by itself because those integrations are deployment-specific. + +Fresh reload evidence still does not prove that the replacement credential value is correct. Webhook-secret rotation must additionally verify an authenticated delivery after the GitHub-side secret update. Private-key rotation must additionally verify a fresh installation-token exchange. Only then should the previous credential be retired. + +This API does not broaden repository authorization, expose secrets, reload services, revoke credentials, or perform live-target security testing. diff --git a/docs/GITHUB_APP_CREDENTIAL_ROTATION.md b/docs/GITHUB_APP_CREDENTIAL_ROTATION.md new file mode 100644 index 00000000..d4e468d7 --- /dev/null +++ b/docs/GITHUB_APP_CREDENTIAL_ROTATION.md @@ -0,0 +1,134 @@ +# GitHub App credential rotation + +SynSec treats webhook-secret and GitHub App private-key rotation as explicit operator-controlled rollouts. The runtime must never silently discard the previous credential before the replacement has been deployed and externally verified. + +`@synsec/github/credential-rotation` provides `buildSynSecGitHubAppCredentialRotationPlan()` as a secret-free state evaluator. It accepts only boolean operator acknowledgements and returns completed steps, remaining actions, and `readyToRetirePrevious`. It does not accept credential values, contact GitHub, reload services, change webhook settings, revoke keys, or mint installation tokens. + +For hosted or multi-replica deployments, `@synsec/github/credential-reload` provides a stricter deployment-observation boundary. `assessSynSecGitHubAppCredentialReload()` requires an exact set of expected application replica identifiers. Every required replica must report the same bounded target configuration generation and be ready. Missing, stale, duplicate, unexpected, or unready replica observations fail closed. The generation and replica identifiers are deployment metadata only and must never contain credential material. + +`buildSynSecGitHubAppCredentialRotationWithReloadAssessment()` is the preferred production composition API. It recomputes the reload assessment from raw replica observations and derives `runtimeReloaded` internally before invoking the existing rotation planner. This prevents a hand-authored `complete: true` object or a matching replica count from being used as reload proof. + +## CLI workflow + +Operators can evaluate the base state machine with `synsec-github-app rotation [--json]`. The input file accepts only `kind` plus the boolean acknowledgement fields `replacementActivated`, `runtimeReloaded`, `externalConfigurationUpdated`, and `verificationSucceeded`. Unknown fields are rejected so credential material cannot be silently accepted by the diagnostic path. + +An incomplete rollout exits `2` and explicitly keeps the previous credential active. A complete rollout exits `0` and reports `readyToRetirePrevious: true`. + +Example webhook rotation state: + +```json +{ + "kind": "webhook-secret", + "replacementActivated": true, + "runtimeReloaded": true, + "externalConfigurationUpdated": true, + "verificationSucceeded": true +} +``` + +The booleans are acknowledgements, not probes. They must be derived from deployment and GitHub observations rather than inferred merely because a configuration file was written. In a multi-replica production deployment, prefer the deployment-wide reload assessment rather than manually asserting `runtimeReloaded: true`. + +## Deployment-wide reload assessment + +A supervisor or orchestration integration must supply both the exact intended fleet membership and secret-free observations: + +```ts +import { + buildSynSecGitHubAppCredentialRotationWithReloadAssessment, +} from "@synsec/github/credential-reload"; + +const result = buildSynSecGitHubAppCredentialRotationWithReloadAssessment({ + rotation: { + kind: "webhook-secret", + replacementActivated: true, + externalConfigurationUpdated: true, + verificationSucceeded: true, + }, + reload: { + kind: "webhook-secret", + targetGeneration: "webhook-2026-08-23-a", + expectedReplicaIds: ["synsec-0", "synsec-1"], + replicas: [ + { replicaId: "synsec-0", loadedGeneration: "webhook-2026-08-23-a", ready: true }, + { replicaId: "synsec-1", loadedGeneration: "webhook-2026-08-23-a", ready: true }, + ], + }, +}); +``` + +The assessment is intentionally identity-bound rather than count-bound. The observed replica set must exactly equal `expectedReplicaIds`; every expected ID and observation ID must be unique; every expected replica must report the exact target generation; and every expected replica must be ready. A replacement observation cannot substitute for a missing required replica merely because the total count is unchanged. + +The assessment returns aggregate `missingReplicaCount` and `unexpectedReplicaCount` values rather than echoing expected fleet membership. `expectedReplicaCount` is derived from the supplied identity set, eliminating a separate caller-controlled count that could drift from the topology declaration. + +SynSec does not discover Kubernetes pods, inspect a service mesh, read a secret manager, or contact a deployment API here. The host integration remains responsible for producing trustworthy identity observations and for deriving `expectedReplicaIds` from the intended deployment topology rather than from whichever replicas happen to respond. + +### Offline reload verifier + +The same reload assessment is available to deployment automation through: + +```text +synsec-github-app-reload [--json] +``` + +The verifier exits `0` only when the exact expected fleet is ready on the target generation, exits `2` for incomplete/stale/missing/unexpected rollout state, and exits `1` for malformed input or CLI usage. Its input file is capped at 256 KiB, must be a non-symlink regular file, and has a strict credential-free schema. Unknown top-level or per-replica fields are rejected rather than ignored. + +Example input: + +```json +{ + "kind": "app-private-key", + "targetGeneration": "key-v7", + "expectedReplicaIds": ["synsec-0", "synsec-1"], + "replicas": [ + { "replicaId": "synsec-0", "loadedGeneration": "key-v7", "ready": true }, + { "replicaId": "synsec-1", "loadedGeneration": "key-v7", "ready": true } + ] +} +``` + +Count-only declarations using `expectedReplicaCount` are intentionally rejected by the verifier. A rollout gate must identify which replicas are required, not only how many responses were observed. + +Use the verifier as a rollout gate before acknowledging `runtimeReloaded` in the base rotation CLI. For programmatic production integrations, prefer `buildSynSecGitHubAppCredentialRotationWithReloadAssessment()` because it derives that acknowledgement internally. + +## Webhook secret + +Use a coordinated overlap: + +1. Stage the replacement in SynSec's bounded two-secret verification set while retaining the previous secret. +2. Reload or roll the SynSec runtime. +3. Confirm the exact expected replica set reports the replacement configuration generation and is ready. +4. Update the webhook secret in GitHub. +5. Confirm an authenticated webhook delivery after that GitHub-side update. +6. Only when the composed planner reports `readyToRetirePrevious: true`, remove the previous secret and reload again. + +Programmatic single-runtime example: + +```ts +import { buildSynSecGitHubAppCredentialRotationPlan } from "@synsec/github/credential-rotation"; + +const plan = buildSynSecGitHubAppCredentialRotationPlan({ + kind: "webhook-secret", + replacementActivated: true, + runtimeReloaded: true, + externalConfigurationUpdated: true, + verificationSucceeded: true, +}); +``` + +## GitHub App private key + +Private-key rotation uses a different ordering because GitHub can keep more than one App key active: + +1. Activate the replacement private key in GitHub. +2. Roll SynSec with the replacement key. +3. Confirm the exact expected replica set reports the replacement configuration generation and is ready. +4. Verify a fresh installation-token exchange after the rollout. +5. Only when `readyToRetirePrevious` is true, revoke the previous key in GitHub. + +The planner intentionally does not model installation tokens as rotatable credentials. SynSec creates installation tokens in memory for bounded purposes and does not persist them. + +## Security boundary + +These APIs and the CLIs are rollout guidance and deployment-state evaluation, not runtime authorization. GitHub-issued installation-token permissions and SynSec's durable repository authorization state remain authoritative. Rotation/reload state must never broaden repository scope, grant permissions, trigger remediation, or authorize network assessment. + +The reload assessment also does not certify that a secret value is correct. It proves only that the exact declared fleet observations agree on a target configuration generation. External webhook authentication or a fresh installation-token exchange is still required before the previous credential can be retired. diff --git a/docs/GITHUB_APP_DEPLOYMENT.md b/docs/GITHUB_APP_DEPLOYMENT.md new file mode 100644 index 00000000..2da1ba02 --- /dev/null +++ b/docs/GITHUB_APP_DEPLOYMENT.md @@ -0,0 +1,127 @@ +# GitHub App deployment readiness + +SynSec's hosted GitHub App modules are repository-security infrastructure, not a general-purpose target execution service. Production deployment must preserve the same fixed-host, commit-pinned, credential-minimized boundaries as the scan worker. + +`@synsec/github/app-deployment` provides a preflight validator for operator-controlled settings that are easy to misconfigure before the bounded webhook handler is mounted. It intentionally validates configuration only; it does not provision certificates, fetch secrets, modify firewall rules, or widen repository authorization. + +## Preflight contract + +Call `validateGitHubAppDeployment()` before starting the listener and fail startup when `ready` is false. `assertGitHubAppDeploymentReady()` is provided for callers that prefer an exception-based startup guard. + +The preflight currently requires: + +- a positive integer GitHub App id; +- a PEM-shaped App private key; +- either one webhook secret or an explicit two-secret rotation pair, with every secret between 32 and 4096 UTF-8 bytes; +- a host/IP value rather than a URL-shaped listener setting; +- local TLS or explicit upstream TLS termination for any non-loopback listener; +- absolute durable-state and repository-workspace paths; +- separate, non-nested state and workspace directory trees; and +- when strict scanner isolation is required, a declared sandbox/container boundary, CPU and memory limits, restricted networking, and read-only repository source. + +Plain HTTP is accepted only on loopback so local development can mount the handler without pretending that an externally reachable plaintext listener is production-ready. + +Diagnostics are categorical and deliberately do not echo private keys, webhook secrets, token values, repository credentials, or filesystem contents. Startup logs may record issue codes, but operators should still avoid dumping the original configuration object. + +## Example + +```ts +import { assertGitHubAppDeploymentReady } from "@synsec/github/app-deployment"; + +assertGitHubAppDeploymentReady({ + appId: process.env.SYNSEC_GITHUB_APP_ID ?? "", + privateKey: process.env.SYNSEC_GITHUB_APP_PRIVATE_KEY ?? "", + webhookSecret: process.env.SYNSEC_GITHUB_WEBHOOK_SECRET ?? "", + listenHost: "127.0.0.1", + tlsMode: "terminated-upstream", + stateDirectory: "/var/lib/synsec/state", + workspaceDirectory: "/var/lib/synsec/workspaces", +}); +``` + +A reverse proxy or ingress that terminates TLS should forward only to a private/loopback listener and should preserve the exact webhook request body. SynSec verifies `X-Hub-Signature-256` over the original bytes, so middleware must not parse and reserialize the body before the bounded webhook handler receives it. + +## Bounded listener + +`@synsec/github/app-server` provides the framework-free Node listener used to mount the webhook handler and optional aggregate status health endpoint. It is deliberately a small transport primitive rather than a process supervisor. + +```ts +import { createGitHubAppServer } from "@synsec/github/app-server"; + +const server = createGitHubAppServer({ + host: "127.0.0.1", + port: 3210, + tlsMode: "terminated-upstream", + webhookHandler: runtime.webhookHandler, + getStatus: runtime.getStatus, +}); + +await server.start(); +// During supervised shutdown: +await server.close(); +``` + +The listener enforces bounded request, header, keep-alive, and shutdown timeouts and limits each socket to a bounded number of requests. Plaintext `tlsMode: "none"` is restricted to loopback. `tlsMode: "local"` requires an in-memory key/certificate pair and creates an HTTPS listener. `tlsMode: "terminated-upstream"` records the explicit operator decision that TLS is handled before traffic reaches this process; deployments using that mode should still bind SynSec to a private or loopback interface whenever possible. + +The built-in `/healthz` route accepts only `GET`, disables caching, and returns either `{ "status": "ok" }` or the sanitized aggregate runtime status when `getStatus` is supplied. Status-collection failures return `503` with only `{ "status": "unavailable" }`; exception messages and durable-record contents are not reflected to callers. The listener does not add a second webhook parser, so the exact raw request body still reaches SynSec's signature-verifying webhook handler unchanged. + +`port: 0` is supported for tests and other operator-controlled ephemeral listeners. Production deployments should configure a fixed service port at the hosting layer. + +## What this does not certify + +A successful preflight and bounded listener do **not** mean the hosted service is production-complete. Operators still need actual process/container isolation for scanner subprocesses, OS CPU/memory limits, outbound network policy, service supervision, secret injection/reload, log retention, and a transactional shared state backend before horizontally scaling across hosts. + +The validator also does not test whether GitHub currently grants an installation the permissions needed for a specific operation. Runtime installation-token exchange remains authoritative for `contents:read`, `checks:write`, optional `security_events:write`, and explicitly invoked remediation permissions. + +## Secret rotation + +Treat App private keys and webhook secrets as hosting credentials, never scanner inputs. They remain memory-only in SynSec runtime configuration and are not written to state, reports, queue records, scanner environments, or repository workspaces. + +Webhook-secret rotation supports an explicit overlap pair. The verification primitive accepts either a single secret or at most two distinct secrets. Every candidate HMAC is evaluated before returning the result, and the same pair flows through initial intake and installation-state re-verification. A third fallback secret, duplicate secrets, an empty set, or secrets outside the configured size bounds fail closed. + +Use this sequence for a coordinated webhook-secret rotation: + +1. Generate a new strong secret in the hosting secret manager. +2. Roll SynSec with `webhookSecret: [newSecret, previousSecret]`. The deployment preflight should be green before accepting traffic. +3. Change the GitHub App webhook secret to `newSecret`. +4. Confirm normal authenticated deliveries are being accepted after the GitHub-side change. +5. Roll SynSec again with only `webhookSecret: newSecret`. +6. Remove the previous secret from the hosting secret manager after the deployment is confirmed healthy. + +The overlap is a deployment transition, not a permanent fallback mechanism. SynSec deliberately caps it at two secrets and does not persist which secret matched a delivery. Replay protection remains keyed to GitHub delivery id, so accepting the previous secret during the overlap does not create a second replay namespace. + +Private-key rotation is different because App JWT creation signs with one configured key. Create/activate the replacement GitHub App private key first, inject the replacement key into the runtime, roll/restart SynSec, confirm installation-token exchange succeeds, and only then revoke the previous key in GitHub. SynSec does not persist or multiplex App private keys, and scanners never receive them. + +## Filesystem placement + +Durable authorization/replay/queue state and repository workspaces must not be the same tree or ancestors of one another. This prevents checkout cleanup, scanner traversal, or workspace retention policy from reaching durable App authorization state, and prevents durable state from being exposed as repository scan input. + +A shared parent is fine. For example, `/var/lib/synsec/state` and `/var/lib/synsec/workspaces` are separate sibling trees. `/var/lib/synsec` and `/var/lib/synsec/workspaces` are not. + +## Bounded maintenance and retention + +The local runtime exposes `runMaintenance()` for durable state that can be deleted safely without guessing whether a repository scan is active. One maintenance pass prunes expired webhook replay markers according to the replay store's configured retention and removes only terminal `failed` queue records that have remained unchanged past the failed-job retention window. + +Failed-job retention defaults to 30 days, accepts only values from 1 hour through 180 days, and deletes at most 100 records per pass unless `retentionMaxDeletes` is explicitly configured. The cap itself is bounded to 1,000. Pending and leased jobs are never deleted by retention, regardless of age. Failed-job age is measured from the durable queue record's last modification time, which is refreshed when the job becomes failed, rather than from the original enqueue timestamp. + +```ts +const runtime = await createLocalGitHubAppRuntime({ + // ...credentials, config, stateDirectory, workspaceRoot... + failedJobRetentionMs: 14 * 24 * 60 * 60 * 1000, + retentionMaxDeletes: 100, +}); + +const result = await runtime.runMaintenance(); +``` + +Operators may invoke maintenance from their existing supervised process loop or an external scheduler. SynSec deliberately does not create its own background timer because service scheduling and lifecycle belong to the hosting layer. + +Repository acquisition creates an identity-free `.synsec-workspace-owner.json` marker with restrictive permissions before Git is invoked. Normal acquisition/worker cleanup still removes owned workspaces immediately after failure or completion. If a process dies first, `runMaintenance()` can reconcile stale `synsec-github-*` directories only when a valid ownership marker proves SynSec created the workspace. Reconciliation is observation-only by default; deletion must be explicitly enabled and is bounded by both retention age and per-pass deletion count. Symlinks, missing/malformed markers, unrelated directories, and entries that cannot be revalidated immediately before deletion are skipped rather than removed. + +## Sanitized runtime status + +`runtime.getStatus()` returns an aggregate-only snapshot that the bounded listener can expose through its local health endpoint. It reports installation totals split by active/suspended and repository-selection mode, plus queue totals split by pending/leased/failed status. + +The status contract intentionally excludes installation ids, account logins, repository names, commit SHAs, delivery ids, source paths, scanner output, credentials, and arbitrary durable-record fields. Durable stores are still fully parsed and validated before aggregation; malformed persisted state makes status collection fail rather than silently reporting a healthy snapshot. + +This snapshot is diagnostic data, not an authorization decision. A hosting layer should treat successful status collection as evidence that local durable state can be read, while installation authorization and GitHub permission checks remain authoritative at dispatch/worker execution time. diff --git a/docs/GITHUB_APP_HEALTH_READINESS.md b/docs/GITHUB_APP_HEALTH_READINESS.md new file mode 100644 index 00000000..c66ce08b --- /dev/null +++ b/docs/GITHUB_APP_HEALTH_READINESS.md @@ -0,0 +1,47 @@ +# GitHub App health and readiness probes + +SynSec's hosted GitHub App listener exposes two separate operator-facing probe surfaces: + +- `/healthz` is the aggregate operational health surface. When `getStatus` is configured it reports bounded installation and queue counts only. Repository identities, commit SHAs, delivery ids, source paths, credentials, scanner diagnostics, and arbitrary durable records are never serialized. +- `/readyz` is the routing-readiness surface. It returns only `{ "status": "ready" }` or `{ "status": "not_ready" }` and never includes the status object or readiness-policy diagnostics. + +Both paths are configurable with `healthPath` and `readinessPath`, but they must be distinct absolute paths without query or fragment components. Only `GET` is accepted. + +## Readiness semantics + +If no `getStatus` callback is configured, `/readyz` reports ready once the listener is serving requests. + +When `getStatus` is configured, readiness first requires aggregate durable state to load successfully. Operators may additionally supply an `isReady(status)` predicate for local policy. The predicate can inspect only the already-aggregated runtime status supplied by the host application. A false result, thrown error, or failed status read produces a minimal `503` response: + +```json +{ "status": "not_ready" } +``` + +The listener deliberately does not prescribe a universal queue threshold. For example, retained failed jobs can be expected operational history rather than evidence that routing must stop, while an operator may reasonably decide that any expired worker lease should make one deployment temporarily unready. That policy belongs to the host deployment and can be expressed without exposing its reasoning over HTTP. + +Example: + +```ts +const server = createGitHubAppServer({ + host: "127.0.0.1", + port: 3000, + tlsMode: "none", + webhookHandler, + getStatus: () => buildGitHubAppRuntimeStatus({ installationStore, queue }), + isReady: (status) => status.queue.expiredLeases === 0, +}); +``` + +`isReady` requires `getStatus`; SynSec rejects a readiness policy that has no aggregate runtime status input. + +## Admission control + +Health and readiness probes bypass the in-process webhook concurrency admission limit. A saturated listener can therefore return `503 { "status": "busy" }` for additional webhook work while still exposing health and readiness to an orchestrator or supervisor. + +This is intentional: probe traffic must not consume a webhook execution slot, and webhook saturation by itself is not silently converted into a durable-state failure. + +## Deployment boundary + +These probes do not implement external load balancing, TLS termination, restart policy, process supervision, ingress rate limiting, shared-state transactions, or scanner sandboxing. They provide a bounded signal that those external systems can consume. + +For multi-replica deployments, the shared-state production-readiness contract and matching conformance evidence remain mandatory. A healthy HTTP listener does not certify that its backing state is safe for horizontal operation. diff --git a/docs/GITHUB_APP_HOST_PROFILE.md b/docs/GITHUB_APP_HOST_PROFILE.md new file mode 100644 index 00000000..20d0794c --- /dev/null +++ b/docs/GITHUB_APP_HOST_PROFILE.md @@ -0,0 +1,49 @@ +# Secret-free GitHub App host profile + +`@synsec/github/app-host-profile` defines the declarative configuration that may safely live in an operator-managed deployment file. It is intentionally **not** a credential bundle and is not runtime-readiness evidence. + +The profile contains release/replica identity, App ID, the mounted credential directory, the **name** of the environment variable from which trusted hosting code obtains the PostgreSQL URL, listener settings, a repository workspace directory, an OCI runtime command, an immutable digest-pinned scanner image, and the protected operator-status path. + +## Why the profile is exact-keyed + +Unknown and missing fields are rejected. This prevents a convenient deployment JSON file from gradually accumulating `privateKey`, `webhookSecret`, `databaseUrl`, tokens, tenant metadata, or arbitrary backend payloads. Credential values remain in the existing mounted-credential boundary. The PostgreSQL URL remains in hosting/secret-manager state and the profile stores only its environment-variable name. + +The credential directory and repository workspace must be absolute and non-overlapping so untrusted repository content cannot be placed underneath the credential tree. Scanner images must be pinned by `sha256` digest. The scanner runtime is one bounded command token, preventing a declarative profile from becoming a shell-command surface. + +## Example + +```json +{ + "releaseId": "synsec-v0.2.0+abcdef0", + "replicaId": "github-app-01", + "replicaCount": 3, + "appId": 12345, + "credentialDirectory": "/run/credentials/synsec-github", + "postgresUrlEnvironment": "SYNSEC_POSTGRES_URL", + "listenHost": "127.0.0.1", + "port": 8787, + "tlsMode": "terminated-upstream", + "workspaceDirectory": "/var/lib/synsec/workspaces", + "scannerRuntimeCommand": "/usr/bin/docker", + "scannerImage": "ghcr.io/example/synsec-scanners@sha256:<64-hex-digest>", + "operatorStatusPath": "/_synsec/operator/status" +} +``` + +The placeholder digest above is documentation only and will not pass validation until replaced by an actual immutable image digest. + +## Hosting integration boundary + +A systemd/Kubernetes/container host should: + +1. read and validate this non-secret profile; +2. resolve `postgresUrlEnvironment` through its trusted secret/configuration mechanism without logging the resulting value; +3. load the fixed mounted credential files with `loadMountedGitHubAppRuntimeCredentialSnapshot()`; +4. migrate and compose the built-in PostgreSQL shared backend; +5. use the enforced OCI scanner process runner with the profile's pinned image/runtime; +6. mount the webhook endpoint behind TLS and protect the operator-status endpoint with an independent authenticated operator plane; +7. bind SIGTERM/SIGINT through the existing service-lifecycle/maintenance drain before process exit. + +The environment-variable mechanism is a hosting integration boundary, not a recommendation to expose database credentials broadly in process environments. Operators should use the narrowest secret-manager/service-manager mechanism available and ensure untrusted scanners never inherit the host environment. + +A successfully parsed profile carries `secret-free-host-wiring-contract-not-runtime-readiness`. Parsing does not prove files exist, PostgreSQL is reachable, migrations succeeded, GitHub accepted credentials, the service manager applied the intended sandbox, or the fleet is healthy. diff --git a/docs/GITHUB_APP_INTAKE_HOST.md b/docs/GITHUB_APP_INTAKE_HOST.md new file mode 100644 index 00000000..533bc116 --- /dev/null +++ b/docs/GITHUB_APP_INTAKE_HOST.md @@ -0,0 +1,94 @@ +# GitHub App intake host + +SynSec now ships an executable **webhook-intake host** for production-style PostgreSQL deployments: + +```text +npm run github-app:intake-host -- \ + --profile /etc/synsec/github-app-host.json \ + --conformance /etc/synsec/postgres-conformance.json +``` + +For `tlsMode: "local"`, also provide `--tls-key ` and `--tls-cert `. When TLS terminates at a trusted reverse proxy or load balancer, the profile must use `terminated-upstream` and the executable rejects local key/certificate arguments. + +## Activation order + +The host deliberately fails closed in this order: + +1. Read bounded regular non-symlink profile and conformance JSON files. +2. Validate the exact-keyed secret-free host profile. +3. Validate that the canonical shared-state conformance report is complete and bound to the exact built-in PostgreSQL backend id and implementation version. +4. Resolve the PostgreSQL URL only from the environment-variable name declared by the profile. The connection value is never accepted in the profile or printed by the host. +5. Load the fixed-filename mounted GitHub App credential generation into the existing memory-only atomic credential source. +6. Run the serialized PostgreSQL migrations. +7. Compose PostgreSQL replay, installation-authorization, and scan-queue stores. +8. Wrap webhook intake in the enforced local admission-drain controller. +9. Start the bounded HTTP(S) listener. + +Invalid conformance evidence and invalid TLS ownership fail **before** credential loading or database access. + +## Credential mount + +The `credentialDirectory` from the host profile uses the existing mounted credential contract: + +- `generation` +- `private-key.pem` +- `webhook-secret` +- optional `webhook-secret-previous` + +The directory and files must satisfy the existing regular-file, non-symlink, and byte-bound checks. SynSec does not write credentials back to this directory. + +## PostgreSQL secret boundary + +`postgresUrlEnvironment` is an environment-variable **name**, for example `SYNSEC_POSTGRES_URL`. Hosting or a service manager supplies the actual secret value: + +```text +SYNSEC_POSTGRES_URL=postgresql://... +``` + +Do not place the connection value in the JSON profile, command line, repository, or logs. The executable validates only that a bounded PostgreSQL URL is present; PostgreSQL authentication and TLS policy remain operator-owned connection configuration. + +## Shutdown + +`SIGTERM` and `SIGINT` stop new webhook admission, wait for locally admitted webhook requests to finish, close the listener, and then close the PostgreSQL pool. Rejected requests receive the existing retryable drain response so GitHub can retry them. + +This is **local intake drainage only**. It does not prove worker drainage, durable zero-lease state, fleet-wide maintenance eligibility, GitHub credential acceptance, repository authorization, or scanner completion. + +## Role separation + +This executable intentionally performs webhook intake and durable queue insertion only. It does not run scanner workers or hosted ownership re-verification sweeps. Keeping those roles separate prevents a webhook listener from silently expanding into scanner execution and allows intake and worker replica counts to be managed independently. + +Workers must continue to use the fenced durable queue, authorization rechecks, enforced OCI scanner boundary, and their worker-drain/service-lifecycle controls. Service-wide upgrades still require the existing durable lease and upgrade gates. + +## systemd example + +A minimal deployment shape behind an HTTPS reverse proxy is: + +```ini +[Unit] +Description=SynSec GitHub App intake +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/opt/synsec +EnvironmentFile=/run/synsec/postgres.env +ExecStart=/usr/bin/npm run github-app:intake-host -- --profile /etc/synsec/github-app-host.json --conformance /etc/synsec/postgres-conformance.json +Restart=on-failure +RestartSec=5 +TimeoutStopSec=45 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/synsec + +[Install] +WantedBy=multi-user.target +``` + +The service-manager sandbox above is an operator example, not a SynSec security attestation. Paths and permissions must be adapted to the actual deployment, and the reverse proxy must enforce HTTPS when the host profile declares `terminated-upstream`. + +## Current packaging limitation + +The executable is usable from a built repository/workspace, but a reproducible container image is still intentionally blocked by the repository's missing verified dependency lockfile. SynSec's strict release-readiness gate continues to report that blocker. Do not build a production image around unconstrained `npm install` and call it reproducible. diff --git a/docs/GITHUB_APP_MOUNTED_CREDENTIALS.md b/docs/GITHUB_APP_MOUNTED_CREDENTIALS.md new file mode 100644 index 00000000..1b2729dc --- /dev/null +++ b/docs/GITHUB_APP_MOUNTED_CREDENTIALS.md @@ -0,0 +1,44 @@ +# GitHub App mounted runtime credentials + +SynSec can load one operator-managed GitHub App credential generation from a fixed mounted directory and hand that snapshot directly to the existing memory-only runtime credential source. + +The mount contract uses fixed filenames: + +- `generation` — a non-secret generation identifier. +- `private-key.pem` — the GitHub App private key. +- `webhook-secret` — the currently active webhook verification secret. +- `webhook-secret-previous` — optional previous webhook secret during a bounded rotation overlap. + +`loadMountedGitHubAppRuntimeCredentialSnapshot()` only reads an absolute operator-supplied directory. The directory and every credential file must be non-symlink filesystem objects of the expected type. File sizes are bounded before reads, filenames cannot be selected by repository or webhook data, and loader errors are categorical rather than reflecting paths or credential contents. + +This is a vendor-neutral integration boundary for a supervisor, container-orchestrator secret mount, CSI driver, tmpfs handoff, or another operator-controlled mechanism. SynSec does not write credentials back to the mount and does not persist the returned snapshot. + +## Runtime reload + +A mounted snapshot is intended to be passed immediately to `createGitHubAppRuntimeCredentialSource()` or used as its `reload()` loader: + +```ts +const credentials = createGitHubAppRuntimeCredentialSource( + await loadMountedGitHubAppRuntimeCredentialSnapshot(secretDirectory), +); + +await credentials.reload( + () => loadMountedGitHubAppRuntimeCredentialSnapshot(secretDirectory), +); +``` + +The existing runtime source validates the generation, PEM framing, webhook-secret strength, and bounded two-secret rotation overlap before atomically replacing the active generation. Reloads serialize. A read or validation failure preserves the previous active generation. + +Operators should publish a complete new mounted generation atomically at the secret-manager/service-manager boundary rather than modifying individual live files in place. SynSec's loader does not claim to make a sequence of external filesystem replacements transactional. + +## Rotation sequence + +For webhook-secret rotation, mount the new active secret and optionally retain the previous secret in `webhook-secret-previous`, reload the runtime generation, observe the existing fleet reload/freshness checks, confirm authenticated GitHub deliveries with the new credential, and only then retire the old secret according to the credential-rotation runbook. + +For private-key rotation, mount the new private key under a new generation, reload the runtime generation, verify a fresh installation-token exchange, and only then retire the old GitHub App key. A successful mounted-file read or in-process reload is not proof that GitHub accepted or activated a credential. + +## Isolation boundary + +Do not mount the credential directory inside a repository checkout, scanner workspace, OCI scanner mount, report directory, or durable SynSec shared state. Scanner execution must remain credential-free. The mounted source is for the trusted GitHub App runtime/supervisor boundary only. + +The loader deliberately rejects symlink-shaped secret files even though some secret-management products implement rotation through symlink trees. Supporting such a provider requires a separate adapter with an explicit trust model rather than silently weakening this portable filesystem boundary. diff --git a/docs/GITHUB_APP_OPERATOR_STATUS.md b/docs/GITHUB_APP_OPERATOR_STATUS.md new file mode 100644 index 00000000..e2f64135 --- /dev/null +++ b/docs/GITHUB_APP_OPERATOR_STATUS.md @@ -0,0 +1,38 @@ +# Protected GitHub App operator status + +`@synsec/github/app-operator-status` provides a small framework-free boundary for production operator diagnostics without widening the public GitHub webhook surface. + +## Trust boundary + +The endpoint is **not public health** and SynSec does not invent an operator identity system. Hosting code must provide `authorize(request)` using the deployment's existing protected operator plane (for example, mutually authenticated ingress, a service-mesh identity, or an authenticated internal admin gateway). An authorization failure or exception returns `404` and the observation callback is not invoked. + +Only after authorization does `observe()` run. The observation contract is deliberately fixed and aggregate-only: + +- bounded release identifier and schema version; +- readiness boolean; +- memory-only credential generation identifier, webhook-secret count, and reload count; +- webhook/worker admission state and local active counts; +- durable active fenced-lease count supplied by a trusted backend observer; +- categorical recovery phase; +- observation timestamp. + +The response builder reconstructs every field. It does not spread caller objects, so arbitrary backend payloads, tenant identifiers, repository names, filesystem paths, scanner output, tokens, private keys, webhook secrets, or database diagnostics cannot accidentally become response fields. + +## Failure behavior + +Authentication failures are hidden as `404`. Observation failures return only `503 {"status":"unavailable"}`. The optional `onError` callback receives a new categorical error rather than the original exception, because secret-manager and database errors may contain credentials, connection strings, tenant data, or paths. + +Responses set `Cache-Control: no-store` and `X-Content-Type-Options: nosniff`. The handler accepts only `GET` on one bounded absolute path; the default is `/_synsec/operator/status`. + +## Interpretation + +A successful response is labeled `aggregate-operator-observation-not-external-security-proof`. It is useful for authenticated operations tooling, but it does **not** prove: + +- GitHub accepted the current App credential generation; +- repository or tenant authorization; +- fleet-wide readiness merely because one replica is ready; +- runtime reachability or scanner isolation beyond the separately enforced controls; +- exploitability or absence of vulnerabilities; +- successful service-manager rollout, recovery, or upgrade. + +For multi-replica maintenance decisions, use the existing durable lease observer and upgrade/maintenance gates. For request authorization, use the installation/ownership/freshness boundaries rather than this diagnostic endpoint. diff --git a/docs/GITHUB_APP_PROVISIONING.md b/docs/GITHUB_APP_PROVISIONING.md new file mode 100644 index 00000000..4d07fa94 --- /dev/null +++ b/docs/GITHUB_APP_PROVISIONING.md @@ -0,0 +1,80 @@ +# GitHub App provisioning + +SynSec can generate the initial GitHub App Manifest registration request for a production deployment. This is an operator workflow, not an automatic authorization path: creating a registration request, receiving a callback, or seeing an `installation_id` in a setup redirect does not prove that a GitHub App or installation is authorized for a SynSec tenant/runtime. + +## Generate a registration request + +Create a non-secret JSON file: + +```json +{ + "homepageUrl": "https://security.example.com/", + "webhookUrl": "https://security.example.com/github/webhooks", + "redirectUrl": "https://security.example.com/github/app/manifest/callback", + "setupUrl": "https://security.example.com/github/app/setup", + "organization": "example-org", + "name": "SynSec Production", + "description": "Repository-first defensive security", + "public": false, + "publishSarif": true, + "enableRemediationPullRequests": false +} +``` + +Then run: + +```sh +synsec-github-app-provision provisioning.json --json +``` + +The command emits a `POST` registration contract containing GitHub's registration endpoint, a generated CSRF `state`, and the serialized manifest. The manifest is derived from SynSec's feature-aware minimum permission/event contract. Remediation write permissions are never enabled unless `enableRemediationPullRequests` is explicitly true. + +The provisioning config deliberately rejects unknown fields. Private keys, webhook secrets, client secrets, installation tokens, database URLs, and other credentials do not belong in this file. + +## Registration handshake + +GitHub's App Manifest flow requires the manifest JSON to be submitted as the `manifest` form field to the generated registration endpoint. The generated `state` must be retained in a short-lived server-side session and checked when GitHub redirects to `redirectUrl`. + +Use `validateSynSecGitHubAppManifestCallback()` at that boundary. It requires both `code` and `state`, compares the state in constant time when lengths match, and returns the bounded one-time code only after validation. The result is labeled `validated-callback-not-conversion-success` because callback validation does not mean the manifest conversion has completed. + +GitHub requires the temporary manifest code to be exchanged through `POST /app-manifests/{code}/conversions` within the manifest-flow window. The conversion response is credential-bearing and may include a private key, webhook secret, client secret, and additional registration metadata. + +## Secret-manager handoff + +`provisionSynSecGitHubAppManifestConversion()` implements the next boundary without embedding a GitHub HTTP client or secret-store vendor into the core package. Hosting code supplies two callbacks: + +1. `exchange(code)` performs the fixed-host GitHub manifest conversion request and returns the decoded response. +2. `activate(credentials)` writes the validated App id/private key/webhook secret into the deployment's secret-manager or service-manager boundary and returns a non-secret generation identifier. + +SynSec validates the App id, PEM shape/size, and webhook-secret bounds before activation. It forwards only the three fields required by the existing runtime. Unrelated conversion metadata such as a generated client secret is not forwarded by this interface. + +Transport and activation failures are replaced with bounded generic errors. Raw backend errors are treated as untrusted because they can contain URLs, provider metadata, credential material, or customer-controlled strings. Hosting code may perform protected diagnostic logging at its own boundary, but normal SynSec status/CLI/HTTP surfaces must not echo the original error. + +The successful result contains only the App id and caller-supplied generation identifier and is labeled `secret-manager-handoff-complete-not-runtime-readiness`. It does not contain the private key or webhook secret, and it does not prove that every runtime replica has reloaded the generation. After activation, use the existing credential-reload orchestration/readiness flow to load and verify the new generation before retiring prior credentials or declaring rollout complete. + +The conversion helper deliberately does not persist credentials, log them, place them in scanner environments, or include them in readiness/status artifacts. + +## URLs and transport + +Provisioning requires absolute HTTPS URLs without embedded credentials or fragments. SynSec does not silently relax this requirement for local development because this command is intended to generate production registration state. Development operators should terminate TLS at their chosen local ingress/tunnel rather than teach the production manifest builder to accept insecure endpoints. + +## Setup URL is not authorization + +GitHub may append an `installation_id` to the configured setup URL after installation or repository-selection changes. Treat that query parameter as untrusted metadata. A caller can spoof it by requesting the setup URL directly. Do not mark an installation active, associate it with a hosted tenant, expose repository data, or broaden access solely from the query parameter. + +Runtime authorization remains the durable installation state synchronized from verified GitHub webhooks and, for any future authenticated hosted setup flow, an independently authenticated GitHub user/installation relationship. The local sanitized dashboard boundary must not be weakened to make provisioning convenient. + +## Permission changes and recovery + +After registration, compare the actual App configuration with: + +```sh +synsec-github-app evaluate setup.json --sarif +synsec-github-app recover setup.json --sarif +``` + +These commands remain diagnostics/guidance only. Permission changes in GitHub can require installation owners to approve updated access, and successful configuration changes are not evidence that a running installation token currently has the requested permissions. Runtime token permission diagnostics remain authoritative for worker operations. + +## Security interpretation + +The manifest builder enforces input bounds, HTTPS endpoints, least-privilege defaults, CSRF callback validation, bounded conversion-response validation, and secret-free activation status. It does not prove GitHub accepted the manifest, that an installation exists, that a user controls an installation, that a credential reached every replica, or that a scanner is authorized to access a repository. Those properties require their existing runtime, shared-state, credential-reload, and installation-authorization checks. diff --git a/docs/GITHUB_APP_QUEUE.md b/docs/GITHUB_APP_QUEUE.md new file mode 100644 index 00000000..03977f2c --- /dev/null +++ b/docs/GITHUB_APP_QUEUE.md @@ -0,0 +1,102 @@ +# GitHub App durable queue and lease fencing + +SynSec's hosted GitHub App runtime uses a bounded local durable queue to connect verified webhook intake to commit-pinned repository scans. The queue is intentionally repository-first and transport-minimal: persisted jobs identify the authorized installation, repository, exact head/base commit provenance, delivery identity, event type, retry count, and lease state. They do not persist GitHub installation tokens, App private keys, webhook secrets, clone credentials, scanner output, source excerpts, or arbitrary outbound targets. + +This document describes the queue's concurrency contract. It is not a claim that the filesystem implementation is a transactional multi-host queue. + +## Claim and fencing contract + +Each successful `claimNext()` creates a fresh random `leaseId` and a bounded `leaseUntil` timestamp. The `leaseId` is the fencing identity for that claim. + +A worker must present the exact current `leaseId` when it: + +- revalidates ownership before publication; +- renews a long-running lease; +- releases work for retry; +- marks work terminally failed; or +- acknowledges successful completion. + +A stale worker cannot safely infer the current fence from the retry count. Every new claim receives a new random id, including reclamation after lease expiry. Mutations fail closed when the stored job is no longer leased, the fence does not match, or the lease has already expired. + +The queue's `attempts` field remains a bounded retry counter. It is operational metadata, not a lock token. + +## Long-running worker renewal + +The production file queue exposes its configured `leaseMs` and a fenced `renew()` operation. The hosted worker starts a heartbeat after claim and renews the exact current fence approximately every one-third of the lease duration, with a one-second minimum interval. + +Renewal does not create a new claim and does not change the fence. It only extends `leaseUntil` for the currently owned lease after revalidating the stored `leaseId` and expiry state. + +The heartbeat remains active across repository acquisition, scanning, report construction, and publication. Before GitHub publication the worker also performs an explicit fenced `assertLease()` check. Before completion or retry mutation it stops the heartbeat and waits for any in-flight renewal before checking whether the heartbeat failed. + +If renewal fails, SynSec treats the worker as no longer safely authoritative. It does not silently continue as though ownership were intact. Publication/completion is blocked by the failed heartbeat or by the explicit lease assertion, and any retry mutation must still satisfy the same fence. + +## Expiry and reclamation + +An active unexpired lease is not claimable by another worker. Once `leaseUntil` has passed, a later `claimNext()` may reclaim the job, increments the bounded retry counter, and writes a new random `leaseId`. + +The previous worker's fence is immediately stale. It cannot release, fail, complete, renew, or pass the pre-publication ownership check for the reclaimed job. + +This protects against a common failure mode where a slow or paused worker resumes after another worker has already taken ownership. + +## In-process enqueue and authorization ordering + +The supported single-runtime implementation also serializes two read-modify-write operations whose correctness depends on ordering: + +- `enqueue()` duplicate-delivery and capacity checks are serialized within one `FileGitHubScanQueue` instance, so concurrent calls in that runtime cannot both persist the same delivery id after racing the same pre-write snapshot. +- GitHub installation/repository-selection synchronization is serialized per installation id and state-store instance. Concurrent deltas for the same installation therefore observe the preceding committed authorization state instead of both deriving replacements from one stale repository set. Events for different installations remain independently concurrent. + +These are deliberately in-process guarantees. They do not turn two separate Node processes, two queue/store objects over the same directory, or a shared filesystem into a transactional datastore. + +## Durable-state filesystem permissions + +Queue records, installation authorization state, and replay markers are written as private files. Their store directories are also created as `0700` and, on platforms with POSIX permissions, SynSec repairs a pre-existing more-permissive directory back to `0700` before writing/listing durable state. This matters because installation records can contain account/repository authorization names even though credentials and source are excluded. + +This directory repair applies to the local filesystem stores only. It is not a substitute for host access controls, encrypted storage where required, or a transactional shared service for multi-host deployments. + +## Operational status + +The aggregate runtime status reports `queue.expiredLeases` in addition to total, pending, leased, and failed counts. An expired lease is still a durable `leased` record, so it contributes to both `leased` and `expiredLeases`; the second count identifies the subset that is already eligible for reclaim. + +The status remains identity-free. It does not expose repository names, installation ids, delivery ids, commit SHAs, job ids, lease ids, source paths, or scanner output. + +A non-zero `expiredLeases` value is an operator signal rather than proof of data loss. It can mean a worker process exited, was paused longer than its lease, or failed to renew. A healthy worker loop should reclaim eligible work on its next claim pass. Repeated or growing expired-lease counts should prompt inspection of worker liveness, scanner duration, resource pressure, and service supervision before operators change lease settings. + +## Retry and terminal state + +Recoverable worker failures release the exact currently leased job back to `pending`. Terminal authorization revocation can move the exact current lease to `failed`. Successful completion deletes only the exact current leased record after fenced validation. + +Failed jobs are retained for bounded operator diagnostics and maintenance. Retention uses the separate terminal-only deletion path; it does not call successful lease acknowledgement and cannot delete pending or leased jobs. + +The queue caps attempts and durable queue size. Jobs that exhaust the retry bound become failed rather than cycling indefinitely. + +## Crash behavior + +If a process exits without releasing its job, the durable record remains leased until `leaseUntil`. Another worker may reclaim it only after expiry. Workspace cleanup is a separate ownership-marker-based maintenance concern and does not weaken queue fencing. + +The heartbeat is process-local. It is not persisted as a timer and does not survive a crash; the durable lease timestamp is the recovery boundary. + +## Single-host concurrency limitation + +The current file-backed queue improves stale-worker correctness with unique fencing identities and renewal, but it is still a single-host runtime foundation. Claims and enqueue duplicate/capacity checks are serialized inside one `FileGitHubScanQueue` instance; installation authorization deltas are similarly ordered within one runtime per installation. + +That in-process serialization is not a cross-process lock. Independent Node processes, separate queue/store instances pointed at the same directory, shared network filesystems, and multi-host deployment are not advertised as linearizable or transactionally safe. + +Production horizontal scaling still requires a transactional shared queue/state backend with atomic equivalents of: + +1. unique delivery insertion and queue capacity enforcement; +2. select eligible pending/expired work; +3. compare current durable state; +4. install a unique lease fence and expiry; +5. renew only that exact fence; +6. condition terminal/retry acknowledgement on that same fence; and +7. transactionally apply installation/repository authorization deltas. + +A future shared backend must preserve these semantics rather than weakening them to job-id-only acknowledgement or last-write-wins authorization updates. + +Until such a backend exists, operators should keep this file queue and installation state on one host and one runtime process/store instance. Service supervision may restart that process, but operators should not run multiple independent worker/runtime processes against the same durable state directories. Do not treat shared filesystem placement as a supported substitute for transactional multi-host persistence. + +## Security boundary + +Queue leasing and authorization ordering do not widen scan scope or grant new repository capability. Repository authorization is rechecked after claim, acquisition stays pinned to queued exact commits, scanners do not receive GitHub credentials, and publication remains commit-bound. + +The queue never authorizes autonomous live-target assessment, target expansion, persistence, secret exfiltration, or unapproved repository writes. Remediation remains a separate explicit approval-consuming workflow with distinct write credentials. diff --git a/docs/GITHUB_APP_QUEUE_SAFETY.md b/docs/GITHUB_APP_QUEUE_SAFETY.md new file mode 100644 index 00000000..c79aac51 --- /dev/null +++ b/docs/GITHUB_APP_QUEUE_SAFETY.md @@ -0,0 +1,42 @@ +# GitHub App queue lease safety + +SynSec's local GitHub App queue is a bounded single-host durable work queue. Queue records are commit-pinned repository scan descriptors; they never contain GitHub tokens, App private keys, webhook secrets, scanner output, source excerpts, clone URLs, or arbitrary outbound targets. + +## Unique lease fencing + +Every new queue claim receives a fresh random 128-bit `leaseId` in addition to the existing attempt counter and `leaseUntil` timestamp. The lease id is the worker's fencing identity. Release, failure, completion, renewal, and the worker's pre-publication check all require the exact lease id currently persisted for that job. + +This matters when work outlives a lease or two local workers race. An older worker may still finish CPU or scanner work, but once another claim has replaced its lease id it cannot release the newer claim, mark it failed, acknowledge it complete, or pass the worker's publication fence. + +`attempts` remains retry/accounting metadata. It is deliberately not used as the fencing identity because two processes that read the same pending generation concurrently could derive the same next attempt number. + +Legacy version-1 leased records that predate `leaseId` remain parseable so they can age out and be reclaimed. Newly claimed records always receive a lease id. A worker refuses a claimed record without a lease id rather than treating missing ownership proof as permission to proceed. + +## Lease renewal + +The file queue exposes `renew(jobId, leaseId)`. Renewal first validates that the supplied lease id is still current and unexpired, then extends `leaseUntil` by the queue's configured lease duration. It cannot revive an expired lease or renew a lease owned by another worker. + +The hosted worker starts a bounded heartbeat when the queue exposes both `renew()` and `leaseMs` (the production `FileGitHubScanQueue` does). The heartbeat runs at approximately one third of the lease duration, with a one-second lower bound, and remains active through repository acquisition, scanning, and publication. + +A renewal failure is remembered. The worker does not interrupt a scanner asynchronously or recursively delete its workspace while scanner code may still be using it; instead, it lets the current operation unwind, refuses publication/completion, stops the heartbeat, and then attempts the normal fenced retry transition. If ownership has already moved to another worker, that release also fails closed. + +Before obtaining publication credentials, the worker independently revalidates the current persisted lease id. This keeps GitHub publication behind both exact report commit provenance and current queue ownership. + +## Retention is a separate operation + +Terminal failed-job retention does not reuse `complete()`. Completion is reserved for acknowledging the exact active lease. Retention uses a distinct `deleteFailed()` operation that first proves the record is already terminal, so maintenance cannot bypass lease fencing for pending or active work. + +## What this does not claim + +Lease ids and heartbeat renewal reduce stale-worker and long-scan hazards, but the local filesystem queue is not presented as a transactional multi-host queue. Its scan-job claim path is still filesystem-backed rather than a shared database transaction, and horizontally scaled replicas on separate durable volumes cannot coordinate through it. + +A production multi-host backend must preserve at least these semantics atomically: + +- unique delivery/job insertion or equivalent idempotency; +- claim plus fresh fencing-token creation; +- compare-and-set lease renewal; +- fenced release/failure/completion; +- bounded retry accounting and terminal retention; and +- repository/install authorization rechecks at worker execution time. + +Until such a backend exists, SynSec's local queue should remain a single-host deployment primitive. The fencing contract is intended to make the required shared-backend semantics explicit rather than to overstate the guarantees of the current filesystem implementation. diff --git a/docs/GITHUB_APP_RECOVERY.md b/docs/GITHUB_APP_RECOVERY.md new file mode 100644 index 00000000..b67f1633 --- /dev/null +++ b/docs/GITHUB_APP_RECOVERY.md @@ -0,0 +1,46 @@ +# GitHub App recovery boundary + +SynSec provides a process-local recovery admission gate through `@synsec/github/app-recovery`. It is intended for operators recovering a hosted GitHub App replica after a shared-state, runtime-credential, GitHub control-plane, or explicitly initiated operational incident. + +## Enforced behavior + +`isolate()` immediately closes both webhook and worker admission through the existing maintenance controller. Operations already admitted before isolation are not killed; their normal fenced queue ownership and terminal transitions remain authoritative. + +`recover()` keeps admission closed until: + +1. locally admitted webhook requests and worker runs have both reached zero; +2. a caller-owned trusted recovery probe reports, in one observation, that shared state, the active runtime credential source, and the GitHub control plane are ready; and +3. local admission is still closed immediately before SynSec reopens it. + +Concurrent recovery calls inside one process are coalesced. Explicit `not ready` observations can be retried until the bounded recovery deadline. A thrown probe, malformed probe output, externally reopened admission, or timeout fails closed and leaves admission closed. + +Probe exceptions are deliberately discarded. Recovery status contains only a categorical incident reason, attempt count, and the interpretation `local-admission-recovery-boundary-not-external-health-proof`; it does not expose database URLs, filesystem paths, GitHub responses, tenant identifiers, tokens, private keys, or secret-manager diagnostics. + +## Trusted hosting boundary + +The recovery probe belongs to trusted hosting code. Repository content, scanner output, webhook payloads, stored artifacts, CLI input, and externally supplied metadata must never construct the probe or decide its result. + +A production probe should normally verify, using credential-owning infrastructure: + +- the configured transactional shared-state backend can complete an appropriate non-destructive health operation and is on the expected schema generation; +- the currently selected runtime credential generation can be loaded and validated locally; and +- the GitHub App control-plane operation required by the deployment can complete with bounded timeouts and sanitized diagnostics. + +The exact checks are deployment-specific. `true` is therefore operator/runtime evidence, not a security proof. In particular, `runtimeCredentialsReady` does not prove GitHub accepted newly rolled credentials unless the probe actually validates that property, and `githubControlPlaneReady` does not establish installation ownership or repository authorization. + +## Multi-replica recovery + +This controller is intentionally not a distributed recovery lock. A service manager or rollout controller must isolate and recover each replica according to deployment policy. SynSec's PostgreSQL fencing, installation authorization state, hosted ownership fence, and re-verification freshness checks remain the authoritative cross-replica controls. + +Do not release hosted installation ownership, delete durable queue state, rewrite lease fencing tokens, or truncate replay state as a recovery shortcut. Those operations change security semantics and are not performed by the recovery controller. + +## Suggested operator sequence + +1. Detect the incident through trusted service/backend telemetry. +2. Call `isolate()` with only the corresponding categorical reason. +3. Repair or roll back the failing infrastructure outside SynSec. +4. Call `recover()` with a bounded deadline. +5. If recovery succeeds, allow the service manager to continue normal operation. +6. If recovery fails, keep the replica isolated and investigate through protected operator logs. Do not surface raw backend/GitHub/secret-manager diagnostics through user-facing status endpoints. + +A successful recovery means only that this process enforced its local drain/reopen sequence and the configured trusted probe reported ready. It does not prove that another replica recovered, that an upgrade completed, that GitHub will accept every future request, or that repository code is safe. diff --git a/docs/GITHUB_APP_RUNTIME_READINESS_POLICY.md b/docs/GITHUB_APP_RUNTIME_READINESS_POLICY.md new file mode 100644 index 00000000..b9892c19 --- /dev/null +++ b/docs/GITHUB_APP_RUNTIME_READINESS_POLICY.md @@ -0,0 +1,47 @@ +# GitHub App runtime readiness policy + +SynSec's hosted listener exposes a minimal `/readyz` probe and accepts an optional `isReady(status)` predicate. `@synsec/github/app-readiness-policy` provides a reusable fail-closed predicate for deployments that want routing readiness to account for aggregate queue health without exposing repository or installation identities. + +## Default behavior + +`assessGitHubAppRuntimeReadiness()` first validates that the aggregate status is internally consistent: + +- installation counts are non-negative bounded integers; +- active plus suspended installations equals the installation total; +- all-repository plus selected-repository installations equals the installation total; +- pending plus leased plus failed jobs equals the queue total; and +- expired leases never exceed the leased-job count. + +Malformed or contradictory status fails with the aggregate code `invalid-status`. + +By default, any expired worker lease makes the runtime not ready. An expired lease is reclaimable work and can indicate a stalled or lost worker. Queue-depth and retained-failure limits are deployment-specific, so `maxPendingJobs` and `maxFailedJobs` are opt-in bounded thresholds. + +## Listener integration + +```ts +import { createGitHubAppServer } from "@synsec/github/app-server"; +import { + createGitHubAppRuntimeReadinessPredicate, +} from "@synsec/github/app-readiness-policy"; + +const server = createGitHubAppServer({ + host: "127.0.0.1", + port: 3000, + tlsMode: "terminated-upstream", + webhookHandler, + getStatus, + isReady: createGitHubAppRuntimeReadinessPredicate({ + maxExpiredLeases: 0, + maxPendingJobs: 500, + maxFailedJobs: 50, + }), +}); +``` + +The listener continues to serialize only `{ "status": "ready" }` or `{ "status": "not_ready" }` from the readiness endpoint. Policy reason codes are local operator/developer diagnostics and are not exposed through the HTTP probe. + +## Security boundary + +Runtime readiness is a routing and operational-health signal, not a security certification. A ready result does not prove scanner sandboxing, network isolation, transactional shared state, GitHub authorization, credential correctness, or safe multi-replica deployment. Those controls remain separate production-readiness gates. + +The policy consumes only aggregate counts. It does not accept repository names, installation ids, commit SHAs, credentials, scanner output, source paths, or arbitrary URLs, and it never broadens repository scope or initiates network assessment. diff --git a/docs/GITHUB_APP_SERVICE_LIFECYCLE.md b/docs/GITHUB_APP_SERVICE_LIFECYCLE.md new file mode 100644 index 00000000..76a5a4fa --- /dev/null +++ b/docs/GITHUB_APP_SERVICE_LIFECYCLE.md @@ -0,0 +1,39 @@ +# GitHub App service lifecycle integration + +`@synsec/github/app-service-lifecycle` bridges trusted process/service-manager stop requests into SynSec's enforced maintenance boundary. + +The lifecycle controller does **not** decide that a service may stop from a signal alone. A stop request first calls `prepareForServiceStop()`, which closes webhook and worker admission, waits for locally admitted work, and requires the configured durable lease observer to report zero current fenced leases. Only then is the caller-owned `onReadyToStop` callback invoked. + +## Signal handling + +`bindSynSecGitHubAppServiceSignals()` binds `SIGTERM` and `SIGINT` to the same serialized stop path. It deliberately does not call `process.exit()`, invoke systemd, patch a Kubernetes object, or terminate another process. The hosting application owns that final handoff. + +A typical host should: + +1. construct the webhook and worker drain controllers; +2. construct `createSynSecGitHubAppMaintenanceController()` with a durable lease observer, using the PostgreSQL observer for the built-in shared backend; +3. construct `createSynSecGitHubAppServiceLifecycleController()`; +4. bind `SIGTERM`/`SIGINT`; +5. in `onReadyToStop`, perform only the trusted local hosting action needed to finish process termination. + +Concurrent stop requests are serialized. Once stop eligibility has been handed off successfully, the lifecycle cannot be resumed. If maintenance or the hosting handoff fails, the lifecycle enters `stop-failed`; an operator-controlled recovery path may call `resume()` to reopen webhook and worker admission. + +## systemd boundary + +For systemd, configure the process to receive `SIGTERM` and set `TimeoutStopSec` longer than SynSec's configured lifecycle timeout. The Node host installs the lifecycle signal binding. SynSec performs the drain/evidence check in-process; systemd remains responsible for process supervision and final termination. + +Do not use a short `TimeoutStopSec` that can kill the process before fenced work drains. Do not interpret receipt of `SIGTERM` as evidence that shared-state leases are zero. + +## Kubernetes boundary + +For Kubernetes, the container receives `SIGTERM` during pod termination. The same lifecycle binding should begin the SynSec drain. `terminationGracePeriodSeconds` must exceed the configured lifecycle timeout plus expected shutdown overhead. + +Readiness should be withdrawn when admission is drained so new traffic is not intentionally directed to the pod. A preStop hook may initiate an operator-owned drain endpoint only if that endpoint is authenticated and cannot be reached by repository content, scanner output, or public webhook traffic; signal-driven in-process draining avoids creating such an endpoint. + +The lifecycle result proves only the local process admission state plus the durable lease observation supplied to its maintenance controller. It does not prove load-balancer propagation, pod deletion, rollout completion, or the state of other replicas. + +## Disclosure boundary + +Maintenance/backend exceptions and hosting callback failures are converted to categorical `stop-failed` state. Original errors are not returned through the lifecycle API because they may contain PostgreSQL URLs, filesystem paths, tenant data, command lines, or service-manager diagnostics. + +Repository content, scanner output, webhook payloads, and externally supplied metadata must never supply the maintenance controller, durable lease observer, lifecycle callbacks, or signal source. diff --git a/docs/GITHUB_APP_SERVICE_MAINTENANCE.md b/docs/GITHUB_APP_SERVICE_MAINTENANCE.md new file mode 100644 index 00000000..d366c0bd --- /dev/null +++ b/docs/GITHUB_APP_SERVICE_MAINTENANCE.md @@ -0,0 +1,42 @@ +# GitHub App service-manager maintenance boundary + +SynSec exposes `@synsec/github/app-maintenance` to connect the enforced webhook and worker admission drains to a trusted service manager without pretending that local process counters are durable fleet state. + +## Stop/restart sequence + +A hosting process should create one maintenance controller with the same `app-drain` and `app-worker-drain` controllers used by the live webhook and configured worker paths. The caller also supplies `countActiveLeases()`, backed by the transactional shared-state backend rather than local memory. PostgreSQL deployments can pass a callback around `countSynSecGitHubPostgresActiveLeases()` from `@synsec/github/postgres-lease-observer`. + +Before an intentional service stop, restart, or replacement: + +1. Call `prepareForServiceStop()`. +2. SynSec synchronously closes webhook admission and worker-run admission. +3. Already-admitted webhook requests and worker runs are allowed to finish. The maintenance controller does not cancel, steal, or rewrite their work. +4. After local admitted work reaches zero, SynSec repeatedly queries the caller-owned durable lease observer. +5. Stop eligibility is returned only after the observer reports exactly zero active fenced leases while both admission boundaries are still closed. +6. The external service manager may then stop or replace the process. SynSec itself does not invoke systemd, Kubernetes, Docker, or a cloud control plane. + +If the deployment or restart is aborted before the process stops, call `resumeAdmission()` explicitly. Admission is never reopened automatically after an observation failure or timeout. + +## Service-manager integration example + +The service manager should expose a privileged local control path or process signal whose handler invokes `prepareForServiceStop()` and exits successfully only when it returns stop evidence. The service manager can then use that helper as its pre-stop gate. Do not put database URLs, GitHub credentials, repository identities, webhook payloads, or scanner output into the control request. + +For systemd, a deployment can place a small operator-owned helper in `ExecStop=` or `ExecStopPre=` that talks only to a loopback/Unix-domain administrative endpoint implemented by the hosting process. For Kubernetes, the equivalent integration belongs in a `preStop` lifecycle hook plus readiness removal. These are deployment examples, not claims that SynSec itself controls either service manager. + +The service manager must use a stop timeout longer than SynSec's configured maintenance timeout. A timeout should fail closed and leave admission closed; the operator must decide whether to resume or investigate durable work instead of forcing a normal rolling restart. + +## Durable lease observer boundary + +`countActiveLeases()` is trusted hosting input. It must query the same transactional backend used for worker leases and return only a bounded non-negative integer. A local worker-run count, process table, readiness flag, or operator assertion is not a substitute. + +The built-in PostgreSQL observer counts only rows whose status is `leased` and whose fence has not expired according to PostgreSQL `clock_timestamp()`. Expired leases are reclaimable and therefore do not establish current ownership. CI exercises this helper against the real PostgreSQL backend, including a transition from two valid leases to one valid/one expired lease and finally zero valid leases. + +Backend errors are deliberately reduced to a categorical maintenance error because driver diagnostics can contain connection strings, SQL, hostnames, tenant data, or other sensitive values. Invalid counts, including negative, fractional, non-finite, or unreasonably large values, also fail closed. + +Zero durable leases is maintenance evidence only. It does not prove that all replicas in a deployment are drained. Multi-replica rolling upgrades must still use `app-upgrade` with exact fresh replica observations and closed worker admission on every expected replica. + +## Trust and security interpretation + +The maintenance controller enforces local admission closure through the existing drain controllers and requires a durable zero-lease observation before returning stop eligibility. It does not prove GitHub-side credential activation, scanner isolation, repository safety, runtime authorization, or absence of vulnerabilities. It also does not perform process termination, deployment, migration, rollback, or secret-manager operations. + +Repository content, scanner output, webhook payloads, stored artifacts, backend errors, and externally supplied metadata remain untrusted. None may choose the lease observer or service-manager control path. diff --git a/docs/GITHUB_APP_SETUP.md b/docs/GITHUB_APP_SETUP.md new file mode 100644 index 00000000..eafa1910 --- /dev/null +++ b/docs/GITHUB_APP_SETUP.md @@ -0,0 +1,154 @@ +# GitHub App setup and least privilege + +SynSec's hosted App setup helpers describe and compare the GitHub permissions/events required by enabled repository-security features. They do not create an App, modify an installation, request broader permissions, or contact GitHub. + +## CLI setup diagnostics + +The packaged CLI includes a separate, offline `synsec-github-app` binary so operators can inspect and lint setup requirements without writing integration code or mixing setup logic into repository scanning. + +```sh +synsec-github-app requirements +synsec-github-app requirements --sarif --remediation --json +``` + +`requirements` prints the feature-aware permission/event minimum. `--sarif` adds the code-scanning publication permission and `--remediation` adds only the write permissions required by the explicit approval-consuming remediation PR path. + +To compare a declarative or exported setup, provide a bounded JSON document containing only `permissions` and `events`: + +```json +{ + "permissions": { + "contents": "read", + "checks": "write" + }, + "events": [ + "installation", + "installation_repositories", + "pull_request", + "push" + ] +} +``` + +Then run: + +```sh +synsec-github-app evaluate ./github-app-setup.json +synsec-github-app evaluate ./github-app-setup.json --json +synsec-github-app evaluate ./github-app-setup.json --strict +``` + +When an operator wants actionable recovery guidance rather than the raw comparison, use the same bounded declaration with `recover`: + +```sh +synsec-github-app recover ./github-app-setup.json +synsec-github-app recover ./github-app-setup.json --json +synsec-github-app recover ./github-app-setup.json --strict +``` + +`recover` converts missing capability into deterministic required operator actions and keeps excessive write grants or unused subscriptions in a separate least-privilege review list. It never applies those actions. In particular, SynSec does not automatically reduce permissions because an extra grant may be required by another operator-approved integration, and it does not automatically broaden permissions because setup changes remain an administrator-controlled GitHub action. + +The evaluator and recovery planner are offline: they do not contact GitHub, inspect repositories, accept credentials, or mutate App settings. Setup files are limited to 256 KiB. A missing required permission/event exits with code `2`. Least-privilege drift is advisory by default; `--strict` exits with code `3` when extra write grants or unused webhook subscriptions exist. Schema/input failures exit with code `1`. The exit semantics are identical for `evaluate` and `recover`, which makes either command suitable for deterministic deployment linting. + +The file format intentionally has no credential fields. Unknown extra top-level fields are ignored rather than consumed, and errors never reflect their values. Operators should still export only the minimal permission/event declaration shown above rather than passing raw hosting configuration into this tool. + +## Feature-aware minimum + +`buildSynSecGitHubAppSetupContract()` returns the minimum repository permission/event contract for the selected features. + +```ts +import { buildSynSecGitHubAppSetupContract } from "@synsec/github/app-setup"; + +const setup = buildSynSecGitHubAppSetupContract({ + publishSarif: true, + enableRemediationPullRequests: false, +}); +``` + +Scan-only operation remains `contents:read` plus `checks:write`. `security_events:write` is added only when SARIF publication is enabled. `contents:write` and `pull_requests:write` are added only when approved remediation PR creation is explicitly enabled. + +The required webhook events are bounded to: + +- `installation` +- `installation_repositories` +- `pull_request` +- `push` + +No repository name, installation id, account identity, token, commit SHA, clone URL, or credential is part of the setup contract. + +## Compare an existing configuration + +`evaluateSynSecGitHubAppSetup()` compares an operator-declared App permission/event configuration with SynSec's feature-aware minimum. + +```ts +import { evaluateSynSecGitHubAppSetup } from "@synsec/github/app-setup"; + +const evaluation = evaluateSynSecGitHubAppSetup({ + permissions: { + contents: "read", + checks: "write", + }, + events: [ + "installation", + "installation_repositories", + "pull_request", + "push", + ], +}); +``` + +The result separates four cases: + +- `missingPermissions`: required capability is absent or weaker than required; +- `missingEvents`: a SynSec intake event is not subscribed; +- `excessiveWritePermissions`: the App has write access SynSec does not require for the enabled features; +- `extraEvents`: the App subscribes to events SynSec does not consume. + +`ready` is false only when a required permission or event is missing. Excess write permissions and extra events are least-privilege drift: they should be reviewed and normally removed, but the comparison does not pretend they make the runtime nonfunctional. + +A GitHub `write` grant satisfies a SynSec `read` requirement. The reverse never does. For example, `contents:write` can acquire repository source, but it is still reported as excessive when remediation is disabled because scan-only SynSec does not need repository write access. + +## Build a recovery plan programmatically + +`buildSynSecGitHubAppSetupRecoveryPlan()` produces bounded, human-readable setup guidance from the same input and feature flags used by the evaluator. + +```ts +import { buildSynSecGitHubAppSetupRecoveryPlan } from "@synsec/github/app-setup"; + +const plan = buildSynSecGitHubAppSetupRecoveryPlan({ + permissions: { + contents: "read", + checks: "read" + }, + events: ["push", "pull_request"] +}); +``` + +`requiredActions` contains only changes required for SynSec capability. `leastPrivilegeReview` contains optional review items for permissions/events SynSec does not require. The plan is labeled `operator-guidance-not-runtime-authorization` and contains no mutation callback, GitHub client, installation identity, repository identity, or credential material. + +## Runtime authorization remains authoritative + +The setup evaluator is intentionally labeled `setup-comparison-not-runtime-authorization`. It is a configuration UX tool, not proof that a particular installation currently authorizes a repository or that GitHub will issue a usable token. The recovery planner is similarly guidance-only. + +At execution time SynSec still: + +1. checks durable installation/repository authorization; +2. exchanges a fresh App JWT for an installation token at GitHub's fixed API host; +3. validates GitHub-reported token permissions for the exact operation purpose; and +4. keeps the token out of scanner inputs and persisted state. + +This separation prevents a copied setup configuration from becoming an authorization bypass. + +## Recommended setup workflow + +1. Choose whether SARIF publication is enabled. +2. Keep remediation PR writes disabled unless the operator intends to use the explicit approval-consuming remediation path. +3. Print the feature-aware minimum with `synsec-github-app requirements` or build it programmatically with `buildSynSecGitHubAppSetupContract()`. +4. Configure the GitHub App permissions and events to match that minimum. +5. Compare the resulting declaration with `synsec-github-app evaluate` or `evaluateSynSecGitHubAppSetup()`. +6. If capability is missing or least-privilege drift exists, run `synsec-github-app recover` or `buildSynSecGitHubAppSetupRecoveryPlan()` to generate operator actions; apply any GitHub-side changes manually through the administrator-controlled setup flow. +7. Re-run `evaluate --strict` after changes when deployment policy requires exact least privilege. +8. Run deployment preflight from `@synsec/github/app-deployment` before starting the listener. +9. Keep runtime permission diagnostics enabled; GitHub's issued installation token remains the final permission source of truth. + +Secret rotation is documented separately in `GITHUB_APP_DEPLOYMENT.md`. Setup comparison deliberately contains no secret values and can be safely included in sanitized operator diagnostics, subject to the hosting layer's normal log policy. diff --git a/docs/GITHUB_APP_SHARED_RUNTIME.md b/docs/GITHUB_APP_SHARED_RUNTIME.md new file mode 100644 index 00000000..9ee2f090 --- /dev/null +++ b/docs/GITHUB_APP_SHARED_RUNTIME.md @@ -0,0 +1,55 @@ +# GitHub App shared runtime integration seam + +`createGitHubAppSharedRuntime()` is the integration boundary for future transactional GitHub App state adapters. It composes externally implemented replay, installation-authorization, and scan-queue stores into the same repository-first webhook and worker pipeline used by SynSec's hosted runtime. + +This API does **not** ship a database adapter or independently certify supplied stores. Before composition it now requires both a complete versioned `GitHubAppSharedStateBackendContract` and a portable conformance report that passes SynSec's evidence gate for the exact same backend id and implementation version. Capability declarations alone are not enough to activate a shared runtime. + +## Why this seam exists + +The built-in `createLocalGitHubAppRuntime()` deliberately constructs filesystem stores and rejects application replica counts other than one. Replacing its directory with NFS or a shared volume does not provide transactional coordination. + +A real horizontally scalable deployment needs different persistence implementations while preserving SynSec's existing security boundaries. The shared runtime accepts those implementations structurally rather than weakening the local runtime or adding database credentials to SynSec's core configuration. + +```ts +const runtime = createGitHubAppSharedRuntime({ + backendContract, + conformanceReport, + webhookSecret, + replayStore, + installationStore, + queue, + worker: { + config, + getInstallationToken, + publishSarif: true, + }, +}); +``` + +The supplied stores must implement the existing narrow interfaces used by webhook intake and workers. In particular, the queue must provide fresh fencing identities on claim, compare-and-set lease renewal when supported, and fence-bound release/failure/completion behavior. Installation authorization must come from shared durable state, and replay claims must remain globally unique and retry-safe. + +## Credential boundary + +The shared runtime does not accept a database URL, password, TLS key, or arbitrary backend options. Database clients and credentials remain inside the adapter implementation. The backend contract accepts only bounded adapter/version identifiers and bounded non-secret evidence references. The conformance report contains canonical scenario ids, statuses, durations, derived coverage, and the adapter identity; backend/database exception text is excluded by the conformance runner. + +GitHub App credentials remain transport-only as elsewhere in SynSec. Scanner processes must never receive installation tokens, App private keys, webhook secrets, or database credentials. + +## Required conformance work before production scaling + +`@synsec/github/shared-state-conformance` exports `GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS`, a stable minimum adversarial scenario for each required shared-state capability. The executable runner produces the portable report consumed by the shared runtime and by `synsec-github-app-evidence`. The evidence gate independently recomputes coverage and rejects stale, detached, duplicate, invented, or structurally invalid results. + +The current required scenarios cover: + +- concurrent duplicate webhook replay claims; +- concurrent idempotent queue insertion; +- competing queue claimers with fresh fencing identities; +- stale-fence lease renewal rejection; +- stale-fence retry/failure/completion rejection; +- transactional installation/repository-selection mutation; +- cross-replica authorization revocation visibility. + +The report must come from a harness that exercised the real backend. Requiring the artifact at composition prevents accidental declaration-only activation, but it cannot prove that a dishonest or defective adapter harness actually used independent database connections/processes. Backend review and integration tests remain necessary. + +Additional adapter tests should cover transaction rollback, reconnect/restart behavior, and durable visibility across independent application processes. Those operational tests remain backend-specific and should not be replaced by mocks. + +The versioned contract, mandatory evidence gate, composition seam, and conformance registry make tests attributable to a concrete adapter build. None of these APIs makes the built-in filesystem stores horizontally safe. diff --git a/docs/GITHUB_APP_SHARED_STATE.md b/docs/GITHUB_APP_SHARED_STATE.md new file mode 100644 index 00000000..47081131 --- /dev/null +++ b/docs/GITHUB_APP_SHARED_STATE.md @@ -0,0 +1,134 @@ +# GitHub App shared-state deployment contract + +SynSec's built-in GitHub App replay store, installation state, and scan queue are designed for a single hosted runtime using local durable state. They must not be treated as transactional multi-host infrastructure merely because the state directory is placed on NFS, a shared volume, or another network filesystem. + +`validateGitHubAppDeployment()` makes that boundary machine-checkable. `replicaCount` defaults to `1`. A deployment declaring more than one replica fails readiness unless it also declares a `shared-transactional` state backend with every coordination guarantee SynSec's hosted runtime depends on. + +The concrete `createLocalGitHubAppRuntime()` factory adds a second guard at the implementation boundary. Its filesystem-backed runtime accepts an omitted `replicaCount` or exactly `1`; any other declared cardinality is rejected before runtime state directories are created. This prevents callers that bypass deployment preflight from representing the local runtime as horizontally safe. + +## Required guarantees + +A multi-replica backend must provide all of the following as backend-level atomic operations: + +- **Atomic webhook replay claim.** Two intake replicas cannot both accept the same delivery claim. +- **Atomic queue insertion.** Idempotent work insertion cannot create duplicate jobs under concurrent dispatch. +- **Atomic queue claim with a fresh fence.** Claiming work and establishing its fencing identity are one indivisible transition. +- **Compare-and-set lease renewal.** Only the currently fenced worker can extend its lease. +- **Fenced queue transitions.** Retry release, failure, completion, and other terminal transitions reject stale workers. +- **Transactional installation state.** Installation/repository-selection mutations cannot expose partial authorization state. +- **Shared authorization state.** Every intake and worker replica observes the same durable authorization authority. + +These are correctness and authorization requirements, not performance hints. Losing any one of them can turn a stale worker, concurrent webhook, or repository-selection race into duplicate publication or work performed after authorization changed. + +The canonical capability names are exported as `REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES`. Backend/provisioning integrations should use that list rather than maintaining a duplicate copy. + +## Deployment example + +```ts +validateGitHubAppDeployment({ + // existing App, listener, path, credential, and isolation configuration... + replicaCount: 3, + stateBackend: { + kind: "shared-transactional", + capabilities: { + atomicReplayClaim: true, + atomicQueueInsertion: true, + atomicQueueClaimWithFence: true, + compareAndSetLeaseRenewal: true, + fencedQueueTransitions: true, + transactionalInstallationState: true, + sharedAuthorizationState: true, + }, + }, +}); +``` + +The declaration is an integration contract. It does **not** implement those guarantees, certify an arbitrary database, or convert the built-in filesystem stores into a shared backend. Production operators must map each capability to a real database transaction, unique constraint, compare-and-set statement, or equivalent strongly consistent primitive in the selected backend. + +## Versioned backend evidence contract + +Capability booleans are useful for deployment preflight, but they are intentionally not enough to identify or review a concrete backend implementation. `@synsec/github/shared-state-contract` therefore exports a separate versioned, secret-free backend contract: + +```ts +{ + contractVersion: 1, + backendId: "postgres-v1", + implementationVersion: "0.2.0", + capabilities: { + atomicReplayClaim: true, + atomicQueueInsertion: true, + atomicQueueClaimWithFence: true, + compareAndSetLeaseRenewal: true, + fencedQueueTransitions: true, + transactionalInstallationState: true, + sharedAuthorizationState: true + }, + evidence: [ + { + capability: "atomicReplayClaim", + mechanism: "database-constraint", + reference: "conformance-atomicReplayClaim" + } + ] +} +``` + +A valid contract must include exactly one bounded evidence record for every required capability. Evidence mechanisms are restricted to known coordination primitives such as database constraints, serializable transactions, compare-and-set operations, fencing tokens, and shared durable stores. References are bounded non-secret identifiers; arbitrary URLs, connection strings, credentials, control characters, and unknown fields are rejected. + +Use `assessGitHubAppSharedStateBackendContract()` to obtain deterministic readiness diagnostics, or `assertGitHubAppSharedStateBackendContract()` at an adapter integration boundary. `GITHUB_APP_SHARED_STATE_CONTRACT_VERSION` is currently `1`. + +This contract is still **not certification**. It binds a concrete adapter/version to reviewable implementation evidence so that future database adapters and their concurrency tests have a stable interface. SynSec does not infer that a backend is safe merely because it can produce this document. + +## Actionable readiness diagnostics + +Provisioning and deployment tooling can evaluate a capability declaration directly without parsing human-readable error messages: + +```ts +const assessment = assessGitHubAppSharedStateCapabilities(capabilities); +if (!assessment.complete) { + console.error("Missing shared-state guarantees:", assessment.missing); +} +``` + +`assessment.missing` is emitted in stable contract order and contains only capability identifiers. When deployment validation emits `shared-state-capabilities-incomplete`, the corresponding issue also carries the same identifiers in `missingCapabilities`. This output is safe for startup diagnostics because it contains no database connection strings, credentials, filesystem contents, or backend-provided free-form text. + +Operators can run the same check offline from the setup CLI. The input file contains capability booleans only; connection strings, credentials, and unknown fields are rejected. + +```json +{ + "atomicReplayClaim": true, + "atomicQueueInsertion": true, + "atomicQueueClaimWithFence": true, + "compareAndSetLeaseRenewal": true, + "fencedQueueTransitions": true, + "transactionalInstallationState": true, + "sharedAuthorizationState": true +} +``` + +```sh +synsec-github-app shared-state capabilities.json --json +``` + +The command exits `0` only when every required guarantee is declared `true`. Missing or false guarantees exit `2` and are returned by name. Invalid schema or unsupported fields exit `1` without echoing supplied values. + +Treat these diagnostics as requirements to satisfy, not as proof that a backend really implements the declared guarantees. A production adapter still needs tests against its actual database concurrency semantics. + +## Fail-closed behavior + +For `replicaCount > 1`: + +- an omitted backend or `kind: "filesystem"` produces `shared-state-required`; +- a `shared-transactional` backend missing any required guarantee produces `shared-state-capabilities-incomplete` plus the exact `missingCapabilities` identifiers; +- invalid replica counts produce `invalid-replica-count` before shared-state evaluation; +- `createLocalGitHubAppRuntime()` rejects the configuration regardless of a declared backend because that factory always instantiates the filesystem stores. + +A single replica retains the current filesystem behavior. This keeps local and single-host deployments compatible while preventing configuration from overstating horizontal-scaling safety. + +## What a future backend adapter must preserve + +A real shared backend should expose operations matching SynSec's existing security invariants rather than generic key/value reads and writes. In particular, queue completion and publication ownership must remain fence-bound, webhook replay claims must remain unique and retry-safe, and installation authorization must be checked from shared durable state at execution time. + +Before a shared backend is accepted for horizontal production use, its integration test suite should exercise concurrent duplicate replay claims, duplicate queue inserts, competing claimers, stale-fence renew/release/complete attempts, installation authorization changes racing worker execution, and restart/reconnect behavior. Passing the declaration preflight or versioned evidence contract alone is not certification. + +Do not weaken those invariants to fit a storage product. If a backend cannot provide the required atomicity, keep SynSec at one application replica. diff --git a/docs/GITHUB_APP_SHARED_STATE_CONFORMANCE.md b/docs/GITHUB_APP_SHARED_STATE_CONFORMANCE.md new file mode 100644 index 00000000..76f4a9bd --- /dev/null +++ b/docs/GITHUB_APP_SHARED_STATE_CONFORMANCE.md @@ -0,0 +1,97 @@ +# GitHub App shared-state conformance runner + +SynSec's shared-state capability declaration and versioned backend contract describe what a horizontally scaled backend must guarantee. They do not prove that a database adapter actually preserves those guarantees under concurrency. + +`@synsec/github/shared-state-conformance-runner` provides the executable boundary for that proof. Adapter authors supply one callback for every canonical adversarial scenario, a `reset()` hook that isolates scenario state, and the same bounded `backendId` plus `implementationVersion` used by the versioned backend contract. SynSec executes the matrix in stable order, bounds each scenario by a timeout, and produces a schema-versioned result containing only adapter identity, scenario ids, status, duration, and capability coverage. + +## Adapter shape + +```ts +import { + runGitHubAppSharedStateConformance, +} from "@synsec/github/shared-state-conformance-runner"; + +const report = await runGitHubAppSharedStateConformance({ + backendId: "postgres-v1", + implementationVersion: "0.2.0-build.42", + async reset() { + await testDatabase.resetFixtures(); + }, + scenarios: { + "replay.concurrent-duplicate-claim": async () => { + // Race independent adapter clients against one delivery id and assert <= 1 accepted claim. + }, + "queue.concurrent-idempotent-insert": async () => { + // Race idempotent inserts and assert one durable logical job identity. + }, + "queue.concurrent-claim-fence": async () => { + // Race workers and assert one current lease plus a fresh fence per successful claim. + }, + "queue.stale-fence-renewal": async () => { + // Supersede a lease, then assert the stale fence cannot renew it. + }, + "queue.stale-fence-terminal-transitions": async () => { + // Assert a stale fence cannot release, fail, or complete newer work. + }, + "installation.concurrent-selection-mutation": async () => { + // Race installation/repository-selection mutations and reject partial authorization state. + }, + "authorization.cross-replica-revocation": async () => { + // Revoke authorization through one client and prove independent replicas fail closed. + }, + }, +}); + +if (!report.complete) process.exitCode = 1; +``` + +The runner requires exactly the canonical scenario ids. Missing callbacks and invented extra ids are rejected before execution, preventing a test harness from manufacturing coverage by renaming or omitting required cases. Adapter identity fields use the same bounded, non-secret identifier shape as the backend evidence contract, so connection strings and arbitrary free-form metadata cannot enter the portable report. + +## Fail-closed execution + +Scenarios run sequentially so failures are attributable and adapter-owned fixtures can be reset between adversarial cases. `reset()` runs before every scenario. A reset failure fails that scenario and the runner continues with the remaining matrix. + +The default per-scenario timeout is 15 seconds. Adapter suites may select an integer from 100 ms through 120 seconds. A timeout is a failed conformance result; a hung database operation cannot count as evidence. + +The report deliberately excludes thrown error text. Database exceptions frequently contain hostnames, SQL, connection strings, credentials, or tenant data. Adapter CI may retain its own separately sanitized diagnostics, but SynSec's portable conformance artifact only records bounded structural results. + +## Evidence binding gate + +Use `assessGitHubAppSharedStateConformanceEvidence()` from `@synsec/github/shared-state-evidence` before accepting a stored conformance artifact as deployment evidence. + +The assessor validates the versioned backend contract, parses the report as untrusted input, requires exactly one canonical result per scenario, recomputes coverage from those results, verifies the derived coverage object matches that recomputation, and requires `backendId` plus `implementationVersion` to match the backend contract exactly. Duplicate ids, invented scenarios, unsupported fields, inconsistent `complete` claims, tampered coverage arrays, malformed durations, and identity mismatches fail closed. + +```ts +import { + assessGitHubAppSharedStateConformanceEvidence, +} from "@synsec/github/shared-state-evidence"; + +const assessment = assessGitHubAppSharedStateConformanceEvidence( + backendContract, + JSON.parse(conformanceArtifact), +); + +if (!assessment.ready) { + throw new Error("Shared-state backend evidence is not ready for horizontal deployment."); +} +``` + +The returned issues use bounded issue codes and fixed messages rather than backend-provided text. This keeps the policy surface safe for startup logs and CI summaries even when the rejected artifact or database error originally contained credentials, internal hostnames, SQL, or tenant data. + +## What a pass means + +A complete report means every canonical callback completed successfully within its bound. It does **not** certify a storage product by itself. Production evidence should bind all of the following to the same tested revision: + +- exact `backendId` and `implementationVersion` in both the conformance report and versioned backend contract; +- exact backend/database version and relevant isolation configuration in CI provenance; +- the versioned `shared-state-contract` evidence document; +- the schema-versioned conformance report; +- CI provenance showing the scenarios ran against a real database, preferably with independent connections/processes where the invariant requires cross-replica behavior. + +Do not replace the adversarial operations with mocks of the adapter itself. The purpose of this runner is to make real-backend concurrency tests uniform and reviewable while keeping credentials and backend-specific internals outside SynSec's reports. + +## Relationship to deployment readiness + +`validateGitHubAppDeployment()` still fails multi-replica configurations closed unless every required capability is declared. The conformance runner and evidence gate add proof structure; they do not bypass deployment validation, automatically enable horizontal scaling, or turn the built-in filesystem stores into distributed infrastructure. + +A production shared-state adapter remains responsible for implementing atomic replay claims, idempotent queue insertion, fenced ownership and terminal transitions, compare-and-set lease renewal, transactional installation state, and shared durable authorization using primitives provided by the selected backend. diff --git a/docs/GITHUB_APP_SHARED_STATE_EVIDENCE_GATE.md b/docs/GITHUB_APP_SHARED_STATE_EVIDENCE_GATE.md new file mode 100644 index 00000000..df1057a4 --- /dev/null +++ b/docs/GITHUB_APP_SHARED_STATE_EVIDENCE_GATE.md @@ -0,0 +1,54 @@ +# GitHub App shared-state evidence gate + +SynSec can validate portable shared-state conformance evidence before an operator treats a horizontally scaled GitHub App deployment as ready for review. + +The gate is intentionally offline and credential-free. It does not connect to PostgreSQL or another database, contact GitHub, certify a backend, or accept connection strings. It only verifies that a versioned backend contract and a conformance report are structurally valid, complete, untampered, and bound to the exact same adapter identity and implementation version. + +## CLI + +```bash +synsec-github-app-evidence backend-contract.json conformance-report.json +``` + +Use `--json` for deployment and CI policy: + +```bash +synsec-github-app-evidence backend-contract.json conformance-report.json --json +``` + +Exit codes: + +- `0`: the supplied artifacts pass the evidence-binding gate. +- `2`: the artifacts are parseable but do not establish readiness, for example because conformance is incomplete, the backend identity differs, or the implementation version is stale. +- `1`: usage, file, size, JSON parsing, or unsupported arguments failed. + +The command bounds each input file to 1 MiB, rejects unknown/duplicate flags instead of silently ignoring operator typos, and does not include backend-provided error text in its assessment. Invalid credential-shaped values are rejected by the underlying contract validator without being echoed into the portable result. + +## What the gate verifies + +The gate independently checks all of the following instead of trusting summary flags supplied by an adapter: + +1. The backend contract uses SynSec's supported contract schema and declares every required shared-state capability. +2. Every capability has one bounded, secret-free implementation evidence entry. +3. The conformance report has the supported schema and one result for every canonical adversarial scenario. +4. Scenario identifiers are canonical and unique. +5. Result status and duration values are bounded and valid. +6. Derived coverage matches the actual scenario results. +7. Every required scenario passed. +8. `backendId` and `implementationVersion` exactly match between the contract and report. + +A successful result is therefore suitable as a deployment-review prerequisite, but it is not database certification. The conformance report still has to be produced by a harness that exercised the real backend using genuine concurrent independent connections or processes. + +## Composed production readiness + +Application code can use `assessGitHubAppProductionReadiness()` from `@synsec/github/production-readiness` to compose the ordinary hosted deployment preflight with the shared-state evidence gate. + +For one application replica, production readiness preserves the existing deployment-preflight semantics. For more than one replica, capability declarations alone are insufficient: readiness additionally requires a structurally valid, complete conformance report bound to the exact backend adapter build. Missing evidence therefore fails closed even when every transactional capability flag is declared true. + +`assertGitHubAppProductionReady()` exposes the same policy as an assertion suitable for startup/provisioning code. Its failure message contains categorical issue codes only; backend contract values, database errors, credentials, and connection details are not included. + +This composition is designed to prevent a deployment pipeline from accidentally treating `shared-transactional` plus seven boolean capability declarations as proof that a backend has actually passed SynSec's adversarial concurrency contract. + +## Defensive boundary + +This workflow only evaluates repository-hosting infrastructure for SynSec itself. It does not authorize scanning additional repositories, grant GitHub permissions, inspect repository source, perform network assessment, or enable any live-target exploitation behavior. diff --git a/docs/GITHUB_APP_UPGRADES.md b/docs/GITHUB_APP_UPGRADES.md new file mode 100644 index 00000000..1d6cb73c --- /dev/null +++ b/docs/GITHUB_APP_UPGRADES.md @@ -0,0 +1,88 @@ +# GitHub App rolling upgrades and rollback + +SynSec's hosted GitHub App has durable shared state and fenced workers, but those properties do not by themselves make an application rollout safe. A production service manager also needs exact fleet membership, fresh runtime observations, closed work admission, drained durable leases, an immutable previous artifact, and an explicit database-schema rollback decision. + +`@synsec/github/app-upgrade` provides a secret-free gate for that orchestration boundary. `@synsec/github/app-drain` provides the local enforced webhook-admission primitive, while `@synsec/github/app-worker-drain` provides the separate local background-worker admission primitive. None of these modules restarts processes, mutates PostgreSQL, executes migrations, revokes credentials, or calls a deployment platform. + +## Trusted observations + +The service manager supplies: + +- distinct current and target release identifiers; +- current and target shared-state schema versions; +- the exact expected replica identifiers; +- one fresh observation for every replica containing the loaded release, schema version, readiness, worker-admission state, durable active lease count, and observation timestamp; +- whether the immutable previous release artifact is still deployable; and +- whether a schema change is explicitly rollback-compatible. + +Replica observations and `assessedAt` must come from trusted supervisor/runtime state. Repository content, webhook payloads, scanner output, or user-controlled metadata must never be allowed to manufacture these values. + +## Enforced local webhook admission drain + +Wrap the production webhook handler with `createSynSecGitHubAppDrainController()` before passing it to the GitHub App listener. `beginDrain()` immediately prevents new webhook requests from entering the wrapped handler. Rejected requests receive only an aggregate `503 {"status":"draining"}` response plus `Retry-After: 1`, allowing GitHub to retry without reflecting delivery ids, repositories, payloads, or backend errors. + +Requests admitted before the drain continue running. `waitForDrained()` waits only for those in-process webhook calls. `resumeAdmission()` is explicit so a failed rollout can reopen the old replica without recreating the handler. + +## Enforced local worker admission drain + +Create one `createSynSecGitHubAppWorkerDrainController()` per hosted worker replica and pass it to `runConfiguredGitHubAppWorkerOnce({ workerDrain: controller, ... })`. `beginDrain()` synchronously closes admission. After it returns, a new configured-worker invocation receives `{ status: "draining" }` without reaching `queue.claimNext()`, so it cannot acquire a new durable lease through this worker path. + +A worker invocation admitted before `beginDrain()` remains owned by the worker. It is allowed to keep its existing heartbeat, finish publication, and perform the normal fenced terminal transition. The drain controller does not cancel the scanner, steal a lease, rewrite durable state, or force a stale worker to complete. + +`activeWorkerRuns` is deliberately only an in-process count of operations admitted through this controller. It is useful for waiting for local admitted work to exit, but it is **not** durable lease evidence and cannot survive a crashed process. `waitForDrained()` therefore also requires worker admission to already be closed, preventing a check-then-claim race inside the local orchestration sequence. + +## Durable lease observation remains separate + +Closing local worker admission and reaching `activeWorkerRuns === 0` prevents this replica from deliberately starting more work, but fleet-wide replacement still requires the shared transactional backend to report zero active fenced leases for the replica being replaced. Conversely, observing `activeLeases === 0` while worker admission remains open is only a momentary state: a worker could claim another job immediately afterward. + +For that reason `assessSynSecGitHubAppUpgrade()` treats a replica as drained only when both are true: + +- `acceptingWorkerRuns === false`; and +- `activeLeases === 0` from trusted durable-backend/supervisor observation. + +An open worker-admission observation produces `worker-admission-open` and blocks rollout/finalization even when the lease count is zero. + +## Start gate + +`readyToBeginRollout` is true only when the exact expected fleet is: + +- still on the previous release; +- ready; +- closed to new worker runs; +- free of active durable leases; +- represented by fresh observations; and +- rollback-capable. + +For schema-changing releases, rollback capability must be an explicit operator/migration property. SynSec does not infer reversibility merely because a migration completed successfully. + +A conservative per-replica rolling sequence is: + +1. call webhook `beginDrain()` so new GitHub deliveries receive retryable aggregate-only responses; +2. call worker `beginDrain()` so this replica cannot make another queue claim; +3. wait for both local controllers' admitted work to drain; +4. observe the shared fenced durable lease count reach zero and record `acceptingWorkerRuns: false`; +5. run the upgrade assessment with a fresh observation and keep the previous immutable artifact plus compatible database state available; +6. replace that replica with the target release; +7. verify normal readiness and shared-state health, then open admissions on the new replica according to the service-manager policy; +8. continue one replica at a time; and +9. run the final fleet assessment before retiring the previous artifact. + +The assessment intentionally treats active durable work or open worker admission as rollout blockers. A deployment platform can choose a different drain policy, but it must not reinterpret this result as proof that interrupting workers is safe. + +## Finalization gate + +`readyToFinalizeRollout` requires the exact expected fleet to be fresh, ready, closed to new worker admission for the assessment window, free of active durable leases, on the target release, and reporting the target schema version. Old-release replicas, open worker admission, active leases, or target replicas reporting another schema prevent finalization. + +Only after finalization should an operator consider removing the previous application artifact. Credential rotation is separate: follow `GITHUB_APP_CREDENTIAL_RELOAD.md` and `GITHUB_APP_CREDENTIAL_RELOAD_FRESHNESS.md` before retiring an old GitHub credential. + +## Rollback + +`rollbackAllowed` requires the previous immutable application artifact to remain available. If the release changes the database schema, the caller must additionally assert that the target schema is compatible with the previous application release. + +This is deliberately conservative. A migration declaration, application readiness probe, or successful target startup is not evidence that an old binary can safely use the new schema. Destructive or one-way migrations should therefore keep `rollbackSchemaCompatible: false` and require a separately designed recovery procedure. + +## Disclosure boundary + +Assessment output and drain status contain only bounded release/replica identifiers, counts, booleans, and categorical state. They should not contain database connection strings, GitHub credentials, webhook secrets, repository contents, scanner output, or backend exception text. + +These controls are operational evidence, not assertions that GitHub accepted credentials, that every request is authorized, that a scanner sandbox is correct, or that a deployment platform actually performed the requested rollout steps. diff --git a/docs/GITHUB_APP_WORKER_HOST.md b/docs/GITHUB_APP_WORKER_HOST.md new file mode 100644 index 00000000..15df739e --- /dev/null +++ b/docs/GITHUB_APP_WORKER_HOST.md @@ -0,0 +1,85 @@ +# GitHub App worker host + +SynSec now has a separate executable worker role for durable GitHub App scan jobs. It is intentionally distinct from the webhook intake role. + +## What is enforced + +`@synsec/github/app-worker-host` composes the following boundaries in one process: + +- exact secret-free host-profile validation; +- exact built-in PostgreSQL backend/conformance-evidence validation before credential or database access; +- mounted GitHub App credentials held only in the existing memory-only atomic credential source; +- serialized PostgreSQL migration plus shared installation authorization and fenced scan queue state; +- worker admission drain before `queue.claimNext()`; +- durable lease heartbeat, compare-and-set fence checks, and fenced terminal transitions; +- repository authorization recheck after lease acquisition; +- short-lived purpose-scoped installation tokens for acquisition and publication; +- exact queued commit acquisition into the operator-owned workspace tree; +- scanner execution through the digest-pinned OCI sandbox path; +- fresh publication authorization only after the worker still owns the durable lease. + +The executable command is: + +```sh +npm run github-app:worker-host -- --profile /absolute/host.json --conformance /absolute/postgres-conformance.json --config /absolute/worker-synsec.json +``` + +The PostgreSQL connection value is read only from the environment-variable name declared in the host profile. It is not accepted in the JSON profile. + +## Current scanner scope + +The production worker **fails closed unless every configured scanner is one of `checkov`, `grype`, or `syft`**. + +This is not a product-level claim that those scanners are sufficient. They are currently the adapters whose availability and scan execution both support SynSec's enforced OCI process runner. Checkov adds offline IaC/configuration analysis to the hosted subset; its normal bundled checks do not require SynSec to grant scanner network access. The worker still refuses `opengrep`, `trivy`, `osv-scanner`, `scorecard`, or any other unsupported adapter in this role rather than silently executing it on the host. + +The pinned scanner image must contain every selected tool. For Grype it must also contain all vulnerability database/cache material required by the pinned version. The OCI sandbox uses `network=none`; SynSec will not widen networking to make an unprepared scanner image succeed. + +Changed-file Checkov scans remain bounded to validated repository-relative `-f` arguments and execute from the read-only `/workspace` repository root. Full Checkov scans map the configured repository directory into `/workspace` through the same OCI runner. Checkov exit code `1` remains its normal findings-present result; other non-zero exits fail the job. + +AI review must also be disabled in the worker configuration. AI review is a separate outbound disclosure/trust boundary and is not part of scanner isolation. + +## OCI boundary + +For each exact acquired repository workspace, the worker constructs only OCI-backed adapters. The scanner sandbox enforces: + +- immutable image digest pinning; +- repository bind mount read-only; +- container root filesystem read-only; +- separate bounded writable `/scratch` and `/tmp` tmpfs; +- numeric non-root UID/GID; +- all Linux capabilities dropped; +- `no-new-privileges`; +- bounded PIDs, memory, swap, and CPU; +- `network=none` and `ipc=none`; +- no host control socket or namespace mounts; +- no explicit scanner child environment. + +GitHub installation tokens exist only in the host acquisition/publication layers. They are not mounted into the scanner container and are not passed as scanner environment variables. + +## Async scanner composition + +The normal local/CLI engine continues to use its ordinary built-in adapters. The hosted worker establishes a context-local scanner factory with `AsyncLocalStorage` for each scan operation. Concurrent worker operations therefore cannot replace one another's adapter set through process-global mutation. + +That factory mechanism is only a composition primitive. It is **not isolation evidence by itself**. The worker's security property comes from supplying only the OCI-backed adapter factory and rejecting unsupported hosted scanner IDs before credentials or database migration are reached. + +## Shutdown and rolling maintenance + +`beginDrain()` closes local worker admission synchronously. New `runOnce()` calls then return `draining` before a queue claim can occur. Work admitted before the boundary closes keeps its existing durable lease and may complete normally. + +`close()` begins drain and waits only for locally admitted worker calls. Fleet-wide stop eligibility still requires the existing durable PostgreSQL lease observer and maintenance/upgrade gates. A closed local worker does not prove another replica has stopped claiming work. + +The executable process handles `SIGTERM` and `SIGINT` by closing worker admission, waiting for admitted local work, and then closing the PostgreSQL pool. + +## Logging and disclosure + +The executable loop emits only the release/replica identity at startup, the configured scanner IDs, and aggregate worker result categories. It deliberately does not print repository names, installation IDs, finding content, scanner diagnostics, installation tokens, private keys, webhook secrets, or database errors from individual jobs. + +Operational code should continue treating all repository content, scanner output, GitHub responses, stored queue records, and backend diagnostics as untrusted. + +## Interpretation + +The worker reports the interpretation: + +`executable-fenced-worker-with-enforced-oci-subset-not-fleet-readiness-or-complete-coverage` + +A successful job therefore demonstrates that this worker used the configured transactional/fenced execution path and the currently supported OCI-isolated scanner subset. It does **not** prove fleet readiness, runtime reachability, exploitability, effective authorization beyond the explicit checks performed, complete scanner coverage, or absence of vulnerabilities. diff --git a/docs/HOSTED_INSTALLATION_OWNERSHIP.md b/docs/HOSTED_INSTALLATION_OWNERSHIP.md new file mode 100644 index 00000000..25b4ed5d --- /dev/null +++ b/docs/HOSTED_INSTALLATION_OWNERSHIP.md @@ -0,0 +1,65 @@ +# Hosted GitHub installation ownership + +SynSec hosted collaboration must not infer tenant ownership from an installation id supplied by a browser, webhook, repository, or URL parameter. The hosted setup boundary requires two independent facts before an installation can be associated with a tenant: + +1. the caller-owned authentication layer has already bound the current application principal to a specific GitHub user id; and +2. a user-scoped GitHub transport, using that same GitHub identity, confirms that the requested installation is currently accessible to the authenticated user. + +`verifyAndClaimSynSecHostedGitHubInstallation()` enforces those checks and then asks an ownership store to atomically claim the installation for one hosted tenant. It accepts no GitHub access token directly. Credential acquisition, storage, refresh, revocation, and transport headers stay outside SynSec's ownership record and must remain in the trusted identity/secret-management layer. + +## PostgreSQL tenant fence + +`PostgresSynSecHostedInstallationOwnershipStore` provides the built-in transactional implementation. `installation_id` is the durable global fence. The first tenant claim wins; a competing tenant receives `conflict` and cannot overwrite the row. Re-verification by the same tenant is accepted only when the durable GitHub account id and account type still match. Account login and authenticating-user churn cannot transfer ownership. + +`release(tenantId, installationId)` is compare-and-delete. A tenant cannot release another tenant's installation by knowing its numeric installation id. Normal access revocation does **not** call `release()`: the durable tenant fence remains present so loss of GitHub access cannot make the installation claimable by a different hosted tenant. + +Apply `migrateSynSecGitHubPostgresHostedInstallationOwnership()` before enabling hosted setup. Migrations are serialized with a transaction-scoped PostgreSQL advisory lock and contain no credentials. The additive re-verification migration backfills existing `verified_at` state from the original claim timestamp; a configured freshness gate therefore naturally denies sufficiently old pre-upgrade claims until they are re-verified. + +## Required hosted setup sequence + +The hosting application should perform this sequence: + +1. authenticate the local application session and resolve its stable `subject`, `tenantId`, and GitHub user id; +2. obtain or refresh a GitHub **user-scoped** credential in the trusted identity layer; +3. construct a transport whose `getAuthenticatedUser()` and `getAccessibleInstallation()` calls use that same user identity; +4. call `verifyAndClaimSynSecHostedGitHubInstallation()` with the requested installation id; +5. only after `status: "verified"` may hosted application state refer to the tenant/installation association; +6. continue enforcing repository-level installation authorization from SynSec's installation state for every repository operation. Tenant ownership does not replace repository authorization. + +Do not accept a GitHub login string, organization name, webhook payload, setup URL parameter, repository metadata, or installation id as ownership proof by itself. + +## Periodic and revocation-aware re-verification + +A one-time setup proof is not durable authorization evidence. Hosted deployments should periodically call `reverifySynSecHostedGitHubInstallation()` with the same authenticated hosted principal and a freshly usable user-scoped GitHub transport. + +The PostgreSQL store implements this as a fenced multi-replica protocol: + +1. `beginReverification()` atomically increments a durable `verification_epoch` for the exact tenant, installation, and currently recorded proof user; +2. GitHub identity and installation access are checked outside the database transaction; +3. `finishVerified()` or `finishRevoked()` applies the observation only when that epoch is still current; +4. if another replica started a newer check first, the older completion returns `stale` and cannot overwrite newer authorization state. + +This fencing is important because a slow negative GitHub response must not be able to revoke a newer successful verification, and a slow positive response must not reactivate a newer revocation. + +Definitive observations that the exact installation is inaccessible, suspended, or now represents a different durable GitHub account identity set `access_status = 'revoked'`. Revocation retains the tenant fence and records only a categorical reason. A later successful verification by the same durable tenant/proof identity can reactivate access. + +Transport exceptions are **not** converted into revocation evidence. Network failures, rate limits, GitHub outages, and secret-manager failures can be transient and do not prove access loss. Instead, hosted authorization should call `isSynSecHostedInstallationFreshlyAuthorized()` (or the store's equivalent gate) with an operator-selected bounded maximum age. PostgreSQL evaluates freshness using database time and returns false when the claim is revoked or its last successful verification is too old. This gives transient failures a bounded grace period while still failing closed after evidence becomes stale. + +A new authenticated GitHub user for the same hosted tenant cannot use the periodic path to revoke the previous proof. The tenant must first pass the full setup verification/claim path successfully; that operation can update the recorded proof user without moving the installation to another tenant and supersedes older in-flight re-verifications. + +## What successful evidence means + +Initial setup evidence is labeled `authenticated-user-access-and-atomic-tenant-claim-only`. It means that, at verification time: + +- the GitHub user returned by the user-scoped transport matched the GitHub user id bound to the authenticated hosted session; +- GitHub exposed the exact requested installation to that user; +- the installation was not reported suspended; +- the ownership store atomically accepted the tenant claim or found the same durable tenant/account identity already present. + +Periodic evidence is separately labeled `fresh-user-access-and-fenced-durable-reverification-only`. It additionally means that the observation won the current durable verification epoch. It does **not** prove that the user is an organization owner, that GitHub access will remain valid, that every repository in the installation is authorized, that a route is runtime-protected, or that the hosted tenant should gain access to any other installation or repository. Those boundaries remain separate and must fail closed independently. + +## Failure and disclosure behavior + +GitHub transport and ownership-backend exceptions are reduced to categorical messages. Backend URLs, authorization headers, tokens, tenant data, SQL diagnostics, and GitHub response bodies must not be reflected to an untrusted client. The caller may log separately sanitized operational telemetry, but should not serialize raw upstream exceptions into hosted responses. + +Periodic scheduling remains a hosting concern. SynSec supplies the fenced operation and freshness gate; it does not claim that a scheduler ran, that GitHub accepted any credential beyond the observed request, or that a successful ownership check replaces repository-level authorization. diff --git a/docs/HOSTED_INSTALLATION_REVERIFICATION_SWEEPS.md b/docs/HOSTED_INSTALLATION_REVERIFICATION_SWEEPS.md new file mode 100644 index 00000000..0af47e3b --- /dev/null +++ b/docs/HOSTED_INSTALLATION_REVERIFICATION_SWEEPS.md @@ -0,0 +1,52 @@ +# Hosted installation re-verification sweeps + +SynSec exposes `runSynSecHostedInstallationReverificationSweep()` and `SynSecHostedInstallationReverificationSweepController` for hosting environments that need to periodically refresh the user-access proof behind hosted GitHub installation ownership. + +This is an orchestration and observability boundary, not an authorization boundary. A completed sweep, a scheduler invocation, a zero-failure result, or process-local controller status must never replace `isSynSecHostedInstallationFreshlyAuthorized()` on an installation-scoped request path. + +## Trusted inputs + +The caller owns `SynSecHostedInstallationReverificationTargetProvider`. + +- `listTargets()` must derive the current target set from trusted hosted tenant state. Repository content, webhook payloads, setup URL parameters, account/login strings, or externally supplied metadata are not acceptable target authority by themselves. +- `createTransport(target)` owns the user-scoped GitHub credential boundary. It should obtain a freshly usable credential for only that target and return a transport with bounded GitHub HTTP timeouts/retries. +- SynSec never accepts the credential itself, persists the transport, or serializes transport/backend errors in sweep output. + +The sweep validates every principal and installation id, rejects duplicate tenant/installation pairs before requesting credentials, bounds one sweep to 10,000 targets, and bounds concurrency to 1-32 workers. + +## Multi-replica behavior + +The process-local controller coalesces overlapping `runOnce()` calls only inside one process. It is not a distributed scheduler lock. + +Multiple replicas may still execute the same target concurrently. Safety comes from the durable monotonically increasing verification epoch in the ownership store: an older positive or negative completion cannot overwrite a newer observation. Operators may still use leader election or one dedicated scheduler replica to avoid unnecessary GitHub traffic, but leader-election success is not security evidence. + +## Failure behavior + +Per-target failures are counted in the aggregate `failed` field. Raw GitHub, secret-manager, database, tenant, token, and transport diagnostics are not returned. Target-discovery failure aborts the sweep with the categorical error `Hosted installation re-verification target discovery failed.` because there is no trustworthy bounded target set to process. + +A transient per-target failure does not manufacture revocation. Durable authorization continues to rely on the existing freshness deadline: once the last successful verification becomes too old, the request-time authorization gate fails closed. + +The sweep intentionally does not implement a synthetic timeout by racing and abandoning `reverifySynSecHostedGitHubInstallation()`. An abandoned promise can still complete remote work and a fenced durable write after the caller thinks it timed out. The credential-owning GitHub transport must therefore enforce real HTTP cancellation/time limits at its own boundary. + +## Aggregate observability + +A sweep result contains only: + +- total attempted targets; +- verified count; +- revoked count; +- superseded count; +- failed count; and +- the interpretation `scheduler-observation-only-not-authorization-evidence`. + +It deliberately omits tenant ids, installation ids, GitHub user ids, account names, credential metadata, and backend diagnostics. + +`controller.status()` is process-local operational state only. It reports whether one local sweep is active, the number of completed local sweeps, and the last aggregate result. It carries the interpretation `process-local-scheduler-status-only`. + +## Service-manager patterns + +A systemd timer, Kubernetes CronJob, queue-driven maintenance worker, or application-owned scheduler can invoke `runOnce()` at an operator-selected cadence. The cadence should be comfortably shorter than the request-time freshness maximum so one transient failure does not immediately deny hosted access, while repeated failures still age into a fail-closed authorization state. + +For Kubernetes or horizontally scaled services, prefer a single scheduler deployment or external leader election for load control, while preserving the durable epoch fence because scheduler exclusivity can fail during failover. + +Monitoring should alert on aggregate failure/revocation trends and on freshness-denied authorization at the request boundary. Do not treat scheduler liveness alone as proof that GitHub access was refreshed. diff --git a/docs/INCREMENTAL_SCANS.md b/docs/INCREMENTAL_SCANS.md new file mode 100644 index 00000000..7e8044ea --- /dev/null +++ b/docs/INCREMENTAL_SCANS.md @@ -0,0 +1,77 @@ +# Incremental repository scan contract + +SynSec may reduce repository scan work only when it can preserve a defensible repository-first scope. Incremental execution is an optimization, not a claim that unselected files are safe or unreachable. + +## Local and GitHub Actions scans + +`runScanEngine()` can derive changed files from a local Git base or accept a caller-supplied changed-file set. Caller-supplied paths are accepted only when `changedOnly=true` and an explicit `changedBase` provenance identifier is present. Paths must be bounded, repository-relative, free of traversal/control characters, and are normalized/deduplicated before scanner execution. + +The engine then builds the existing repository index and resolved local module graph before choosing a scope. `buildIncrementalScanPlan()` always includes direct changes and may add bounded local dependents. It falls back to a full repository scan for high-impact repository/configuration files, unsafe paths, excessive change sets, changed analyzable source missing from the graph, or dependent expansion that would exceed its bound. A no-op targeted request also becomes a full scan rather than relying on adapter-specific empty-scope behavior. + +The planner interpretation is deliberately `coverage-heuristic-not-proof-of-unaffected-code`. Resolved import relationships are structural evidence only; they do not prove runtime reachability. + +## Native adapter narrowing + +Adapters may use the planner's final changed-file list to reduce scanner work only when the underlying scanner exposes a file-scoped mode that preserves repository-local target boundaries. + +Opengrep and Betterleaks narrow execution directly to changed files. Checkov uses its supported repeated `-f/--file` mode for a bounded changed-file scope, runs from the authorized repository working directory, deduplicates paths, and independently rejects absolute or traversal-shaped file names. If no changed-file scope is supplied, Checkov retains its normal directory scan. + +Gitleaks uses one staged temporary directory for targeted scans rather than passing an arbitrary list of positional targets to `gitleaks dir`. The adapter independently validates and deduplicates at most 500 repository-relative paths, copies only changed regular files while preserving their relative directory layout, and copies a regular repository-local `.gitleaks.toml` when present so configuration semantics remain available to the staged scan. Findings are remapped from the temporary root back to repository-relative paths before normalization. + +Trivy follows the same fail-closed staging principle for source-only targeted scans. Trivy's filesystem command accepts one filesystem path, so SynSec stages the selected regular files into one temporary directory, preserves their repository-relative layout, runs Trivy from the authorized repository working directory so repository-local Trivy configuration remains discoverable, and remaps result targets back to repository-relative locations. Dependency manifests, Dockerfiles, Terraform, workflows, and other high-impact configuration paths already force the planner to full-repository mode before this adapter narrowing is considered. + +OSV-Scanner uses its supported repeated `--lockfile` input only when the entire planner-selected scope is dependency artifacts that OSV can parse directly. SynSec independently normalizes and deduplicates those paths, requires regular non-symlink files under the authorized repository root, and caps native OSV scope at 100 changed paths. Recognized inputs include supported lockfile names, requirements variants, and SPDX/CycloneDX SBOM filenames. A mixed source/dependency scope, manifest or OSV configuration change, unsupported filename, missing file, symlink, path ambiguity, or oversized scope keeps OSV on its recursive repository scan. This deliberately prefers broader repository coverage over claiming a dependency-only optimization is complete. + +Gitleaks and Trivy staging deliberately fail closed. If a requested path is missing, a symlink, non-regular, or escapes the repository, the adapter discards the targeted optimization and runs its normal full repository scan instead. Gitleaks additionally falls back when its repository-local configuration is ambiguous. Neither adapter follows a changed-file symlink into an external path or silently drops an unsafe changed path. + +Checkov, Gitleaks, and Trivy each impose an adapter-level 500-file limit even though the engine normally applies a tighter planner bound. OSV-Scanner imposes a 100-path native dependency-input limit. These adapter checks are defense in depth for direct SDK use. Oversized requests are never silently truncated; adapters either reject them under their existing contract or fall back to repository execution. + +Other scanner adapters may still perform their normal repository analysis before SynSec filters file-located findings. A scanner is not described as natively incremental until its adapter explicitly narrows the underlying scanner command safely. + +## Per-scanner execution provenance + +A report-level changed-file scope does not imply that every scanner executed the same way. Each scanner summary can therefore carry an `executionScope` object with one of three modes: + +- `repository`: the scanner ran against the repository scope because the overall scan was full-repository; +- `changed-files-native`: the underlying scanner command was actually narrowed to the planner-selected files; or +- `repository-then-filtered`: the scanner still analyzed repository scope and SynSec filtered file-located findings afterward. + +`changedFileCount` records the selected path count for changed-file modes. The fixed interpretation string is `scanner-execution-scope-not-coverage-proof`: execution provenance describes how work was performed, not evidence that unselected code is unaffected. + +Opengrep, Betterleaks, Checkov, Gitleaks, Trivy, and OSV-Scanner are classified as native changed-file adapters. OSV-Scanner earns that classification only for dependency-artifact-only scopes that satisfy its adapter checks. Grype, Syft, and Scorecard intentionally remain repository-wide because dependency inventory, SBOM generation, or repository-posture semantics would be weakened by pretending they are file-local. Their changed-file reports therefore use `repository-then-filtered` when the overall engine scope is targeted. + +Adapters override their default classification when the actual execution differs. Gitleaks and Trivy record `repository-then-filtered` if staged-file safety checks force their normal full scan. OSV-Scanner does the same whenever its selected paths cannot all be represented as safe direct dependency inputs. This makes fallback machine-readable instead of relying on a diagnostic string or implying that the targeted optimization succeeded. + +The field is additive and optional so existing/imported schema-version-1 reports remain readable. New engine-generated reports populate it for scanner runs. + +## Hosted GitHub App pull requests + +Hosted App workers already acquire the exact queued base and head commits into separate detached workspaces. `deriveExactChangedFiles()` compares those two local Git trees using bounded `git ls-tree` output. It does not trust branch names, webhook clone URLs, default branches, scanner-suggested targets, or an unbounded history fetch. + +Only changed blob paths that exist in the head can become targeted scanner input. The comparison falls back to a full repository scan when: + +- either tree cannot be read within the configured time/output bounds; +- tree output is malformed or contains unsafe paths; +- repository/tree entry counts or changed-file counts exceed bounds; +- a changed entry is not a normal blob (for example a submodule entry); or +- any path was deleted. + +Deletions intentionally force a full scan because targeted scanner adapters must not receive absent paths and SynSec does not manufacture a partial deletion-remediation proof. + +When the exact tree comparison succeeds, the resulting direct paths still pass through the engine's conservative incremental planner before scanner execution. Therefore high-impact or structurally ambiguous changes can still expand to a full repository scan. + +## Baseline and remediation semantics + +An incremental report cannot treat every baseline finding missing from the partial result as fixed. `applyEvidenceAwareBaseline()` marks an absent baseline finding fixed only when the current report covered that finding path and at least one scanner that previously detected it ran again. Findings outside a changed-file scope, findings without a path in a partial scan, and findings whose detecting scanner did not rerun are not reported as fixed merely because they are absent. + +New and persisting findings continue to use the stable normalized SynSec fingerprint. The same evidence rule protects full scans from calling a finding fixed when its detecting scanner was omitted from the current scanner set. + +## SARIF safety + +Hosted App workers currently keep SARIF-enabled pull-request jobs on full-repository head scans even when an exact changed-file plan is available. Publishing a partial SARIF analysis as the latest code-scanning analysis can make untouched alerts appear absent, so SynSec does not use partial hosted SARIF until it has an explicit merge-safe publication contract. + +Checks publication can use the changed-file report because annotations are report-local and baseline-aware. Publication still requires the completed report commit to equal the queued GitHub head SHA. + +## Security boundary + +Incremental planning never authorizes network assessment or target expansion. The only targets are files inside the already-authorized repository checkout. Scanner subprocess credential minimization, fixed-host GitHub acquisition/publication, installation authorization checks, exact commit binding, and existing workflow capability restrictions remain unchanged. diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 00000000..ffb51f69 --- /dev/null +++ b/docs/INSTALL.md @@ -0,0 +1,196 @@ +# Installing SynSec and scanner engines + +SynSec itself is a Node.js application. Detection engines remain separate binaries so they can be upgraded independently and keep their original licenses. + +You do **not** need every engine installed to use SynSec. `synsec doctor` shows what is available and scans continue with the engines that are present. + +## SynSec + +```bash +git clone https://github.com/cmahmud/synsec.git +cd synsec +npm install +npm run build +npm run synsec -- doctor . +``` + +Node.js 20+ is supported. Node.js 24 is recommended. + +## Opengrep + +Project: https://github.com/opengrep/opengrep + +Linux/macOS: + +```bash +curl -fsSL https://raw.githubusercontent.com/opengrep/opengrep/main/install.sh | bash +``` + +Windows PowerShell: + +```powershell +irm https://raw.githubusercontent.com/opengrep/opengrep/main/install.ps1 | iex +``` + +Confirm: + +```bash +opengrep --version +``` + +## Betterleaks + +Project: https://github.com/betterleaks/betterleaks + +Betterleaks is SynSec's preferred secrets engine for new installs. It is maintained by the team behind Gitleaks. + +macOS: + +```bash +brew install betterleaks +``` + +With Go: + +```bash +go install github.com/betterleaks/betterleaks@latest +``` + +Or use a release binary from the project's GitHub Releases page. + +SynSec runs Betterleaks with fully redacted report output. Live credential validation is not enabled by the SynSec adapter. + +## Gitleaks (optional fallback) + +Project: https://github.com/gitleaks/gitleaks + +SynSec keeps a Gitleaks adapter for environments that already have it installed, but it is not in the default scanner list. + +## OSV-Scanner + +Project: https://github.com/google/osv-scanner + +With Go: + +```bash +go install github.com/google/osv-scanner/v2/cmd/osv-scanner@latest +``` + +Prebuilt release binaries are also available from GitHub Releases. + +Confirm: + +```bash +osv-scanner --version +``` + +## Trivy + +Project: https://github.com/aquasecurity/trivy + +Use the installation method documented by Aqua for your operating system. Trivy is available through common package managers and as a standalone binary. + +Confirm: + +```bash +trivy --version +``` + +## Grype + +Project: https://github.com/anchore/grype + +Linux/macOS installation helper published by Anchore: + +```bash +curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b "$HOME/.local/bin" +``` + +Confirm: + +```bash +grype version +``` + +## Checkov + +Project: https://github.com/bridgecrewio/checkov + +`pipx` is recommended so Checkov does not modify the system Python environment: + +```bash +pipx install checkov +``` + +Confirm: + +```bash +checkov --version +``` + +## Syft + +Project: https://github.com/anchore/syft + +Anchore publishes an installation helper for Linux/macOS. Installing into a user-writable bin directory avoids requiring `sudo`: + +```bash +mkdir -p "$HOME/.local/bin" +curl -sSfL https://get.anchore.io/syft | sh -s -- -b "$HOME/.local/bin" +``` + +Make sure `$HOME/.local/bin` is in `PATH`, then confirm: + +```bash +syft version +``` + +SynSec runs Syft against the repository filesystem and stores a normalized SBOM artifact containing package identity, version, package type, PURL, licenses, and known package locations. Syft does not create vulnerability findings by itself; vulnerability engines remain separate. + +## OpenSSF Scorecard + +Project: https://github.com/ossf/scorecard + +Scorecard currently documents macOS and Linux as its supported CLI platforms. Homebrew is one convenient install path: + +```bash +brew install scorecard +``` + +Standalone release binaries are also available from its GitHub Releases page. + +Confirm: + +```bash +scorecard --version +``` + +Some Scorecard checks use GitHub APIs. For complete scans without the low unauthenticated API limit, configure one of Scorecard's supported GitHub token environment variables such as `GITHUB_AUTH_TOKEN`. Do not commit that token to a repository. + +## Verify the full setup + +From the SynSec repository: + +```bash +npm run synsec -- doctor . +``` + +A healthy setup can look like: + +```text +OK Opengrep ... +OK Betterleaks ... +DISABLED Gitleaks ... +OK OSV-Scanner ... +OK Trivy ... +OK Grype ... +OK Checkov ... +OK Syft ... +OK OpenSSF Scorecard ... +``` + +Missing scanners are not fatal unless your own CI policy requires them. SynSec reports unavailable selected engines so a scan cannot silently pretend that coverage existed. If no selected scanner can run, the scan fails rather than generating a clean-looking report without coverage. + +## Network/privacy notes + +Some engines use network services for rule or vulnerability metadata. See the main README for the current privacy model. AI review is separately opt-in, and source excerpts are not sent to a model endpoint unless explicitly enabled. diff --git a/docs/KOA_REQUEST_INPUT_FLOW.md b/docs/KOA_REQUEST_INPUT_FLOW.md new file mode 100644 index 00000000..19f09b75 --- /dev/null +++ b/docs/KOA_REQUEST_INPUT_FLOW.md @@ -0,0 +1,40 @@ +# Koa request-input flow evidence + +SynSec exposes a deliberately narrow Koa-specific request-source analysis for routes already resolved by the strict `@koa/router` / `koa-router` composer. + +## What is recognized + +A Koa route must first satisfy the existing router-composition constraints: one unaliased Router import/require, direct `const` router construction with an optional literal prefix, an unreassigned router binding, and plain-identifier callbacks. Koa-produced routes carry the distinct `Koa router` framework identity so they cannot be confused with generic Node router evidence. + +For a resolved Koa route handler, SynSec treats only the handler's first plain identifier parameter as the structural Koa context. It recognizes these explicit accesses: + +- `ctx.request.body` as body evidence; +- `ctx.query` or `ctx.request.query` as query evidence; +- `ctx.params` as path evidence; +- `ctx.headers`, `ctx.request.headers`, or `ctx.get(...)` as header evidence; +- `ctx.cookies.get(...)` as cookie evidence. + +`ctx.body` is deliberately excluded because Koa uses that property for the response body rather than request input. + +The access must occur either on the exact sink line or on the same line as one directly resolved call into the sink-owning function. Exact finding correlation returns only route/method, aggregate source and sink kinds, function names, and direct call distance. Request keys, values, and source text are not copied into finding evidence. + +## Fail-closed exclusions + +The Koa-specific layer intentionally does not infer flow through: + +- locals assigned from request access and used later; +- aliases, destructuring, transformations, spreads, or member copies; +- middleware-to-handler context propagation; +- calls more than one direct edge beyond the source line; +- inline/dynamic router callbacks rejected by the Koa composer; +- generic Express/Node routes that merely use a variable named `ctx`; +- response-only properties such as `ctx.body`; +- unsafe paths, symlinks, oversized source files, or unsupported file types. + +Those shapes require separate, bounded analyses. They are not silently promoted into a directional data-flow claim. + +## Security interpretation + +Every result is labeled `structural-koa-context-source-direct-call-sink-evidence-only`. + +This means SynSec observed a strict Koa route shape, an explicit access on the structural handler context parameter, and an exact same-line sink or one direct resolved call to the sink-owning function. It does **not** prove that the router is mounted at runtime, the route is externally reachable, the value is attacker-controlled in a deployment, middleware executes, authorization is effective, sanitization is absent, or the sink is exploitable. diff --git a/docs/KOA_ROUTER_COMPOSITION.md b/docs/KOA_ROUTER_COMPOSITION.md new file mode 100644 index 00000000..998d4dcb --- /dev/null +++ b/docs/KOA_ROUTER_COMPOSITION.md @@ -0,0 +1,86 @@ +# Koa router composition + +SynSec provides a deliberately bounded structural model for Koa routes registered through `@koa/router` or the legacy `koa-router` package. + +## Accepted shape + +The analyzer recognizes only an unaliased `Router` import or CommonJS require, a `const` router constructed directly with `new Router()`, and an optional literal `prefix`: + +```ts +import Router from "@koa/router"; + +const router = new Router({ prefix: "/api" }); + +function requireUser(ctx, next) { + return next(); +} + +function createUser(ctx) { + saveUser(ctx.request.body); +} + +router.post("/users", requireUser, createUser); +``` + +For this shape SynSec can create structural route evidence for `POST /api/users`. The final plain-identifier callback is the handler. Earlier plain-identifier callbacks are retained separately as route middleware attachment evidence. + +Same-file handlers are resolved against the bounded lexical call graph. An unresolved handler may subsequently resolve through SynSec's existing explicit repository-local named-import resolver when there is exactly one supported import binding, one target function, and matching export evidence. + +## Directional request-input evidence + +Strict Koa-composed handlers may also produce deliberately narrow request-input-to-sink evidence. The handler's first plain identifier parameter is treated as the structural Koa context only for an exact resolved `Koa router` entrypoint. + +Direct evidence recognizes request-side access through `ctx.request.body`, `ctx.query`, `ctx.request.query`, `ctx.params`, `ctx.headers`, `ctx.request.headers`, `ctx.get(...)`, and `ctx.cookies.get(...)` when that access occurs on the exact sink line or on the same line as one resolved direct call to the sink-owning function. `ctx.body` is excluded because Koa uses it as response state. + +A separate one-local forwarding layer recognizes only an exact immutable assignment followed by one unchanged single-argument use, for example: + +```ts +function createUser(ctx) { + const command = ctx.request.body.command; + execute(command); +} + +function execute(command) { + child_process.exec(command); +} +``` + +The local must be declared with `const`, have exactly one later occurrence in the handler, remain within the configured forward-line bound, and be passed unchanged as the sole argument of an exact call. Multiple use, reassignment-capable `let`/`var`, transformation, aliasing, destructuring, object spreading, middleware propagation, and deeper forwarding fail closed. + +For database evidence the Koa directional layers are stricter than the generic lexical sink index: the sink line must contain member-qualified database-style syntax such as `db.query(...)` or `client.execute(...)`. Bare local helpers named `query` or `execute` are not promoted into database flow merely because their names look sink-like. + +Direct evidence uses `structural-koa-context-source-direct-call-sink-evidence-only`. One-local forwarding uses `structural-koa-context-source-single-use-local-call-sink-evidence-only`. Finding correlation returns source/sink categories and function identities without serializing request keys or source expressions. + +## Fail-closed cases + +The Koa model intentionally produces no composed or directional evidence for ambiguous or unsupported shapes, including: + +- dynamic or computed router prefixes; +- Router import aliases or multiple competing Router bindings; +- router factories instead of direct `new Router(...)` construction; +- reassigned router variables; +- inline callbacks, member-expression handlers, middleware factories, or transformed callback expressions; +- ambiguous same-file or imported handlers; +- unsupported HTTP registration syntax; +- request aliases, destructuring, transformations, multi-use locals, mutable local bindings, wider propagation, or unsupported call shapes; +- generic Node routes that merely happen to use a `ctx`-looking parameter; +- unsafe, symlinked, oversized, or out-of-root repository files. + +Output, file reads, forward distance, evidence count, and call-neighborhood traversal remain bounded by repository route-flow analysis limits. + +## Security interpretation + +Koa route and directional request evidence are static repository structure only. They do **not** prove that: + +- the router is mounted into a running Koa application; +- a deployment exposes the route externally; +- attached middleware executes or successfully authenticates/authorizes a request; +- a request value is attacker controlled; +- a value reaches a sink at runtime; +- a value is or is not sanitized; +- a sink is runtime reachable; or +- a correlated finding is exploitable or non-exploitable. + +Middleware attachment uses the interpretation `structural-koa-route-middleware-attachment-not-runtime-protection`. Route/call/sink correlation continues to use the existing structural evidence labels. + +This separation is intentional: repository syntax can improve review prioritization without being promoted into a runtime security claim. diff --git a/docs/LIFECYCLE_REVIEW_DEADLINES.md b/docs/LIFECYCLE_REVIEW_DEADLINES.md new file mode 100644 index 00000000..6c6d913a --- /dev/null +++ b/docs/LIFECYCLE_REVIEW_DEADLINES.md @@ -0,0 +1,54 @@ +# Lifecycle exception review deadlines + +SynSec lifecycle records can attach an optional `reviewAt` timestamp to human triage decisions. `@synsec/lifecycle/review-deadlines` turns those timestamps into a deterministic governance report without changing scanner evidence or lifecycle state. + +The assessment intentionally treats only `accepted-risk` and `false-positive` records as reviewable exceptions. Scanner-derived states such as `new`, `confirmed`, `fixed`, and `regressed` are excluded even if they happen to carry an old review timestamp. + +```ts +import { assessLifecycleReviewDeadlines } from "@synsec/lifecycle/review-deadlines"; + +const assessment = assessLifecycleReviewDeadlines(store, { + now: new Date().toISOString(), + dueSoonWindowMs: 7 * 24 * 60 * 60 * 1000, +}); +``` + +The report classifies scheduled exception reviews as `overdue`, `due-soon`, or `scheduled` and separately counts reviewable exceptions that have no deadline. Items are ordered by deadline and then fingerprint for stable CI/reporting output. + +To keep this artifact suitable for broader operational reporting, it deliberately omits lifecycle notes, owners, report identifiers, and source paths. It contains only the finding fingerprint, triage state, deadline, and derived deadline status. + +The due-soon window defaults to seven days and is bounded between zero and 365 days. Invalid assessment clocks or window values fail closed. + +## Aggregate policy gate + +Hosted or shared CI surfaces often do not need individual finding identifiers. `@synsec/lifecycle/review-policy` therefore converts an assessment into an aggregate policy result containing only counts, deterministic violation names, the assessment generation time, and a `ready` boolean. + +```ts +import { evaluateLifecycleReviewPolicy } from "@synsec/lifecycle/review-policy"; + +const result = evaluateLifecycleReviewPolicy(assessment, { + failOnOverdue: true, + failOnUnscheduled: true, +}); +``` + +The policy gate validates that the supplied summary is internally consistent before evaluating it. Its output does not copy fingerprints, source paths, owners, notes, report ids, or individual review timestamps. It is suitable for status checks and aggregate dashboards where disclosure of per-finding governance metadata is unnecessary. + +## CLI governance checks + +The same assessment is available through the credential-free CLI: + +```text +synsec-lifecycle-reviews .synsec/lifecycle.json +synsec-lifecycle-reviews .synsec/lifecycle.json --json +synsec-lifecycle-reviews .synsec/lifecycle.json --due-soon-days 14 --fail-overdue --fail-unscheduled +synsec-lifecycle-reviews .synsec/lifecycle.json --summary-only --json --fail-overdue --fail-unscheduled +``` + +`--summary-only` emits the aggregate policy projection rather than the per-finding assessment. It omits the lifecycle file path, finding fingerprints, triage states, and individual review timestamps, making it the preferred mode for shared CI logs and hosted operational surfaces. + +`--fail-overdue` returns exit code `2` when at least one exception is overdue. `--fail-unscheduled` returns exit code `3` when reviewable exceptions exist without a deadline, unless the overdue policy already failed. Invalid input or unsupported options return exit code `1`. These policy codes make the command suitable for repository CI/governance workflows without changing finding state. + +The CLI bounds its input file to 1 MiB before lifecycle parsing, requires that the supplied path itself be a regular file, and rejects symlinks before reading their targets. That prevents a repository-controlled path from redirecting a CI governance step to an arbitrary host file. Unknown options are rejected without reflecting their values. The CLI accepts `--now ` for deterministic testing or scheduled policy evaluation and `--due-soon-days <0-365>` to tune the reporting window. + +This API and CLI are reporting/governance surfaces only. An overdue accepted-risk or false-positive decision is not silently converted back into a scanner finding state, and SynSec does not automatically revoke a human exception. Teams can use the assessment to require re-review while preserving an explicit human decision boundary. diff --git a/docs/NESTJS_CONTROLLER_COMPOSITION.md b/docs/NESTJS_CONTROLLER_COMPOSITION.md new file mode 100644 index 00000000..08077a05 --- /dev/null +++ b/docs/NESTJS_CONTROLLER_COMPOSITION.md @@ -0,0 +1,23 @@ +# NestJS controller composition + +SynSec can derive bounded structural route evidence from a deliberately narrow subset of NestJS controller syntax. This improves repository-first correlation without treating decorators as proof of runtime behavior. + +## Accepted structure + +The analyzer requires one-line, unaliased named imports from `@nestjs/common`. It recognizes literal or empty `@Controller(...)` prefixes on class declarations and literal or empty `@Get`, `@Post`, `@Put`, `@Patch`, `@Delete`, `@Options`, and `@Head` decorators on immediate class methods. + +For example, `@Controller("admin")` plus `@Post("run")` produces the structural route `/admin/run`. The resolved class method is added to the bounded lexical call graph so an exact sink located inside that method can participate in the same route-to-sink correlation used by other supported frameworks. + +`@UseGuards(SessionGuard, AdminGuard)` is retained only when every argument is a plain identifier and `UseGuards` itself is an unshadowed direct import from `@nestjs/common`. Guard evidence is labeled `structural-nestjs-guard-attachment-not-runtime-protection`. + +## Fail-closed cases + +SynSec deliberately emits no NestJS composition evidence when the required syntax is ambiguous. Unsupported cases include dynamic controller or route paths, aliased or shadowed NestJS decorators, malformed/unbounded class or method bodies, multiple HTTP decorators for one method, decorator factories, and guard expressions such as `AuthGuard("jwt")`. + +Files are subject to the same repository-root, regular-file, non-symlink, source-size, file-count, and output bounds as the aggregate route-flow analysis. + +## Security interpretation + +This layer is static repository evidence only. It does **not** prove that NestJS discovers or instantiates a controller, that a module imports it, that a route is externally reachable, that a guard executes, that a guard permits or denies a request, or that a finding is exploitable. + +In particular, an auth-looking guard name is never treated as proof of authentication or authorization. Guard attachment is surfaced for review context only. Runtime claims require independent runtime or deployment evidence. diff --git a/docs/POSTGRES_SHARED_STATE.md b/docs/POSTGRES_SHARED_STATE.md new file mode 100644 index 00000000..ba5f52d9 --- /dev/null +++ b/docs/POSTGRES_SHARED_STATE.md @@ -0,0 +1,83 @@ +# PostgreSQL shared state + +SynSec now includes a built-in PostgreSQL implementation for all seven hosted GitHub App shared-state capabilities. The low-level replay/queue pieces are exported from `@synsec/github/postgres-shared-state`, transactional installation authorization from `@synsec/github/postgres-installation-store`, and the production composition boundary from `@synsec/github/postgres-shared-backend`. + +All database APIs accept a caller-owned PostgreSQL pool through a narrow `query()` / `connect()` interface. SynSec does not accept, parse, persist, or expose a database connection string through these APIs. Hosting code remains responsible for constructing the client from its secret manager and for keeping database credentials outside scanner processes. + +## Implemented guarantees + +The PostgreSQL backend implements and exercises the complete shared-state contract against a real PostgreSQL 16 service in CI: + +- `atomicReplayClaim` — one SQL upsert/CTE atomically accepts a new or expired delivery while concurrent replicas observe the same durable claim. +- `atomicQueueInsertion` — a database unique constraint prevents duplicate delivery insertion; queue capacity and insertion are serialized by a transaction-scoped PostgreSQL advisory lock. +- `atomicQueueClaimWithFence` — workers select claimable work with `FOR UPDATE SKIP LOCKED` and create a fresh random lease id in the same atomic update. +- `compareAndSetLeaseRenewal` — renewal updates only the currently leased row with the exact unexpired lease id. +- `fencedQueueTransitions` — release, failure, and completion require the exact current unexpired lease id. +- `transactionalInstallationState` — installation and repository-selection read/modify/write operations execute on one transaction-scoped connection under an installation-specific PostgreSQL advisory lock, so independent replicas cannot overwrite one another's repository deltas. +- `sharedAuthorizationState` — authorization checks query the shared durable installation table each time, so suspension, deletion, and repository-selection changes become authoritative across independent replicas rather than relying on process-local caches. + +The database schema stores only replay identity/timestamps, commit-pinned scan-job metadata, installation/account authorization metadata, selected repository identities, lease metadata, and schema version state. It does not store installation tokens, GitHub App private keys, webhook secrets, repository credentials, scanner output, source excerpts, or arbitrary outbound URLs. + +## Canonical conformance + +The PostgreSQL CI job runs SynSec's existing canonical seven-scenario conformance runner against the real database service. The matrix covers concurrent duplicate replay claims, concurrent idempotent queue insertion, competing fenced claims, stale-fence renewal, stale-fence terminal transitions, concurrent installation-selection mutation, and cross-replica authorization revocation. + +Passing CI demonstrates the behavior of the built-in adapter implementation under those adversarial scenarios. It is still **evidence**, not a magic property inferred from a configuration flag. `createGitHubAppPostgresSharedRuntime()` routes through the same evidence gate as every other shared runtime and requires a complete conformance report whose `backendId` and `implementationVersion` exactly match the built-in PostgreSQL contract. + +The current stable identity is: + +- backend id: `postgres-v1` +- implementation version: `0.2.0-postgres-v1` + +Do not edit these fields in a stored report to make stale evidence appear current. The evidence gate recomputes canonical coverage and checks exact identity matching. + +## Schema migration + +Use `migrateSynSecGitHubPostgresBackend(pool)` from `@synsec/github/postgres-shared-backend` before activating the stores. The composed migration: + +- runs under one transaction-scoped PostgreSQL advisory lock so concurrent deployment replicas cannot race DDL; +- creates replay, queue, installation, index, and schema-version structures idempotently; +- records shared-state schema version `1` and fails closed on an unsupported recorded version; and +- repairs the pre-release replay timestamp shape to millisecond precision so the exact replay claim token returned through JavaScript can be used for compare-and-set release without precision loss. + +Migration remains deliberately separate from runtime construction. Production operators can therefore execute it with a purpose-specific deployment identity and avoid granting DDL privileges to long-running webhook or worker processes. + +## Store composition + +```ts +import { Pool } from "pg"; +import { + buildSynSecGitHubPostgresBackendContract, + createSynSecGitHubPostgresSharedStores, + migrateSynSecGitHubPostgresBackend, +} from "@synsec/github/postgres-shared-backend"; + +const pool = new Pool({ connectionString: process.env.SYNSEC_DATABASE_URL }); +await migrateSynSecGitHubPostgresBackend(pool); + +const stores = createSynSecGitHubPostgresSharedStores(pool); +const contract = buildSynSecGitHubPostgresBackendContract(); +``` + +The example keeps the connection string in hosting code. Do not pass `SYNSEC_DATABASE_URL`, GitHub tokens, App private keys, webhook secrets, or other hosting credentials into scanner environments. + +## Evidence-gated hosted runtime + +Once a complete portable conformance report has been produced for the exact built-in backend identity, hosting code can compose the concrete stores through `createGitHubAppPostgresSharedRuntime()`: + +```ts +const runtime = createGitHubAppPostgresSharedRuntime({ + pool, + conformanceReport, + webhookSecret, + worker, +}); +``` + +The factory generates the backend contract itself and does not accept caller-supplied capability booleans. Invalid, incomplete, tampered, or stale conformance evidence fails before the external stores become active. + +This runtime boundary does not manage PostgreSQL credentials, create cloud databases, run migrations automatically, or weaken SynSec's existing GitHub authorization, replay, lease-fencing, exact-commit acquisition, scanner credential-isolation, or publication checks. + +## Operational boundary + +A passing shared-state conformance report establishes only the tested coordination semantics. It does not certify PostgreSQL availability, backups, encryption, tenant isolation, disaster recovery, network policy, scanner sandboxing, or GitHub App credential management. Those remain separate hosting responsibilities and readiness boundaries. diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md new file mode 100644 index 00000000..a1039289 --- /dev/null +++ b/docs/RELEASE_READINESS.md @@ -0,0 +1,26 @@ +# Release readiness + +SynSec exposes a machine-readable release-readiness assessment so a green build is not automatically treated as evidence that the repository is taggable. + +## Commands + +- `npm run release:check` validates hard release invariants and reports unresolved blockers. Known blockers do not make this command fail so it can run continuously in pull-request CI. +- `npm run release:check:json` emits the same assessment as bounded JSON for release automation and operator tooling. +- `npm run release:ready` is the strict tag gate. It exits non-zero for both hard invariant errors and unresolved blockers. + +The assessment intentionally distinguishes `errors` from `blockers`: + +- **Errors** mean an invariant that the current release line depends on has regressed, such as dropping the Node 20/24 matrix, PostgreSQL shared-state conformance, enforced OCI isolation coverage, operator documentation, the root private-package guard, or the current Node engine policy. +- **Blockers** mean a required production/release property is explicitly not complete yet. They must prevent tagging but do not need to turn every development CI run red. + +## Current reproducibility blocker + +The repository currently has no committed `package-lock.json`, and CI therefore still uses `npm install`. The readiness assessment reports `dependency-lockfile-missing` and strict release readiness fails. + +Do not hand-author or guess a lockfile. Generate it from the repository's verified npm dependency graph in an environment with registry access, review the resulting dependency changes, commit it, and then change every CI dependency-installation step to `npm ci`. Once a lockfile exists, the readiness checker will continue to block release until CI actually enforces it. + +## Trust boundary + +The checker is repository-state evidence only. It does not prove that a deployment is healthy, GitHub accepted App credentials, a migration succeeded in an operator database, a scanner image is safe, a hosted tenant is authorized, or a release artifact was deployed successfully. Those properties remain governed by their existing runtime, transactional-backend, credential, maintenance, and upgrade boundaries. + +Release automation should preserve this distinction: a successful `release:ready` means the repository satisfies the encoded pre-tag invariants. It is not a substitute for deployment-specific rollout and rollback evidence. diff --git a/docs/REMEDIATION_PR.md b/docs/REMEDIATION_PR.md new file mode 100644 index 00000000..e8711a56 --- /dev/null +++ b/docs/REMEDIATION_PR.md @@ -0,0 +1,58 @@ +# Remediation PR workflow + +SynSec remediation remains repository-first and approval-gated. The `remediation-pr` workflow may prepare a patch proposal from normalized repository findings and bounded source context, but proposal generation is not permission to write a branch, commit, or pull request. + +`@synsec/workflows/remediation` defines the artifact boundary used between review and any future repository writer. + +## Proposal contract + +A remediation proposal is bound to one exact repository commit SHA and one explicit set of finding ids. It contains between 1 and 200 repository-relative file changes and currently permits only `create` and `modify` operations. File deletion is intentionally unsupported in this first write workflow because it is higher impact and harder to review safely. + +Each patch is bounded to 256 KiB and the whole proposal to 2 MiB of patch text. Paths are normalized and cannot escape the repository or address `.git` metadata. Duplicate normalized paths are rejected. External network assessment remains forbidden by the workflow policy. + +The proposal id is a SHA-256 digest over the workflow id, target commit, finding ids, summary, file paths, operations, and per-patch hashes. Before approval or execution, SynSec re-hashes every patch body and revalidates the full proposal digest. Changing patch text after review therefore invalidates the proposal even if the stored patch hash is left untouched. + +## Explicit approval + +`approveRemediationProposal()` requires the exact proposal id and records a bounded approver identifier plus approval timestamp. It does not perform a repository write. + +Immediately before a writer acts, `authorizeRemediationExecution()` revalidates both the proposal and approval and requires the repository's current head SHA to still equal the proposal target SHA. If the head moved, execution fails closed and the patch must be regenerated and reapproved against the new source state. + +```ts +import { getWorkflow } from "@synsec/workflows"; +import { + approveRemediationProposal, + authorizeRemediationExecution, + createRemediationProposal, +} from "@synsec/workflows/remediation"; + +const workflow = getWorkflow("remediation-pr"); +if (!workflow) throw new Error("remediation-pr workflow unavailable"); + +const proposal = createRemediationProposal(workflow, { + targetCommitSha: scan.report.target.commitSha, + findingIds: [finding.fingerprint], + summary: "Validate the affected input and add a regression test.", + changes: proposedChanges, +}); + +// Present the exact proposal/patch set to a human review surface first. +const approval = approveRemediationProposal(proposal, { + proposalId: proposal.proposalId, + approvedBy: reviewerIdentity, +}); + +const execution = authorizeRemediationExecution({ + proposal, + approval, + currentHeadSha, +}); +``` + +## Writer boundary + +The execution object is still only authorization evidence. This module deliberately does not create branches, commits, or pull requests and does not hold GitHub write credentials. A repository writer must consume the approved object explicitly and preserve the same exact-head and exact-patch boundaries. + +When GitHub write execution is added, it should use a purpose-specific installation token with the minimum repository permission needed, create a dedicated SynSec remediation branch, refuse force-pushes, never modify an unrelated branch, and publish the proposal id and source commit in the pull-request metadata. Any change to the approved patch set must require a new proposal id and a new approval. + +This workflow does not permit live-target exploitation, secret retrieval or validation, persistence, arbitrary repository expansion, or autonomous merging. diff --git a/docs/REMEDIATION_PRS.md b/docs/REMEDIATION_PRS.md new file mode 100644 index 00000000..d9ecd5e7 --- /dev/null +++ b/docs/REMEDIATION_PRS.md @@ -0,0 +1,42 @@ +# Approved remediation pull requests + +SynSec remediation remains an explicitly approved repository-write workflow. Proposal generation does not grant write permission, scan workers never receive repository-write credentials, and a remediation writer may act only on an `ApprovedRemediationExecution` whose proposal, patch hashes, approval id, and target commit are revalidated immediately before execution. + +## Execution boundary + +`@synsec/github/remediation-writer` consumes an approved execution plus one exact acquired worktree. It reruns the remediation authorization/integrity checks before issuing even a local Git command, so mutation of the JavaScript execution object after an earlier approval check cannot substitute different patch contents. Before changing the worktree it then verifies local `HEAD`, queries the fixed `https://github.com//.git` transport for the configured base ref, and requires that remote ref to still equal the approved target commit. If the base moved, remediation stops before patch application and the proposal must be regenerated and approved again. + +The writer writes the already-approved patch bodies to a private temporary file, runs `git apply --cached --check`, applies to the index only, then verifies the staged `A`/`M` path set exactly equals the proposal. Renames, deletions, extra files, missing files, or operation mismatches fail closed before a commit or network write. + +A successful staged patch is committed with fixed SynSec author identity and the approval timestamp as the Git author/committer date. This makes retries deterministic for the same parent/tree/message. The destination branch is derived only from the proposal id (`synsec/remediation/`). Pushes are never forced. If that branch already exists, it is accepted only when it already points at the exact deterministic remediation commit; a conflicting branch fails closed. + +After the branch is present, the writer opens the pull request only through `https://api.github.com/repos///pulls` with redirects rejected. The returned PR URL must remain on `github.com`. + +## Hosted runtime composition + +`createLocalGitHubAppRuntime()` exposes `createRemediationPullRequest()` as an explicit operator action. It is not called from webhook intake, scan dispatch, scan workers, findings, AI review, or automatic lifecycle transitions. + +The runtime rechecks installation/repository authorization and first requests the normal `acquire` credential, which requires only `contents:read`, to materialize the exact approved target commit. Only after acquisition succeeds does it request a fresh token for the distinct `remediate` purpose and pass that credential to the approval-bound writer. The acquired worktree is cleaned afterward even when the write operation fails. + +The remediation token purpose requires: + +- `contents:write` for the non-force branch push; and +- `pull_requests:write` for pull-request creation. + +Normal scan and remediation-source acquisition therefore continue to use `contents:read`. Check publication still requests `checks:write` and optional `security_events:write`. The shorter-lived write-capable credential is minted only at the explicit approved write boundary and is never passed to scanners. + +## GitHub App setup + +`@synsec/github/app-setup` provides a feature-aware setup contract for operators. Repository scanning without remediation recommends only `contents:read` and `checks:write`, plus `security_events:write` when SARIF is enabled. `contents:write` and `pull_requests:write` appear only when `enableRemediationPullRequests` is explicitly enabled. The helper describes required permissions and webhook events; it does not create or broaden an installation. + +## Failure and retry semantics + +Failures before branch push do not write to the repository. A failure after a successful branch push but before PR creation can leave the deterministic remediation branch present. Retrying the exact same approved execution is safe only if that branch still points to the deterministic remediation commit; the writer then skips the push and retries PR creation. It never overwrites a changed branch. + +SynSec currently does not delete remediation branches automatically. Cleanup of abandoned remediation branches is an explicit repository-administration action, not a silent background mutation. + +## Deliberate limits + +The initial remediation writer supports only proposal operations already allowed by the workflow contract: file creation and modification. It does not support deletes, renames, submodule changes, `.git` metadata, arbitrary target URLs, force pushes, direct writes to the base branch, merge operations, or automatic approval. + +The writer also does not run a live-target verification step. Security validation remains repository-first: CI, scanners, tests, and review can validate the remediation PR without expanding into autonomous external assessment. diff --git a/docs/REQUEST_INPUT_FLOW.md b/docs/REQUEST_INPUT_FLOW.md new file mode 100644 index 00000000..efce3f8a --- /dev/null +++ b/docs/REQUEST_INPUT_FLOW.md @@ -0,0 +1,58 @@ +# Request-input flow evidence + +SynSec can derive a narrow static relationship between explicit web-request input access and sensitive repository sinks. This layer is deliberately conservative. Its output is structural review context only and must not be interpreted as runtime reachability, attacker control, variable-level taint, exploitability, or proof that a deployed route is exposed. + +## Evidence model + +The repository analyzer records a request-input source only when source code contains an explicit supported request-access expression. Examples include Node-style `req.body`, `req.query`, `req.params`, headers, cookies and files; Koa/Hono-style request access; and bounded Flask/Django request access such as query, body, headers, cookies and files. Merely naming a parameter `request`, using an auth-looking identifier, declaring a route, or importing a framework does not create source evidence. + +Request-input records contain only repository path, line, normalized input category, framework family, and a sanitized access category. They do not retain request values or arbitrary source-line text. + +For a source to participate in a cross-function source-to-sink relationship, all of the following must hold: + +1. The route has already resolved to one bounded lexical handler. +2. The request-access line belongs to exactly one lexical function in that route's bounded call neighborhood. +3. The sensitive sink line belongs to exactly one lexical function in the same bounded route neighborhood. +4. The explicit request access appears on the same source line as a resolved outbound call. SynSec does not infer that a local variable assigned on an earlier line remains request-controlled. +5. The sink-owning function is reachable from that source-bearing outbound call through bounded resolved same-file calls and, where applicable, explicit repository-local import bindings that resolve to one unique target function. + +A same-function relationship is emitted only when the explicit request access and sensitive sink are on the exact same line. This avoids manufacturing local data-flow evidence from lexical function membership alone. + +The emitted interpretation label is: + +`structural-request-source-call-sink-evidence-only` + +## Fail-closed behavior + +SynSec omits this evidence when import resolution is ambiguous, function ownership is ambiguous, an import binding is shadowed, the route cannot be resolved conservatively, the request access is separated from the outbound call by an untracked local assignment, or the requested traversal exceeds configured file/node/evidence bounds. + +For example, this does **not** create a source-to-sink relationship: + +```ts +function handler(req) { + const value = req.query.q; + unrelated(); +} + +function unrelated() { + db.query(sql); +} +``` + +Likewise, a handler that calls `consume(req.query.q)` and separately calls an unrelated sink-bearing function does not cause SynSec to associate the source with that sibling sink. Propagation begins only from the source-bearing call itself. + +## Exact finding correlation + +`findingRequestInputFlowEvidence()` returns minimized request-flow context only when a finding path and start line exactly match a sink line already linked by the structural source/call/sink analysis. Nearby findings are not upgraded merely because a related route or source exists elsewhere in the file. + +The correlated metadata contains route identity, handler, source category/function, sink category/function, bounded call distance and whether explicit local imports were used. It excludes source excerpts and request/scanner values. + +## Resource and trust boundaries + +Repository contents, file metadata and imported names are untrusted input. Request-input analysis operates only on the already bounded repository file inventory, rejects path escape and symlink source entries, caps file size/count and signal volume, and never executes repository code or performs network access. + +Scanner findings remain independent evidence. A request-input flow does not raise or suppress a scanner result by itself, and scanner-supplied metadata must not be treated as authoritative SynSec-derived flow evidence. + +## Current limitations + +This implementation intentionally does not provide SSA, AST-based taint propagation, alias analysis, object-property flow, sanitizer modeling, branch-sensitive control flow, interprocedural argument/return-value tracking, framework deployment resolution, or runtime instrumentation. Those capabilities should be added only when they can preserve the same fail-closed evidence semantics and resource bounds. diff --git a/docs/REQUEST_INPUT_FORWARDING.md b/docs/REQUEST_INPUT_FORWARDING.md new file mode 100644 index 00000000..4493b5e8 --- /dev/null +++ b/docs/REQUEST_INPUT_FORWARDING.md @@ -0,0 +1,31 @@ +# Bounded request-input forwarding + +SynSec's request-input forwarding analysis adds one deliberately narrow data-flow step on top of direct request-access evidence. It exists to cover a common repository pattern without turning lexical heuristics into a general taint engine. + +## What is recognized + +The forwarding layer currently applies only to JavaScript and TypeScript source. A candidate must have all of these properties: + +- The source is an explicit request access already recognized by SynSec, such as `req.body`, `req.query`, or `req.params`. +- The exact source line is a simple `const` declaration whose right-hand side is only a supported request access plus direct property or literal-key selection. +- The local binding has exactly one later use in the same lexical function within the configured line bound. +- That use passes the binding unchanged as a direct argument to a call. +- The call resolves to exactly one repository-local function through either the lexical same-file call graph or one explicit unique import binding. +- From that target, the bounded route call neighborhood reaches a sink already identified by SynSec. +- Exact finding correlation is performed only against the already-linked sink line. + +The resulting interpretation string is: + +`structural-request-source-immutable-binding-call-sink-evidence-only` + +## What deliberately fails closed + +SynSec omits forwarding evidence for reassignment or mutation, destructuring, multiple uses, transformations such as `normalize(value)`, nested call arguments, alias chains, sanitizer or validator steps, unresolved/external calls, ambiguous local targets, Python assignments, dynamic dispatch, and evidence outside the bounded route call neighborhood. + +This conservative behavior is intentional. In particular, a sanitizer-looking function is not assumed to sanitize data, and a request-looking variable name is not assumed to be attacker-controlled. + +## Security interpretation + +This is structural static evidence. It does **not** prove runtime reachability, attacker control, effective validation, exploitability, successful injection, authorization bypass, or vulnerability absence. The repository contents being analyzed are untrusted input; source text and local variable names are not copied into the exported evidence object. + +The older direct request-input flow remains a separate evidence layer. SynSec does not silently broaden its semantics: same-line direct source-to-call evidence and immutable-local-forwarding evidence have distinct interpretation labels and can be reviewed independently. diff --git a/docs/REQUEST_INPUT_RETURN_ALIAS_FLOW.md b/docs/REQUEST_INPUT_RETURN_ALIAS_FLOW.md new file mode 100644 index 00000000..994a3147 --- /dev/null +++ b/docs/REQUEST_INPUT_RETURN_ALIAS_FLOW.md @@ -0,0 +1,33 @@ +# Request-input helper return alias evidence + +SynSec exposes a deliberately narrow supplemental repository-intelligence layer at `@synsec/repository/request-input-return-alias-flow`. + +It recognizes only the structural shape where a JavaScript/TypeScript helper has exactly one direct `return req.` statement, the helper result is assigned to `const`, that binding is used exactly once to initialize one second `const` alias, and the alias is then used exactly once as the sole unchanged argument to one uniquely resolved call on a bounded route-to-sink path. + +For example: + +```ts +function readName(req) { + return req.body.name; +} + +function createUser(req) { + const name = readName(req); + const persistedName = name; + persistName(persistedName); +} +``` + +The evidence is labeled `structural-request-source-return-two-immutable-bindings-call-sink-evidence-only` and records `bindingHops: 2`. It is separate from the existing direct-binding return-flow evidence so consumers can distinguish the additional static inference. + +## Fail-closed boundary + +The analyzer emits no evidence when it sees a transformed value, a `let`/`var` binding, a second alias hop, mutation, multiple uses of either binding, multiple or conditional helper returns, destructuring, nested expressions, an unresolved or ambiguous call, unsupported Python flow, unsafe/symlinked/oversized files, or forwarding outside the configured line bound. + +Repository-local explicit import edges may participate only when the existing import-call resolver resolves them uniquely. Import relationships are structural evidence and are not proof that a deployment executes the code. + +## Security interpretation + +This layer does **not** establish runtime reachability, attacker control, sanitization status, exploitability, successful framework registration, or authorization. It does not implement a general taint engine and must not be used to claim that a sink is exploitable merely because this structural pattern exists. + +Finding correlation remains exact: sanitized evidence is returned only when the finding path and line exactly match the structurally linked sink line. diff --git a/docs/REQUEST_INPUT_RETURN_FLOW.md b/docs/REQUEST_INPUT_RETURN_FLOW.md new file mode 100644 index 00000000..fb75a55c --- /dev/null +++ b/docs/REQUEST_INPUT_RETURN_FLOW.md @@ -0,0 +1,42 @@ +# Request-input helper return flow + +SynSec has a deliberately narrow structural layer for one common repository pattern where a helper reads request input and returns it directly to a route-reachable caller: + +```ts +function readName(req) { + return req.body.name; +} + +function createUser(req) { + const name = readName(req); + persistName(name); +} +``` + +The resulting evidence is labeled `structural-request-source-return-binding-call-sink-evidence-only`. + +## What is accepted + +The analyzer currently accepts only bounded JavaScript/TypeScript evidence where all of the following are true: + +- the source is an explicit request access already recognized by SynSec; +- that source is the helper's only lexical `return` statement and the return expression is exactly a direct `req`/`request` body, query, path, header, cookie, or file access; +- the helper call resolves to exactly one same-file function or one explicit repository-local import target; +- the helper is called with one simple identifier and the result is assigned directly to a `const` binding; +- the binding has exactly one later lexical use in the caller; +- that use occurs within the configured line bound and is the only argument to one resolved direct call; and +- a route-linked sink exists on a bounded directed call path from that forwarding call. + +Exact sink-line correlation is available through `findingRequestInputReturnFlowEvidence()`. + +## Fail-closed cases + +SynSec emits no return-flow evidence for transformed returns, multiple return statements, destructuring, `let`/`var` return bindings, aliasing, mutation, multiple uses, nested call expressions, unresolved or ambiguous calls, Python helpers, unsupported request syntax, oversized/unsafe source files, or values forwarded outside the configured bound. + +Those omissions are intentional. They avoid turning a small structural feature into an unsound whole-program taint engine. + +## Security interpretation + +This evidence does **not** prove runtime reachability, attacker control, framework parameter binding, successful routing, sanitization or lack of sanitization, exploitability, or non-exploitability. Repository source, import relationships, request-looking identifiers, and scanner findings remain untrusted input. + +The layer records a bounded lexical relationship that can improve review prioritization and exact finding correlation. Runtime security claims still require appropriate dynamic or operational evidence. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b45ca605..9586f698 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,84 +1,212 @@ # SynSec Roadmap +This roadmap separates what is already usable in the repository from the deeper analysis and hosted-product work that follows it. + ## Phase 0 — Foundation - [x] Standalone public repository - [x] Normalized finding model - [x] Scanner adapter SDK -- [x] CLI skeleton -- [x] First real scanner integration: Trivy -- [x] Initial correlation layer -- [ ] CI green on Node 24 -- [ ] Stable configuration file format - -## Phase 1 — Repository scanner MVP - -- [ ] Opengrep adapter -- [ ] Gitleaks adapter -- [ ] OSV-Scanner adapter -- [ ] Checkov adapter -- [ ] Syft + Grype adapters -- [ ] OpenSSF Scorecard adapter -- [ ] SARIF import/export -- [ ] JSON report format with schema versioning -- [ ] Better cross-scanner deduplication -- [ ] Severity and confidence policy engine -- [ ] Ignore/baseline support -- [ ] Scan only changed files when appropriate +- [x] CLI foundation +- [x] Initial Trivy integration +- [x] Deterministic correlation layer +- [x] CI on Node 20 and Node 24 +- [x] Versioned configuration format + +## Phase 1 — Repository scanner MVP (v0.2) + +- [x] Opengrep adapter +- [x] Betterleaks adapter +- [x] Gitleaks fallback adapter +- [x] OSV-Scanner adapter +- [x] Trivy adapter +- [x] Grype adapter +- [x] Checkov IaC adapter +- [x] Syft SBOM adapter and normalized scanner-artifact model +- [x] OpenSSF Scorecard adapter +- [x] Bounded parallel scanner orchestration +- [x] Scanner failure isolation +- [x] Refuse false clean reports when no selected scanner can run +- [x] Versioned JSON report format +- [x] SARIF 2.1 export +- [x] Generic SARIF 2.1 import into normalized findings +- [x] Self-contained HTML report/dashboard +- [x] Stronger cross-scanner advisory and source-location correlation +- [x] Configurable CI severity threshold +- [x] Baseline support with new/fixed/persisting findings +- [x] Secret redaction in normalized output +- [x] Changed-file finding scope with persisted base/file metadata +- [x] Direct changed-file execution for Opengrep and Betterleaks +- [x] Native bounded changed-file execution for Checkov, Gitleaks, and Trivy with adapter-level validation/fallback +- [x] Conservative incremental scan planner with bounded local-dependent expansion and full-scan fallback +- [x] Engine integration of dependency-aware incremental planning for local and caller-supplied changed scopes +- [ ] Native incremental execution for every remaining scanner that can safely support it + +The incremental planner always includes direct changes and may add structurally known local dependents to improve review coverage. It fails over to a full repository scan when narrowing is ambiguous or high impact, including lockfiles, CI/security/IaC/configuration changes, unindexed analyzable source, unsafe paths, excessive change sets, or dependent expansion that would exceed its configured bound. `runScanEngine()` now applies this plan to locally discovered and explicitly supplied changed-file scopes, and hosted exact-tree PR paths pass through the same planner before scanner execution. This is a coverage heuristic only; it never claims that unselected files are unaffected or safe. Universal native incremental execution remains open because only adapters that can safely narrow their own underlying scanner invocation should do so; other engines may still require repository-wide analysis followed by SynSec scope filtering. ## Phase 2 — Repository intelligence -- [ ] Language/framework detection -- [ ] Repository index -- [ ] Import/call graph -- [ ] Routes and externally reachable entry points -- [ ] Authentication/authorization context -- [ ] Database and filesystem sinks -- [ ] Dependency reachability -- [ ] Finding-to-code context retrieval +- [x] Language/framework inventory +- [x] Safe bounded finding-to-code context retrieval +- [x] Persistent repository index +- [x] Import/module graph with bounded dependency/dependent traversal +- [x] Bounded same-file lexical call-graph primitive for JavaScript/TypeScript and Python +- [x] Conservative decorator-route to callable-entrypoint mapping +- [x] Conservative simple named Node router-handler mapping +- [x] Bounded route-level lexical authentication/authorization context +- [x] Bounded route-level lexical process/filesystem/database/network sink context +- [x] Exact-line structural route-call-sink finding enrichment for resolved same-file call neighborhoods +- [x] Bounded repository posture summary from route/auth/sink signals +- [x] Bounded likely test-ownership context from resolved imports and filename conventions +- [x] Bounded LCOV ingestion and finding-line test-coverage context primitive +- [ ] Full function/call graph with reliable cross-module symbol resolution +- [ ] Broad routes and externally reachable entry points across supported frameworks +- [ ] Framework-aware authentication/authorization enforcement semantics +- [ ] Data-flow-aware sink reachability beyond structural call/sink evidence +- [ ] Dependency reachability beyond scanner-provided call analysis +- [ ] Persisted/report-integrated runtime/test-run coverage context around findings + +The current call graph is deliberately labeled lexical evidence rather than runtime reachability. It resolves unambiguous direct same-file calls and leaves qualified, external, or ambiguous calls unresolved. Decorator-based route mapping links only a structurally close unambiguous declaration. Simple same-line Node router registrations can also carry a named handler candidate when the literal route is followed only by plain identifier arguments and exactly one same-file lexical function matches the final handler. Inline callbacks, call expressions, mounted routers, member expressions, spreads, dynamic paths, imported/duplicate handler ambiguity, and other compound framework shapes remain unresolved rather than guessed. + +Resolved route handlers can be combined with the bounded same-file call neighborhood and normalized sink lines. SynSec emits structural route-call-sink evidence only when a sink line belongs to exactly one reachable lexical function; finding enrichment additionally requires an exact normalized path/start-line match and is disabled for secret findings. This remains `structural-route-call-sink-evidence-only`, not proof of deployed exposure, attacker control, real request reachability, or executable data flow. + +Route authentication and nearby sink context remain conservative proximity signals. They record bounded same-file security signals near indexed routes and label the results `lexical-auth-signals-only` or `lexical-sink-signals-only`. Absence of nearby auth is reported only as `no-auth-signal-observed`, and nearby sinks are not treated as proven data-flow or call reachability. The repository posture summary aggregates these bounded signals for prioritization while explicitly remaining `bounded-lexical-posture-only`. + +Likely test ownership is also structural evidence only. It prioritizes test files that directly import a source module and supplements those with bounded filename-convention matches. The LCOV primitive can ingest caller-supplied test coverage and classify a concrete finding line as executed, not executed, or lacking data. SynSec does not run target tests to generate that coverage, and the result is explicitly labeled `observed-test-coverage-not-runtime-reachability`; normal report persistence/UI integration remains future work. ## Phase 3 — Contextual security review -- [ ] AI-assisted finding triage -- [ ] Explain why a finding matters in this repository -- [ ] Distinguish deterministic evidence from model inference -- [ ] Suggested code patch -- [ ] Suggested tests -- [ ] Rescan after remediation -- [ ] Finding states: new, confirmed, false positive, accepted risk, fixed, regressed - -## Phase 4 — Git hosting and CI - -- [ ] GitHub App -- [ ] Repository installation flow -- [ ] Pull-request scanning -- [ ] Commit status / checks -- [ ] Inline findings -- [ ] Scheduled scans -- [ ] Optional remediation pull requests +- [x] Provider-agnostic OpenAI-compatible AI review adapter +- [x] Explicit opt-in for model review +- [x] Separate deterministic scanner evidence from model inference +- [x] Seven-question evidence gate for model review +- [x] Source-code context disabled by default and separately opt-in +- [x] Deterministic multi-review consensus aggregation with disagreement/insufficient-review handling +- [x] Bounded independent multi-reviewer execution API with failure isolation +- [x] CLI/configured multi-model review UX +- [ ] Repository-aware explanation of reachability and impact +- [ ] Suggested patch generation +- [ ] Suggested regression/security tests +- [x] Safe rescan-after-remediation verification primitive +- [x] Finding lifecycle: new, confirmed, false positive, accepted risk, fixed, regressed + +Consensus remains model inference, not scanner evidence. Duplicate model identities do not count as independent reviewers, split verdicts fail closed to `uncertain`, insufficient reviewer sets do not fabricate consensus, reviewer execution is concurrency-bounded, and provider failures are isolated with credential redaction. CLI review supports an explicit single-model path and an opt-in `--ai-models` path with two to ten unique model ids, bounded reviewer concurrency, and a configurable minimum-successful-reviewer requirement. Multi-model output preserves individual reviews/failures and labels aggregate agreement as model consensus rather than scanner evidence. + +## Phase 4 — Reusable workflows / skills + +The orchestration layer should expose small reusable defensive workflows rather than hard-coding one giant agent prompt. + +- [x] Repository review workflow +- [x] Dependency review workflow +- [x] Secrets review workflow with source-context prohibition +- [x] Infrastructure/IaC review workflow +- [x] Fix verification workflow +- [x] Report-writing workflow +- [x] Provider/model routing policy by task and cost +- [x] User-defined workflow/skill format with explicit capabilities +- [x] Explicit capability declarations per built-in workflow +- [x] Human approval boundary declared for any repository-changing action +- [x] External network assessment forbidden in repository workflows + +These workflows operate on repository evidence and scanner results. They are not a mechanism for silently expanding into external targets. + +## Phase 5 — Git hosting and CI + +- [x] GitHub Actions context/event parsing primitives +- [x] Deterministic GitHub check-result and inline-annotation generation +- [x] Baseline-aware PR annotation filtering and severity-threshold conclusions +- [x] Narrow fixed-host Checks API publication primitive +- [x] Completed-report → GitHub check publication orchestration +- [x] GitHub Actions repository scan → check runner with PR changed-file defaults +- [x] Report/commit binding before check publication +- [x] Packaged Actions entrypoint / workflow template +- [x] Inline SARIF/code-scanning upload +- [x] Provenance-safe pull-request baseline acquisition from the exact local base commit +- [x] Scheduled full-repository workflow template with explicit report-artifact retention +- [x] GitHub App HMAC webhook verification and bounded event normalization +- [x] GitHub App short-lived JWT and fixed-host installation-token exchange primitives +- [x] Explicit GitHub App scan-trigger allowlist for push and selected PR lifecycle events +- [x] Durable webhook delivery replay protection with retry-safe claim release +- [x] Durable local installation authorization state +- [x] Installation/repository-selection event synchronization into authorization state +- [x] Replay-protected authorization-gated local webhook handler +- [x] Bounded framework-free webhook HTTP handler for deployment behind HTTPS +- [x] Installation-scoped exact-commit GitHub repository acquisition primitive +- [x] Exact queued head/base acquisition and hosted PR baseline comparison +- [x] Authorization-aware local scan worker with commit-bound report verification +- [x] Local worker composition through the existing scan engine and Checks/SARIF publishers +- [x] Memory-only App installation-token provider with purpose-specific permission checks +- [x] Deterministic worker-permission diagnostic model for acquisition, Checks, and optional SARIF +- [x] Single-host local runtime composition with separate durable-state/workspace trees +- [x] Exact-provenance changed-file head execution for hosted PR Checks with conservative full-scan fallback +- [x] Optional remediation pull requests with exact approval-bound patch scope and distinct write credentials +- [ ] Production TLS/listener deployment, supervision, and operational secret rotation +- [ ] Repository installation/setup UX and recovery flows +- [ ] Transactional shared App state/queue for multi-host deployment - [ ] GitLab and Bitbucket adapters -## Phase 5 — Web application +The Actions runner consumes the existing repository scan engine rather than introducing a second scanner path. Pull-request contexts default to changed-file scanning against `origin/`, while push, schedule, workflow-dispatch, and other non-PR contexts default to full repository scans. Publication is refused when the scan cannot identify its commit or the report commit differs from the GitHub head being annotated. The packaged Action keeps explicit config/baseline file inputs inside the real checked-out workspace, including symlink resolution, before those files are read. + +For PRs without an explicit baseline, the Action can scan the exact event-provided base commit in a temporary detached worktree. The base commit must already be present locally; SynSec does not implicitly fetch a remote or substitute a nearby revision. The resulting report is accepted only when its commit identity matches the requested base SHA, then the temporary worktree is removed. + +The Action also writes the completed JSON report under `RUNNER_TEMP` and exposes its path. The scheduled workflow template retains that report only through an explicit caller-owned artifact step with a visible retention period; SynSec does not silently persist security evidence. + +GitHub App support now has a coherent single-host local runtime: raw webhook deliveries are bounded and verified, replay-claimed, synchronized into durable authorization state, authorization-gated into a commit-pinned queue, then consumed by workers that recheck authorization and acquire exact repository commits through a fixed GitHub transport. Pull-request jobs acquire and scan both the exact queued base and head; the base report must bind to the queued base SHA before it can become the head baseline, and the head report must bind to the queued head SHA before Checks/SARIF publication. Credentials are created afresh in memory, never handed to scanners, and checked against operation-specific permission requirements. A separate deterministic diagnostic explains whether GitHub-reported token permissions satisfy `contents:read`, `checks:write`, and optional `security_events:write`; unavailable metadata fails closed rather than being treated as authorization. + +For hosted PR Checks, SynSec compares the already-acquired exact base/head trees locally with bounded `git ls-tree` output, accepts only safe normal-blob head paths, and feeds those direct paths through the engine's dependency-aware incremental planner. Deletions, changed non-blob entries such as submodules, malformed/unsafe tree evidence, tree-command failure, or configured size bounds force a full repository scan. SARIF-enabled hosted PR jobs deliberately remain full-repository because publishing a partial SARIF analysis as the latest code-scanning result could make untouched alerts appear absent. Incremental scope is therefore an optimization with exact provenance and conservative fallback, not evidence that omitted files are safe or unreachable. See [INCREMENTAL_SCANS.md](./INCREMENTAL_SCANS.md) and [GITHUB_APP.md](./GITHUB_APP.md). + +Remediation PR creation is explicitly operator-invoked. Approved proposals are bound to one exact target commit, finding set, file set, operations, and patch hashes; the writer revalidates provenance and hashes immediately before Git operations, stages only approved modifications/additions, refuses delete/rename/scope expansion, uses a deterministic non-force branch, and mints write-capable credentials only after read-only acquisition succeeds. Webhook intake, scanning, model review, and lifecycle transitions never trigger remediation automatically. -- [ ] Project/repository dashboard -- [ ] Scan history -- [ ] Security score -- [ ] New/fixed/regressed findings -- [ ] Finding detail page with source evidence -- [ ] Dependency and SBOM views -- [ ] Repository posture view -- [ ] Team triage workflow +See [GITHUB.md](./GITHUB.md) for the current Actions integration contract and security boundaries. -## Phase 6 — Isolated scan workers +## Phase 6 — Persistent web application +- [x] Deterministic report-history aggregation for score, finding count, churn, and finding lifetime +- [x] Bounded local scan-history store with atomic writes and trend-safe snapshots +- [x] Self-contained trend-safe security-history HTML dashboard renderer +- [x] History-store → restrictive local dashboard file generation +- [x] Bounded lifecycle finding-ownership metadata foundation +- [x] Bounded append-only local finding review-comment store +- [x] Sanitized deterministic current-finding triage view model +- [x] Self-contained restrictive local triage dashboard +- [x] Self-contained normalized SBOM/dependency inventory dashboard +- [x] Aggregate bounded-lexical repository-posture dashboard +- [x] Local static project dashboard bundle composing triage, SBOM, posture, and optional trend-safe history +- [ ] Authenticated project/repository web application +- [ ] Multi-project/server persistence layer +- [ ] Interactive security-score history UI backed by server persistence +- [ ] Interactive new/fixed/regressed views +- [ ] Finding detail page with explicitly bounded source evidence +- [ ] Interactive dependency and SBOM views +- [ ] Interactive repository posture exploration +- [ ] Multi-user team triage workflow +- [ ] Multi-user comments and richer collaboration history + +The current dashboard work is intentionally local and static rather than a hosted collaboration product. The project bundle writes fixed local pages for current triage metadata, normalized SBOM inventory, aggregate lexical posture, and optional trend-safe history. It embeds no remote assets and does not copy source excerpts, scanner diagnostics, tokens, or arbitrary outbound URLs into the bundle. The triage model includes only current finding identity/title/severity plus lifecycle state, ownership, bounded notes, and append-only review comments. SBOM pages deliberately omit raw package locations and describe inventory rather than vulnerability/reachability. Posture pages remain aggregate and explicitly say that lexical proximity is not runtime exposure or authentication proof. Generated dashboard/history/triage/SBOM/posture files use restrictive local permissions where supported. Core JSON, SARIF, general report HTML, history, and Markdown writers also create restrictive files and repair permissive existing file modes on overwrite where the platform supports POSIX permissions. Native SynSec JSON report ingestion is bounded to 64 MiB and rejects non-regular files before parsing. + +The local history store itself retains only report identifiers, timestamps, commit/branch metadata, aggregate counts/scores, and finding fingerprint/title/severity tuples. It deliberately omits source excerpts, scanner diagnostics, repository URLs, artifacts, and secret-bearing evidence. Retention is bounded, writes are atomic, and invalid/corrupt stores fail closed. Lifecycle ownership and review comments are separately bounded human triage metadata and are not scanner evidence or a multi-user collaboration database. + +## Phase 7 — Isolated scan workers + +- [x] Scanner subprocess timeout, abort, output-memory, and kill-escalation bounds +- [x] Credential-minimized default scanner subprocess environment +- [x] Bounded durable local scan-job queue with leases/retries +- [x] Unique per-claim lease fencing with bounded worker renewal/heartbeat +- [x] In-process claim serialization for the supported single-runtime file queue +- [x] Aggregate expired-lease operational telemetry without job/repository identity +- [x] Commit-pinned temporary checkout workspace acquisition and cleanup +- [x] Authorization recheck before worker credential/source acquisition +- [x] Separation of durable App state and repository workspace directory trees - [ ] Containerized scanner images -- [ ] Job queue -- [ ] Per-scan workspace isolation -- [ ] Resource limits and timeouts +- [ ] Per-scan process/container workspace isolation +- [ ] OS/container CPU and memory limits - [ ] Network policy -- [ ] Horizontal workers +- [ ] Horizontal workers backed by transactional shared state/queue - [ ] Artifact retention policy +- [ ] Filesystem credential minimization for private-repository scan workspaces + +External scanners no longer inherit the full parent process environment by default. SynSec passes a small execution/locale/certificate allowlist and requires an explicit environment when a scanner genuinely needs additional variables. Hosted GitHub acquisition uses a separate short-lived transport credential, keeps it out of scanner inputs and Git argv, disables inherited Git configuration, and removes temporary checkout workspaces after handling. The local runtime also refuses to place repository workspaces inside durable App state. The file queue now prevents stale workers from mutating or publishing under a superseded lease and renews active work, but its claim serialization is only within one queue instance; it is not a cross-process or multi-host transactional protocol. This materially narrows credential/source/concurrency exposure but is not a complete sandbox: scanner processes still need container isolation, OS resource limits, network policy, transactional shared persistence for horizontal workers, and stronger filesystem credential separation before a production multi-tenant worker deployment. -## Later +## Later — explicitly authorized external assessment -Authorized attack-surface and bug-bounty workflows can be added later as a separate product mode. They should not define the core architecture or weaken the repository-first authorization model. +External attack-surface or bug-bounty workflows may be explored as a separate mode only after scope/authorization controls exist. They should not define the core architecture, should never silently expand target scope, and should not weaken the repository-first defensive defaults. diff --git a/docs/ROUTE_MIDDLEWARE_COMPOSITION.md b/docs/ROUTE_MIDDLEWARE_COMPOSITION.md new file mode 100644 index 00000000..d0be0117 --- /dev/null +++ b/docs/ROUTE_MIDDLEWARE_COMPOSITION.md @@ -0,0 +1,45 @@ +# Route middleware composition evidence + +SynSec performs a bounded static analysis of explicit Node HTTP router middleware composition to improve repository review context without claiming runtime protection. + +## Supported shape + +The analyzer accepts only one-line route registrations whose post-path arguments are plain identifiers, for example: + +```ts +router.post("/users", requireSession, requireAdmin, createUser); +``` + +The final identifier is treated as the route handler and the preceding identifiers as middleware candidates. Invoked middleware factories, inline functions, member expressions, spreads, arrays, conditional expressions, and other dynamic forms are intentionally omitted. + +Each middleware candidate resolves only when there is exactly one corresponding same-file function or one explicit repository-local ES named import/destructured CommonJS import with matching export evidence. Shadowed imported bindings, ambiguous functions, external modules, missing exports, and unresolved module targets remain unresolved. + +## Bounded auth context + +For resolved middleware functions, SynSec may collect authentication/authorization/session/token lexical signals from the middleware function and a bounded call neighborhood. Same-file calls and already-resolved explicit repository-local import calls can contribute evidence. Analysis is limited by route, depth, and node bounds and does not execute repository code or perform network access. + +The resulting status is one of: + +- `authorization-signal-observed` +- `authentication-signal-observed` +- `no-auth-signal-observed` + +These labels are review signals only. They do not mean that middleware executes before the handler, that an authorization branch is effective, that a route is reachable, or that access control cannot be bypassed. + +Every result is labeled: + +`structural-route-middleware-evidence-not-runtime-protection` + +## Fail-closed examples + +SynSec deliberately emits no middleware composition for shapes such as: + +```ts +router.get("/account", requireAuth(), handler); +router.get("/account", (req, res, next) => next(), handler); +router.get("/account", guards.admin, handler); +``` + +It also refuses to resolve an imported middleware binding if the local binding is reassigned or shadowed before the route registration. + +This feature supplements, rather than replaces, route-to-handler, route-to-sink, request-input, and route-protection evidence. None of these static layers individually or collectively establish runtime reachability, attacker control, exploitability, or effective authorization. diff --git a/docs/ROUTE_PROTECTION_CONTEXT.md b/docs/ROUTE_PROTECTION_CONTEXT.md new file mode 100644 index 00000000..8f9c6fa5 --- /dev/null +++ b/docs/ROUTE_PROTECTION_CONTEXT.md @@ -0,0 +1,46 @@ +# Structural route protection context + +SynSec can correlate auth-related lexical signals with a resolved repository route and its bounded call neighborhood. This context is intended to help reviewers prioritize findings that are structurally connected to HTTP routes without turning static names into an authorization verdict. + +The analysis is available from `@synsec/repository/route-protection-context` and is also composed by `buildRepositoryRouteFlowAnalysis()`. + +## What is considered + +For an already-resolved route entrypoint, SynSec can consider: + +- authentication or authorization signals on the exact route-registration line, such as a plainly named middleware identifier; +- auth-related signals located inside the resolved handler function; +- auth-related signals inside bounded same-file callees; and +- auth-related signals inside a repository-local imported callee only when the existing import/call analysis resolves that binding to one unique lexical function. + +The output omits source text. Evidence records contain only repository path, line, signal kind, structural source, and—when applicable—the owning function name and call depth. Finding-level correlation is even smaller: it reports only the route identity, handler, aggregate status, observed signal kinds, and call scope. + +## Scan-engine enrichment + +The normal scan engine consumes the composed route-protection contexts alongside route-to-sink flows. For a non-secret finding, `metadata.routeProtection` is attached only when the finding's normalized repository path and exact start line already match sink evidence in a resolved structural route flow. + +The report-level metadata is deliberately minimized. It does not include source lines, auth-signal paths, auth function names, scanner diagnostics, or scanner evidence. Secret findings remain outside repository-context, route-flow, and route-protection enrichment entirely. + +Route-protection metadata is contextual review evidence only. The engine does not use it to change severity, confidence, baseline state, failure thresholds, lifecycle state, remediation approval, or publication eligibility. In particular, `authorization-signal-observed` must never suppress a scanner finding, and `no-auth-signal-observed` must never be promoted into an exploitability claim. + +## Fail-closed behavior + +SynSec does not manufacture route protection when resolution is ambiguous. Unresolved route handlers, ambiguous function ownership, unsupported dynamic calls, unlinked imports, and auth-looking signals outside the bounded route/call neighborhood are omitted. + +A finding receives route-protection context only when its exact normalized path and start line already match sink evidence in a structural route flow. This keeps auth context tied to the same conservative route-to-sink relationship instead of attaching nearby auth words to unrelated findings. + +## Interpretation boundary + +The status values are deliberately phrased as observations: + +- `authorization-signal-observed` +- `authentication-signal-observed` +- `no-auth-signal-observed` + +They are **not** equivalent to “authorized,” “authenticated,” or “public.” Static analysis cannot prove that middleware executes, that checks are effective, that every branch enforces them, that the route is deployed, or that an attacker can reach the sink. Likewise, `no-auth-signal-observed` means only that SynSec did not observe supported structural auth evidence in the bounded scope; it is not proof that a route is unprotected. + +The machine-readable interpretation is therefore always: + +`structural-auth-signals-not-protection-proof` + +This analysis performs no network requests, does not execute repository code, and does not authorize live route probing or target expansion. diff --git a/docs/ROUTE_REACHABILITY.md b/docs/ROUTE_REACHABILITY.md new file mode 100644 index 00000000..b3753c10 --- /dev/null +++ b/docs/ROUTE_REACHABILITY.md @@ -0,0 +1,63 @@ +# Route and handler reachability evidence + +SynSec records bounded static route and call-graph evidence to help reviewers prioritize repository findings. This evidence is structural only. It does not prove that a route is deployed, externally reachable, reachable by an attacker, or executable in a particular production configuration. + +## Route detection + +The repository index recognizes a deliberately small set of route-registration shapes without executing repository code. Existing decorator-style Python and TypeScript/JavaScript route signals remain supported. Node route signals recognize literal-path registrations on `app`, `router`, or `server` for common HTTP methods. + +For Node registrations, SynSec records a named-handler candidate only when the complete single-line registration after the literal path is a comma-separated list of plain identifiers, for example: + +```ts +router.get("/users", requireAuth, listUsers); +``` + +Here `listUsers` may be recorded as the handler candidate. The preceding `requireAuth` identifier is not treated as proof that the route is authenticated; authentication remains separate lexical evidence. + +SynSec deliberately does **not** infer a named handler from registrations containing inline functions, function-call expressions, member expressions, spreads, dynamically constructed paths, mounted routers, or other compound expressions. Examples such as these remain unresolved: + +```ts +router.post("/users", (req, res) => createUser(req, res)); +router.patch("/users/:id", requireAuth(), updateUser); +router.use("/admin", adminRouter); +``` + +This restriction is intentional. A broader regex would create misleading handler associations for framework composition that requires semantic execution or framework-aware analysis. + +## Handler resolution + +`resolveRouteEntrypoints()` can map a recorded Node named-handler candidate to the bounded lexical call graph only when exactly one function with that name exists in the same repository file. Duplicate same-file declarations, missing declarations, imported handlers, and other ambiguity remain `unresolved`. + +Decorator-style routes continue to use the existing bounded nearest-following-function rule. Both resolution paths are labeled `structural-route-call-evidence-only`. + +For a resolved handler, SynSec may expose the existing bounded lexical call neighborhood. Those calls are still regex/lexical relationships. Dynamic dispatch, framework dependency injection, callbacks, aliases, imported functions, and runtime control flow can make the static neighborhood incomplete. + +## Structural route-to-sink flow + +`@synsec/repository/route-sink-flow` combines three already bounded repository signals: a resolved route entrypoint, its same-file lexical call neighborhood, and normalized sensitive-sink lines. A sink is linked to a route only when its line belongs to exactly one function in that bounded reachable node set. Ambiguous function containment is omitted rather than resolved heuristically. + +The flow context contains only route identity, handler/function identity, sink category, line, and call depth. It deliberately excludes the sink source-line evidence stored in the repository index. The interpretation is always `structural-route-call-sink-evidence-only`. + +The scan engine consumes this evidence conservatively. For non-secret findings, `metadata.routeFlow` is attached only when the finding's normalized repository path and exact start line match a linked sink line. A finding elsewhere in the same handler, file, or route neighborhood does not inherit route-flow metadata merely by proximity. Secret findings never receive this enrichment and remain on their narrower metadata boundary. + +The engine builds the bounded call graph for this purpose only when the repository index contains both route and sink signals. This avoids an additional analysis pass for repositories where route-to-sink evidence cannot exist. + +## Security interpretation + +A resolved route-to-handler or route-to-sink relationship means only that the repository contains static structures matching SynSec's conservative rules. It must not be interpreted as any of the following: + +- proof that the application starts or registers the route in production; +- proof that the route is internet-accessible; +- proof that authentication or authorization is present or absent; +- proof that attacker-controlled input reaches the sink; +- proof that the sink is executable on a real request; +- proof that an unresolved route is safe or unreachable; or +- permission to make network requests against the route. + +Route authentication/sink proximity, route-flow metadata, call-graph edges, test coverage, dependency usage, and scanner findings remain separate evidence sources. Uncertainty in one source is not silently converted into certainty by another. + +## Repository-first boundary + +Route analysis reads only bounded files from the already-authorized repository checkout. It never launches the application, follows discovered URLs, probes HTTP listeners, sends scanner findings to live endpoints, expands to sibling repositories, or turns route strings into outbound targets. + +Future framework-aware analysis should preserve these properties: bounded repository inputs, explicit ambiguity, evidence labels that distinguish static inference from runtime facts, and full fail-closed behavior when the framework shape cannot be resolved safely. diff --git a/docs/ROUTE_SECURITY_REVIEW.md b/docs/ROUTE_SECURITY_REVIEW.md new file mode 100644 index 00000000..ead370e9 --- /dev/null +++ b/docs/ROUTE_SECURITY_REVIEW.md @@ -0,0 +1,30 @@ +# Structural route security review context + +`@synsec/repository/route-security-review` joins SynSec's already-resolved route-to-sink and route-protection evidence into a minimized route-level review surface. + +The API is intentionally conservative. It emits a record only for a route with linked sensitive-sink evidence. A protection status is accepted only when exactly one protection context matches the same route and resolved handler. Missing or duplicate protection records become `not-assessed` rather than being guessed. + +The resulting signals are descriptive review labels: + +- `sensitive-sink-with-authorization-signal` +- `sensitive-sink-with-authentication-signal` +- `sensitive-sink-without-auth-signal` +- `sensitive-sink-auth-context-unavailable` + +They are not vulnerability severities and must not be used as proof that a route is deployed, public, attacker-controlled, protected, exploitable, or safe. `no-auth-signal-observed` means only that SynSec did not observe one in its bounded structural neighborhood. + +## Composition + +`buildRepositoryRouteFlowAnalysis()` now returns `routeSecurityReviews` alongside the bounded call graph, explicit import-call links, route entrypoints, route-to-sink flows, and route-protection contexts. This keeps the route-security join on the same filesystem and ambiguity boundaries as the underlying repository analysis instead of asking consumers to reimplement it. + +The local `@synsec/dashboard` may also accept those review contexts. It validates them through `summarizeRouteSecurityReviews()` and renders aggregate counts only. Route strings, handler names, framework hints, source paths, source evidence, scanner diagnostics, and credentials are not copied into the dashboard index. + +`summarizeRouteSecurityReviews()` treats supplied contexts as untrusted runtime data. It rejects inconsistent protection-status/signal pairs, unknown or duplicate sink kinds, unsupported interpretations or call scopes, invalid bounded identity metadata, and collections above 5,000 contexts. Its output contains only aggregate signal and sink-kind counts plus the number needing auth-context review. + +## Disclosure and interpretation boundary + +The route-level context deliberately excludes source lines, auth evidence text, sink evidence text, file paths, scanner diagnostics, credentials, and arbitrary outbound URLs. It contains only the route/method, optional framework hint, resolved handler name, sink kinds, aggregate protection status, call scope, and the interpretation `structural-route-security-review-context-only`. + +The aggregate summary is narrower still and is labeled `aggregate-structural-route-security-review-only`. Neither representation changes finding severity, suppresses scanner evidence, authorizes remediation, or claims runtime reachability or protection. + +This API performs no network access, executes no repository code, and does not broaden scan targets. It is intended for local dashboards, review queues, finding enrichment, and reporting surfaces that need compact security-review context without copying evidence-bearing source text. diff --git a/docs/SCANNER_ISOLATION.md b/docs/SCANNER_ISOLATION.md new file mode 100644 index 00000000..8bb11aa9 --- /dev/null +++ b/docs/SCANNER_ISOLATION.md @@ -0,0 +1,49 @@ +# Scanner isolation contract + +SynSec treats external scanner binaries as untrusted subprocesses. The scanner SDK minimizes inherited environment variables, bounds stdout/stderr retention, supports timeouts and aborts, and escalates termination when a scanner does not exit. Those controls reduce credential exposure and runaway process risk, but they do not provide a production sandbox by themselves. + +## Scanner process environment + +The default scanner subprocess environment is intentionally smaller than the hosting process environment. SynSec preserves only execution/locale, temporary-directory, certificate, terminal, and cache variables needed by normal command-line tools. It does **not** implicitly pass CI/cloud/registry credentials, proxy URLs, or user configuration roots. + +In particular, the default environment omits `HOME`, `USERPROFILE`, `APPDATA`, `LOCALAPPDATA`, and `XDG_CONFIG_HOME`. This matters because scanner-specific files under those roots can contain credentials or authenticated service configuration even when variables such as `GITHUB_TOKEN`, `NPM_TOKEN`, or cloud keys have already been removed. `XDG_CACHE_HOME` may still be inherited because it is a cache location rather than a configuration/credential root. + +Default command lookup is also constrained. `PATH` entries must be absolute, and when `runProcess()` is given a scanner working directory, entries equal to or contained by that working tree are removed before spawning. Relative entries such as `.` and repository-local directories such as `node_modules/.bin` therefore cannot shadow an expected scanner command merely because the repository is the child process working directory. Normal system-level absolute scanner directories remain available. + +`runProcess()` additionally rejects path-like relative executable names such as `./scanner`, `../scanner`, or `tools/scanner`. Built-in adapters use bare command names resolved through the constrained search path; callers that intentionally pin an executable may use an absolute path. This prevents a future adapter from accidentally turning repository contents into the executable boundary simply by joining a tool path relative to the scan working tree. + +Adapters can supply an explicit `env` to `runProcess()` when a scanner genuinely needs additional variables. Doing so is an explicit trust decision by the adapter and bypasses the SDK's default environment and search-path allowlist for that invocation. Production adapters should add only the exact non-secret variables required, should use trusted absolute command-search directories, and must not pass GitHub App credentials or other hosting secrets to scanner processes. + +This environment boundary is defense in depth, not filesystem isolation: a process running as the same host user could still discover user files through other operating-system mechanisms. Production hosting therefore still requires the external sandbox/filesystem controls described below. + +## Hosted deployment declaration + +`validateGitHubAppDeployment()` accepts an optional `scannerIsolation` declaration describing controls enforced by the surrounding container or sandbox runtime: + +- `processBoundary`: `container`, `sandbox`, or `host`; +- `cpuLimit`: whether a CPU limit is enforced; +- `memoryLimit`: whether a memory limit is enforced; +- `networkPolicy`: `none`, `egress-filtered`, or `host`; and +- `repositoryFilesystem`: `read-only` or `writable`. + +Without `requireScannerIsolation`, missing or incomplete isolation is reported as warning-level deployment diagnostics. This preserves local-development workflows while making the gap visible. + +Production operators should set `requireScannerIsolation: true`. Deployment readiness then fails unless scanner execution uses a container or equivalent sandbox, has both CPU and memory limits, avoids unrestricted host networking, and mounts repository source read-only. + +For a stricter production gate, use the versioned profile in [SCANNER_ISOLATION_PROFILE.md](./SCANNER_ISOLATION_PROFILE.md). It additionally makes separate scratch space, credential/state exclusion, privileged mode, host namespace sharing, and host control-socket mounts explicit. `assessGitHubAppScannerProductionReadiness()` composes that profile with the hosted deployment preflight and always forces scanner isolation into strict mode. + +## Network policy + +Repository-first scanning does not require autonomous live-target access. A production sandbox should prefer no scanner network access when scanner data can be pre-provisioned. When a scanner genuinely requires advisory/rule/database updates, use explicit egress filtering to known package/security-data endpoints outside the scan process's target-selection logic. + +Do not grant scanners general outbound access merely because the hosted SynSec service itself must communicate with GitHub. GitHub installation credentials belong to acquisition/publication transport and are not scanner inputs. + +## Filesystem policy + +The checked-out repository should be mounted read-only inside the scanner sandbox. If a scanner needs caches, databases, temporary files, or generated output, provide a separate bounded writable scratch/cache location. Durable GitHub App state, App private keys, webhook secrets, and installation credentials must remain outside the scanner filesystem namespace. + +## What SynSec does not claim + +The deployment declaration and detailed profile are machine-checkable contracts between SynSec startup configuration and the external runtime. SynSec does not infer that a host process is isolated, and Node's `spawn()` is not treated as a container, resource controller, firewall, or read-only mount mechanism. + +A deployment that sets `requireScannerIsolation: true` or supplies a complete detailed profile should populate those declarations only from actual infrastructure configuration. Falsely declaring controls does not create them. diff --git a/docs/SCANNER_ISOLATION_PROFILE.md b/docs/SCANNER_ISOLATION_PROFILE.md new file mode 100644 index 00000000..1f9406bc --- /dev/null +++ b/docs/SCANNER_ISOLATION_PROFILE.md @@ -0,0 +1,78 @@ +# Scanner isolation verification profile + +SynSec's production scanner boundary depends on controls enforced outside the Node process. The existing GitHub App deployment preflight can require a container or equivalent sandbox, CPU and memory limits, restricted networking, and a read-only repository mount. The scanner isolation profile makes additional container-escape and credential-boundary assumptions explicit and machine-checkable without accepting secrets or infrastructure connection details. + +## Profile schema + +The profile is versioned and intentionally small: + +```json +{ + "schemaVersion": 1, + "runtime": "container", + "cpuLimit": true, + "memoryLimit": true, + "networkPolicy": "none", + "repositoryReadOnly": true, + "rootFilesystemReadOnly": true, + "scratchSeparated": true, + "credentialsExcluded": true, + "durableStateExcluded": true, + "privileged": false, + "allowPrivilegeEscalation": false, + "runAsNonRoot": true, + "capabilitiesDropped": true, + "hostNetwork": false, + "hostPid": false, + "hostIpc": false, + "hostSocketMounts": false +} +``` + +`runtime` may be `container` or `sandbox`. `networkPolicy` may be `none` or `egress-filtered`. A complete profile must also declare that repository source is read-only, the scanner root filesystem is read-only, writable scratch is separate, GitHub credentials and durable App state are outside the scanner namespace, privileged mode and privilege escalation are disabled, the scanner runs as a non-root identity with ambient/additional Linux capabilities dropped, host namespaces are not shared, and host control sockets are not mounted. + +The root-filesystem and process-identity controls are deliberately separate from the repository mount. A read-only checkout does not stop a scanner from persisting into another writable container path, and a non-privileged container alone does not imply `allowPrivilegeEscalation=false`, non-root execution, or dropped capabilities. Production deployment generators should enforce all of these independently. + +The profile deliberately does not contain image names, registry credentials, filesystem paths, database URLs, Kubernetes credentials, GitHub tokens, or other secret-bearing deployment data. + +## Offline verification + +After building the workspace, validate a profile with: + +```sh +synsec-scanner-isolation scanner-isolation.json --json +``` + +Exit codes are designed for deployment gates: + +- `0`: every required control is declared; +- `2`: one or more controls are missing or unsafe; +- `1`: the input or command line is invalid. + +The CLI reads at most 64 KiB, rejects symlink input files, rejects unknown fields, and does not reflect unsupported option values. This allows a repository-controlled CI job to check a sanitized declaration without following an input symlink to an arbitrary host file. + +Programmatic callers can use `assessSynSecScannerIsolationProfile()` from `@synsec/github/scanner-isolation-profile`. The assessment returns only a boolean, deterministic missing-control identifiers, and the interpretation marker `declared-infrastructure-controls-not-runtime-certification`. + +## Hosted production readiness + +`assessGitHubAppScannerProductionReadiness()` from `@synsec/github/scanner-production-readiness` composes the existing hosted deployment validation with the detailed profile. It always forces the legacy deployment isolation contract into strict mode even if the caller omitted `requireScannerIsolation`, then requires the versioned profile to be complete as a second independent gate. + +`assertGitHubAppScannerProductionReady()` provides the same policy as a startup assertion. Failure diagnostics contain only deployment issue codes and scanner-isolation control identifiers; they do not include webhook secrets, private-key material, filesystem contents, or scanner output. + +This composition is intended to prevent a production host from accidentally treating the development/advisory isolation mode as sufficient. It still validates declarations rather than inspecting the running container or orchestrator. + +## Mapping to common orchestrators + +A Kubernetes-style deployment would normally map these declarations to controls such as container CPU/memory limits, a read-only repository volume mount, `readOnlyRootFilesystem: true`, `allowPrivilegeEscalation: false`, `runAsNonRoot: true`, and `capabilities.drop: ["ALL"]`, plus disabled host namespace sharing and an independently enforced NetworkPolicy. Docker or another sandbox runtime needs equivalent controls. + +These examples are conceptual mappings, not proof that a particular manifest is safe. SynSec does not currently parse or certify Kubernetes, Docker, systemd, seccomp, AppArmor, SELinux, cgroup, or network-policy configuration. + +## What the profile proves + +A complete profile proves only that an operator or deployment generator supplied a declaration matching SynSec's minimum isolation contract. It does not inspect Docker, Kubernetes, systemd, a container runtime, a seccomp profile, cgroups, network policy objects, or mount tables, and it is not runtime certification. + +Production systems should derive the declaration from reviewed infrastructure-as-code and independently test the deployed sandbox. Do not set controls to `true` merely to satisfy the gate. + +## Defensive boundary + +Repository scanning remains repository-first. Scanner isolation must not be used as a justification for autonomous live-target probing, general outbound network access, secret transport into scanner processes, persistence, or expansion beyond the repository/commit explicitly selected for the scan. diff --git a/docs/SCANNER_OPERATIONAL_BOUNDARY.md b/docs/SCANNER_OPERATIONAL_BOUNDARY.md new file mode 100644 index 00000000..e8d4286d --- /dev/null +++ b/docs/SCANNER_OPERATIONAL_BOUNDARY.md @@ -0,0 +1,30 @@ +# Scanner operational reporting boundary + +SynSec treats scanner findings and scanner operational diagnostics as different trust domains. + +## Findings and evidence + +Scanner findings are evidence-bearing product data. The engine does not apply the operational-text sanitizer to finding titles, locations, structured metadata, or source evidence. Scanner adapters remain responsible for producing valid finding data, and downstream report/lifecycle logic preserves that evidence for review. + +Some finding metadata keys are reserved for SynSec-derived repository intelligence. Scanner-provided values under `dependencyUsage`, `repositoryContext`, `routeFlow`, or `routeProtection` are discarded before engine enrichment so an adapter cannot impersonate context that SynSec claims to have derived itself. Other scanner-owned metadata is preserved. Secret findings remain outside repository-context enrichment, but the same reserved-key stripping applies so a secret scanner cannot inject forged engine-owned context into a report. + +## Operational diagnostics + +Scanner errors and diagnostic strings are for operators, not an evidence channel. Before those strings cross the scan-engine boundary, SynSec: + +- removes control characters that are unsafe in logs and terminals; +- redacts common GitHub tokens, AWS access keys, JWT-shaped credentials, authorization headers, API/auth tokens, passwords, credential-bearing URLs, and sensitive URL query parameters; +- bounds each diagnostic through the shared `sanitizeOperationalText()` policy; +- retains at most 1,000 diagnostic entries from a successful scanner result and adds an aggregate omission notice when that limit is exceeded; +- sanitizes a thrown scanner error before storing it in `ScanEngineOutcome.failures` or including it in the aggregate "all scanners failed" exception; +- converts a thrown scanner availability probe into a sanitized unavailable status instead of allowing the raw exception to escape; +- sanitizes and bounds unknown configured scanner identifiers before they enter status or aggregate error surfaces; and +- re-sanitizes unavailable-scanner identities and reasons before composing the aggregate unavailable-scanner error. + +This is defense in depth. Built-in adapters already sanitize subprocess stderr and availability/version output, but the engine must not depend on every adapter preserving that invariant forever. Configuration values can also reach operational surfaces, so an invalid scanner id is treated as untrusted text rather than a safe log label. + +## What this does not do + +This boundary is not a scanner sandbox. It does not make repository code safe to execute, grant a scanner network access, inspect container policy, or certify a third-party scanner. Production deployments still need externally enforced filesystem, process, credential, resource, and network isolation. + +The diagnostic sanitizer also must not be used as a substitute for evidence handling. Secret findings intentionally retain the narrow evidence structures required by the scanner/report contract; operators should continue to treat reports containing secret findings as sensitive artifacts. diff --git a/docs/TRIAGE.md b/docs/TRIAGE.md new file mode 100644 index 00000000..3aae2b75 --- /dev/null +++ b/docs/TRIAGE.md @@ -0,0 +1,61 @@ +# Finding triage and collaboration metadata + +SynSec keeps deterministic scanner evidence separate from human review metadata. Local triage state, ownership, and review comments can help a team organize findings without rewriting what a scanner observed. + +## Lifecycle state + +List current lifecycle records: + +```sh +synsec triage .synsec/report.json --list +``` + +Set an explicit lifecycle state: + +```sh +synsec triage .synsec/report.json confirmed --note "validated during review" +``` + +Supported scanner-independent lifecycle states are `new`, `confirmed`, `false-positive`, `accepted-risk`, `fixed`, and `regressed`. Automatic reconciliation remains evidence-aware: changed-file scans do not mark findings outside their covered paths fixed. + +## Ownership + +Assign an owner to a finding that exists in the supplied report: + +```sh +synsec triage .synsec/report.json owner --note appsec +``` + +Clear ownership with an explicit empty value: + +```sh +synsec triage .synsec/report.json owner --note= +``` + +Ownership is bounded triage metadata only. It survives lifecycle state transitions and rescans, but it is not scanner evidence and does not authorize repository writes or external actions. + +## Review comments + +Append a local review comment: + +```sh +synsec triage .synsec/report.json comment --note "verify authorization boundary before accepting risk" +``` + +Comments are stored separately from lifecycle state in `review-comments.json` next to the selected lifecycle store. They are append-only, bounded, atomically written with restrictive local permissions where supported, and require the fingerprint to exist in the supplied report. SynSec does not automatically copy source excerpts, scanner diagnostics, tokens, or repository credentials into the comment store. + +`--list` displays the current owner and comment count for each current lifecycle finding. It does not print comment bodies by default, which keeps routine terminal output compact and avoids unnecessarily redisplaying human-entered review notes. + +## Custom lifecycle store path + +Use `--store ` to select a lifecycle store explicitly: + +```sh +synsec triage report.json confirmed --store .synsec/team-lifecycle.json +``` + +The associated review-comment store remains `review-comments.json` in the same directory as that lifecycle file. This keeps the two forms of human metadata colocated while preserving separate schemas and update semantics. + +## Scope + +This is a local/single-host collaboration foundation, not a multi-user authorization service. There are no silent notifications, remote comment synchronization, repository mutations, or external target actions. A future hosted collaboration layer will need authentication, authorization, concurrency/transaction semantics, audit retention policy, and explicit deployment controls rather than treating these local files as a shared multi-tenant database. diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md new file mode 100644 index 00000000..7a865ebd --- /dev/null +++ b/docs/WORKFLOWS.md @@ -0,0 +1,205 @@ +# Reusable defensive workflows + +SynSec's model-facing layer is built from small workflows with explicit inputs and capabilities rather than one enormous prompt that implicitly has access to everything. + +This matters for two reasons: + +1. scanner orchestration and model reasoning remain independently replaceable; +2. each workflow declares exactly which repository evidence and actions it is allowed to use. + +The built-in workflow registry is implemented in `@synsec/workflows`. The definitions are intentionally small and machine-readable so future routing, UI, and hosted execution can enforce the same boundaries. + +## Workflow contract + +Each workflow declares: + +- a stable ID and version; +- the finding categories it accepts; +- explicit read/proposal capabilities; +- whether bounded source context is allowed; +- mandatory human approval for repository writes; +- an explicit prohibition on external network assessment. + +The important part is that capabilities are explicit and machine-enforced rather than implied by a prompt. + +## User-defined workflow format + +SynSec accepts version 1 user workflows as bounded JSON definitions through `@synsec/workflows/user-defined`. User workflow files are limited to 64 KiB and are parsed into the same `WorkflowDefinition` contract used by built-ins. + +A minimal definition looks like: + +```json +{ + "id": "custom-dependency-review", + "version": 1, + "displayName": "Custom Dependency Review", + "description": "Review dependency evidence for this repository.", + "reviewInstructions": "Prefer deterministic package and import evidence. Preserve uncertainty.", + "categories": ["dependency"], + "capabilities": [ + "read-normalized-findings", + "read-dependency-metadata", + "propose-remediation" + ], + "sourceContextAllowed": false, + "repositoryWriteRequiresApproval": true, + "externalNetworkAssessment": "forbidden" +} +``` + +The parser rejects unknown capabilities and categories. Source context can only be enabled when `read-bounded-source-context` is explicitly declared. The two repository safety boundaries are intentionally not extensible: user-defined repository workflows must require approval for writes and must forbid external network assessment. + +`reviewInstructions` are workflow guidance, not an authorization mechanism. They cannot grant capabilities that the workflow does not declare. + +## Built-in workflow set + +### Repository review + +Inputs: + +- normalized findings; +- repository language/framework inventory; +- selected bounded source context. + +Output: + +- evidence-based finding review; +- confidence and severity recommendation; +- unresolved questions. + +### Dependency review + +Inputs: + +- OSV/Trivy/Grype findings; +- package identity and installed/fixed versions; +- scanner-provided reachability information when available. + +Output: + +- deduplicated advisory explanation; +- fix availability; +- whether evidence suggests the vulnerable package is actually relevant to the project. + +### Secrets review + +Inputs: + +- **redacted** secret findings only; +- file and line metadata; +- safe repository metadata. + +Output: + +- rotation/removal guidance; +- repository-history cleanup recommendation; +- confidence assessment. + +Source context is prohibited for this workflow. A model never needs the secret value itself. + +### Infrastructure review + +Inputs: + +- Checkov/Trivy IaC findings; +- the affected configuration excerpt; +- repository deployment metadata. + +Output: + +- configuration-risk explanation; +- defensive remediation; +- uncertainty when deployment context is missing. + +### Fix verification + +Inputs: + +- previous and current normalized scan reports; +- deterministic remediation-verification result; +- lifecycle state; +- optional bounded source context; +- relevant tests when available. + +Output: + +- fixed / persisting / inconclusive / missing-baseline interpretation; +- explanation of scanner and scope coverage; +- suggested regression/security tests. + +The deterministic rescan remains authoritative. A finding that disappears is only treated as fixed when a detecting scanner reran over the affected scope. Model review can explain the evidence but cannot override missing coverage. + +### Report writing + +Inputs: + +- normalized/correlated findings; +- deterministic scan evidence; +- lifecycle state. + +Output: + +- concise developer-facing explanation; +- remediation summary; +- references to scanner evidence and source locations; +- explicit uncertainty when evidence is incomplete. + +Source context is disabled for this workflow by design. The report writer summarizes normalized evidence rather than receiving arbitrary repository code or secret material. + +## Seven-question evidence gate + +The AI reviewer implements a common workflow primitive. Every contextual finding review asks: + +1. Is there a concrete affected location? +2. Is untrusted input involved when the finding requires it? +3. Is there a security-sensitive sink or invariant violation? +4. Is the affected path actually reachable rather than dead/example code? +5. Were relevant mitigations considered? +6. Is there scanner or code evidence supporting the conclusion? +7. Is there a specific, proportionate remediation? + +An unanswered question stays `unknown`. A model should not fill gaps with invented evidence. + +## Model routing + +`@synsec/workflows/routing` provides a deterministic provider/model selection policy. Workflows request a task class rather than hard-code a vendor/model name: + +```text +fast-classifier +security-reasoner +code-reasoner +report-writer +verifier +``` + +Candidates declare supported task classes, cost tier, latency tier, privacy class, source-context support, and enabled state. A routing request can cap cost, require local execution, prefer local execution, and require source-context compatibility. Ineligible candidates are filtered before ranking; if no model satisfies the requested constraints, routing fails closed instead of silently widening privacy or cost policy. + +This keeps SynSec usable with cloud models, local models, or mixed deployments while preserving explicit privacy and budget boundaries. + +## Human approval boundaries + +A workflow may recommend a repository change, but it does not autonomously modify repositories. + +Any future write-capable workflow must require explicit approval before: + +- editing source files; +- changing dependencies; +- creating a commit; +- opening a pull request; +- changing CI or infrastructure configuration. + +External network assessment is a separate authorization domain. A future external-assessment mode must have its own explicit scope controls and must not inherit permission merely because a repository workflow can read code. + +## Auditability + +Every future persisted workflow run should preserve: + +- workflow ID and version; +- model/provider identifier when a model is used; +- deterministic evidence references; +- whether source context was sent; +- output schema version; +- approval events; +- generated patch hash if a patch is produced. + +This makes it possible to reproduce why SynSec reached a recommendation even when models or routing policies change later. diff --git a/docs/WORKSPACE_RECONCILIATION.md b/docs/WORKSPACE_RECONCILIATION.md new file mode 100644 index 00000000..24be0cbb --- /dev/null +++ b/docs/WORKSPACE_RECONCILIATION.md @@ -0,0 +1,36 @@ +# GitHub workspace ownership and reconciliation + +SynSec GitHub repository acquisition creates temporary workspaces under the configured workspace root. A normal worker removes those directories after scanning or remediation. A process crash can leave a workspace behind, so cleanup must distinguish SynSec-owned source trees from unrelated operator data before deleting anything. + +## Ownership marker + +Each acquisition workspace is created with the `synsec-github-` prefix and immediately receives a restrictive `.synsec-workspace.json` marker before Git runs. The marker contains only a schema version, a random workspace id, and its creation timestamp. It deliberately contains no repository name, commit SHA, installation id, token, source path, or GitHub URL. + +If marker creation fails, acquisition removes the just-created directory and stops. Normal acquisition failure and normal worker cleanup continue to remove the whole owned workspace. + +## Reconciliation + +`reconcileGitHubOwnedWorkspaces()` scans only direct children of one configured workspace root whose names use SynSec's acquisition prefix. Observation is the default; no directories are deleted unless `deleteOwned: true` is explicitly supplied. + +A directory is eligible for stale cleanup only when all of the following remain true: + +- it is a real directory, not a symlink; +- it has the expected SynSec acquisition prefix; +- its ownership marker is a regular, non-symlink file within the marker size bound; +- the marker has the exact supported schema and valid timestamp/id fields; +- the marker age exceeds the configured retention period; and +- the deletion batch has not exceeded its configured maximum. + +The marker is read again immediately before deletion. If the marker changed between discovery and deletion, cleanup fails closed for that entry. Missing or malformed markers are never interpreted as proof of ownership. + +Retention is bounded from one hour through 30 days, and each pass may delete at most 256 workspaces. The default retention is 24 hours and default deletion batch is 32. + +## Runtime maintenance + +`createLocalGitHubAppRuntime().runMaintenance()` includes workspace reconciliation alongside replay-record and failed-job retention. Runtime workspace deletion remains off by default. Operators must explicitly set `deleteStaleOwnedWorkspaces: true`; `workspaceRetentionMs` and `workspaceMaxDeletes` control the bounded policy. + +The runtime maintenance result reports only aggregate counts (`inspected`, `owned`, `stale`, `deleted`, and `skipped`). It does not expose repository identities, commit SHAs, installation ids, or source paths. + +## Limits + +Ownership markers make cleanup materially safer than an age-based directory sweep, but they are not a substitute for host/container isolation or a shared distributed lease service. Multi-host deployments should keep workspace ownership local to the worker/container that created it or use a shared transactional ownership model before implementing cross-host deletion. diff --git a/docs/examples/synsec-scheduled.yml b/docs/examples/synsec-scheduled.yml new file mode 100644 index 00000000..ab7915a5 --- /dev/null +++ b/docs/examples/synsec-scheduled.yml @@ -0,0 +1,40 @@ +name: SynSec scheduled repository scan + +on: + schedule: + - cron: "17 6 * * *" + workflow_dispatch: + +permissions: + contents: read + checks: write + security-events: write + +jobs: + synsec: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + # Install and pin the scanner binaries selected by synsec.config.json here. + # SynSec intentionally does not download scanners implicitly. + + - name: Scan repository + id: synsec + uses: cmahmud/synsec@ + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + changed-only: "false" + auto-baseline: "false" + publish-sarif: "true" + + - name: Retain SynSec report + if: ${{ always() && steps.synsec.outputs.report-path != '' }} + uses: actions/upload-artifact@v4 + with: + name: synsec-report-${{ github.run_id }} + path: ${{ steps.synsec.outputs.report-path }} + if-no-files-found: error + retention-days: 30 diff --git a/package.json b/package.json index d2f52749..32c759a3 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "synsec", - "version": "0.1.0", + "version": "0.2.0", "private": true, - "description": "Repository-first security scanning and vulnerability correlation platform", + "description": "Repository-first security scanning, correlation, reporting, and optional AI triage", "type": "module", "engines": { "node": ">=20" @@ -14,11 +14,18 @@ "scripts": { "build": "tsc -b", "clean": "tsc -b --clean", + "typecheck": "tsc -b --pretty false", "test": "node --test", + "release:check": "node scripts/release-readiness.mjs", + "release:check:json": "node scripts/release-readiness.mjs --json", + "release:ready": "node scripts/release-readiness.mjs --strict", + "github-app:intake-host": "npm run build && node scripts/github-app-intake-host.mjs", + "github-app:worker-host": "npm run build && node scripts/github-app-worker-host.mjs", "synsec": "npm run build && node --enable-source-maps apps/cli/dist/index.js" }, "devDependencies": { "@types/node": "^24.0.0", + "pg": "^8.16.3", "typescript": "^5.9.0" } } diff --git a/packages/ai/package.json b/packages/ai/package.json new file mode 100644 index 00000000..d47fad62 --- /dev/null +++ b/packages/ai/package.json @@ -0,0 +1,19 @@ +{ + "name": "@synsec/ai", + "version": "0.2.0", + "private": true, + "type": "module", + "exports": { + ".": "./dist/index.js", + "./consensus": "./dist/consensus.js" + }, + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@synsec/core": "0.1.0", + "@synsec/repository": "0.2.0" + } +} diff --git a/packages/ai/src/consensus.ts b/packages/ai/src/consensus.ts new file mode 100644 index 00000000..c136d544 --- /dev/null +++ b/packages/ai/src/consensus.ts @@ -0,0 +1,238 @@ +import type { Finding, Severity } from "@synsec/core"; +import type { FindingContext } from "@synsec/repository"; +import { + reviewFinding, + type AiFindingReview, + type OpenAiCompatibleConfig, + type ReviewAnswer, +} from "./index.js"; + +export type ReviewConsensusAgreement = "unanimous" | "majority" | "split" | "insufficient"; + +export interface ReviewConsensusGate { + id: string; + question: string; + answer: ReviewAnswer; + yes: number; + no: number; + unknown: number; +} + +export interface AiReviewConsensus { + schemaVersion: 1; + verdict: AiFindingReview["verdict"]; + severity: Severity; + confidence: number; + agreement: ReviewConsensusAgreement; + reviewerCount: number; + models: string[]; + agreeingModels: string[]; + dissentingModels: string[]; + gate: ReviewConsensusGate[]; + /** Consensus aggregates model inference; deterministic scanner evidence remains authoritative. */ + interpretation: "model-consensus-not-scanner-evidence"; +} + +export interface ReviewConsensusFailure { + model: string; + message: string; +} + +export interface MultiReviewConsensusResult { + reviews: AiFindingReview[]; + failures: ReviewConsensusFailure[]; + consensus: AiReviewConsensus; +} + +export interface MultiReviewOptions { + minimumReviewers?: number; + concurrency?: number; + reviewer?: typeof reviewFinding; +} + +const severityRank: Record = { + critical: 5, + high: 4, + medium: 3, + low: 2, + info: 1, + unknown: 0, +}; + +const verdictRank: Record = { + confirmed: 4, + likely: 3, + uncertain: 2, + "false-positive": 1, +}; + +function clampConfidence(value: number): number { + return Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0)); +} + +function uniqueReviews(reviews: readonly AiFindingReview[]): AiFindingReview[] { + const byModel = new Map(); + for (const review of reviews) { + const model = review.model.trim(); + if (!model || byModel.has(model)) continue; + byModel.set(model, review); + } + return [...byModel.values()]; +} + +function uniqueProviders(providers: readonly OpenAiCompatibleConfig[]): OpenAiCompatibleConfig[] { + const byModel = new Map(); + for (const provider of providers) { + const model = provider.model.trim(); + if (!model || byModel.has(model)) continue; + byModel.set(model, { ...provider, model }); + } + return [...byModel.values()].slice(0, 10); +} + +function safeFailureMessage(error: unknown, apiKey?: string): string { + let message = error instanceof Error ? error.message : String(error); + if (apiKey) message = message.replaceAll(apiKey, "[REDACTED]"); + return message.replace(/[\r\n]+/g, " ").slice(0, 500); +} + +function winner(counts: Map, rank: Record): { value?: T; count: number; tied: boolean } { + const ordered = [...counts.entries()].sort((a, b) => b[1] - a[1] || rank[b[0]] - rank[a[0]] || a[0].localeCompare(b[0])); + const first = ordered[0]; + if (!first) return { count: 0, tied: false }; + const tied = ordered.length > 1 && ordered[1]?.[1] === first[1]; + return { value: first[0], count: first[1], tied }; +} + +function consensusGates(reviews: readonly AiFindingReview[]): ReviewConsensusGate[] { + const questions = new Map(); + for (const review of reviews) { + for (const gate of review.gate) if (!questions.has(gate.id)) questions.set(gate.id, gate.question); + } + + return [...questions.entries()].map(([id, question]) => { + let yes = 0; + let no = 0; + let unknown = 0; + for (const review of reviews) { + const answer = review.gate.find((gate) => gate.id === id)?.answer ?? "unknown"; + if (answer === "yes") yes += 1; + else if (answer === "no") no += 1; + else unknown += 1; + } + const answer: ReviewAnswer = yes > no && yes > unknown + ? "yes" + : no > yes && no > unknown + ? "no" + : "unknown"; + return { id, question, answer, yes, no, unknown }; + }); +} + +export function buildReviewConsensus( + input: readonly AiFindingReview[], + options: { minimumReviewers?: number } = {}, +): AiReviewConsensus { + const minimumReviewers = Math.max(2, Math.min(10, options.minimumReviewers ?? 2)); + const reviews = uniqueReviews(input).slice(0, 10); + const models = reviews.map((review) => review.model.trim()); + + if (reviews.length < minimumReviewers) { + return { + schemaVersion: 1, + verdict: "uncertain", + severity: "unknown", + confidence: 0, + agreement: "insufficient", + reviewerCount: reviews.length, + models, + agreeingModels: [], + dissentingModels: models, + gate: consensusGates(reviews), + interpretation: "model-consensus-not-scanner-evidence", + }; + } + + const verdictCounts = new Map(); + for (const review of reviews) verdictCounts.set(review.verdict, (verdictCounts.get(review.verdict) ?? 0) + 1); + const selected = winner(verdictCounts, verdictRank); + const hasMajority = selected.value !== undefined && selected.count > reviews.length / 2; + const unanimous = selected.value !== undefined && selected.count === reviews.length; + const consensusVerdict: AiFindingReview["verdict"] = hasMajority && !selected.tied ? selected.value ?? "uncertain" : "uncertain"; + const agreeing = reviews.filter((review) => review.verdict === consensusVerdict); + const confidenceSource = agreeing.length > 0 ? agreeing : reviews; + const confidence = confidenceSource.reduce((total, review) => total + clampConfidence(review.confidence), 0) / confidenceSource.length; + const severitySource = consensusVerdict === "false-positive" + ? agreeing + : reviews.filter((review) => review.verdict !== "false-positive"); + const severity = (severitySource.length ? severitySource : reviews) + .map((review) => review.severity) + .sort((a, b) => severityRank[b] - severityRank[a])[0] ?? "unknown"; + const agreeingModels = reviews.filter((review) => review.verdict === consensusVerdict).map((review) => review.model.trim()); + const dissentingModels = reviews.filter((review) => review.verdict !== consensusVerdict).map((review) => review.model.trim()); + + return { + schemaVersion: 1, + verdict: consensusVerdict, + severity, + confidence: Number(confidence.toFixed(4)), + agreement: unanimous ? "unanimous" : hasMajority && !selected.tied ? "majority" : "split", + reviewerCount: reviews.length, + models, + agreeingModels, + dissentingModels, + gate: consensusGates(reviews), + interpretation: "model-consensus-not-scanner-evidence", + }; +} + +/** + * Execute independent defensive finding reviews with bounded concurrency and aggregate them. + * A secret finding can never cross this orchestration boundary with source context, even when a + * custom reviewer is injected. Provider failures are isolated and credentials are redacted from + * returned diagnostics. Fewer than the configured minimum successful reviewers yields an + * insufficient/uncertain consensus rather than silently lowering the requirement. + */ +export async function reviewFindingWithConsensus( + finding: Finding, + providers: readonly OpenAiCompatibleConfig[], + context?: FindingContext, + reviewInstructions?: string, + options: MultiReviewOptions = {}, +): Promise { + if (finding.category === "secret" && context) { + throw new Error("Source context is prohibited for secret findings at the multi-review boundary."); + } + + const selected = uniqueProviders(providers); + const minimumReviewers = Math.max(2, Math.min(10, options.minimumReviewers ?? 2)); + const concurrency = Math.max(1, Math.min(4, options.concurrency ?? 2)); + const reviewer = options.reviewer ?? reviewFinding; + const queue = [...selected]; + const reviews: AiFindingReview[] = []; + const failures: ReviewConsensusFailure[] = []; + + await Promise.all(Array.from({ length: Math.min(concurrency, Math.max(1, queue.length)) }, async () => { + while (queue.length > 0) { + const provider = queue.shift(); + if (!provider) return; + try { + const review = await reviewer(finding, provider, context, reviewInstructions); + reviews.push(review); + } catch (error) { + failures.push({ + model: provider.model, + message: safeFailureMessage(error, provider.apiKey), + }); + } + } + })); + + reviews.sort((a, b) => a.model.localeCompare(b.model)); + failures.sort((a, b) => a.model.localeCompare(b.model)); + return { + reviews, + failures, + consensus: buildReviewConsensus(reviews, { minimumReviewers }), + }; +} diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts new file mode 100644 index 00000000..857f1f90 --- /dev/null +++ b/packages/ai/src/index.ts @@ -0,0 +1,197 @@ +import type { Finding } from "@synsec/core"; +import type { FindingContext } from "@synsec/repository"; + +export type ReviewAnswer = "yes" | "no" | "unknown"; + +export interface ReviewGateQuestion { + id: string; + question: string; + answer: ReviewAnswer; + note: string; +} + +export interface AiFindingReview { + verdict: "confirmed" | "likely" | "uncertain" | "false-positive"; + confidence: number; + severity: Finding["severity"]; + summary: string; + rationale: string; + gate: ReviewGateQuestion[]; + remediation?: string; + model: string; +} + +export interface OpenAiCompatibleConfig { + baseUrl: string; + apiKey?: string; + model: string; + timeoutMs?: number; +} + +interface ChatCompletionResponse { + choices?: Array<{ + message?: { + content?: string; + }; + }>; +} + +const gateQuestions = [ + ["concrete", "Is there a concrete vulnerable code or configuration location?"], + ["input", "Is attacker-controlled or otherwise untrusted input involved where the finding requires it?"], + ["sink", "Does the code reach a security-sensitive sink or violate a meaningful security invariant?"], + ["reachable", "Is the affected path reachable in the repository's actual application flow rather than dead/example code?"], + ["mitigations", "Have relevant validations, escaping, authorization checks, sandboxing, or other mitigations been accounted for?"], + ["evidence", "Is there scanner or code evidence supporting the conclusion without relying only on speculation?"], + ["actionable", "Is there a specific, proportionate remediation that addresses the underlying issue?"], +] as const; + +function stripCodeFence(value: string): string { + const trimmed = value.trim(); + const match = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(trimmed); + return match?.[1] ?? trimmed; +} + +function clampConfidence(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value)) return 0.5; + return Math.max(0, Math.min(1, value)); +} + +function isSeverity(value: unknown): value is Finding["severity"] { + return value === "critical" || value === "high" || value === "medium" || value === "low" || value === "info" || value === "unknown"; +} + +function answer(value: unknown): ReviewAnswer { + return value === "yes" || value === "no" || value === "unknown" ? value : "unknown"; +} + +function verdict(value: unknown): AiFindingReview["verdict"] { + return value === "confirmed" || value === "likely" || value === "uncertain" || value === "false-positive" + ? value + : "uncertain"; +} + +function normalizeReview(value: unknown, finding: Finding, model: string): AiFindingReview { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("AI review response was not a JSON object."); + } + const record = value as Record; + const rawGate = Array.isArray(record.gate) ? record.gate : []; + const gate = gateQuestions.map(([id, question]) => { + const found = rawGate.find((item) => typeof item === "object" && item !== null && (item as Record).id === id) as Record | undefined; + return { + id, + question, + answer: answer(found?.answer), + note: typeof found?.note === "string" ? found.note : "No model note provided.", + }; + }); + + const review: AiFindingReview = { + verdict: verdict(record.verdict), + confidence: clampConfidence(record.confidence), + severity: isSeverity(record.severity) ? record.severity : finding.severity, + summary: typeof record.summary === "string" ? record.summary : finding.title, + rationale: typeof record.rationale === "string" ? record.rationale : "No rationale provided.", + gate, + model, + }; + if (typeof record.remediation === "string") review.remediation = record.remediation; + return review; +} + +function safeMetadataForModel(finding: Finding): Record | undefined { + if (!finding.metadata) return undefined; + if (finding.category !== "secret") return finding.metadata; + + // Keep the model boundary resilient even if a future secret-scanner adapter + // accidentally adds richer metadata. Only a deliberately narrow allowlist + // can cross the boundary for secret findings. + const allowed = new Set([ + "validationStatus", + "validationReason", + "commit", + "author", + "date", + "tags", + ]); + const safe: Record = {}; + for (const [key, value] of Object.entries(finding.metadata)) { + if (allowed.has(key)) safe[key] = value; + } + return safe; +} + +function buildPrompt(finding: Finding, context?: FindingContext, reviewInstructions?: string): string { + const safeFinding = { + title: finding.title, + description: finding.description, + category: finding.category, + severity: finding.severity, + confidence: finding.confidence, + scanner: finding.scanner, + location: finding.location, + identifiers: finding.identifiers, + remediation: finding.remediation, + metadata: safeMetadataForModel(finding), + }; + + const contextBlock = context + ? `\nRepository excerpt (${context.path}, lines ${context.startLine}-${context.endLine}):\n${context.excerpt}` + : "\nNo source excerpt was provided. Treat reachability and code-flow claims as unknown unless scanner evidence is sufficient."; + const workflowBlock = reviewInstructions + ? `\nWorkflow-specific review instructions:\n${reviewInstructions}\n` + : ""; + + return `You are reviewing a repository security scanner finding for defensive software assurance. Do not invent exploit steps, credentials, or evidence. Separate deterministic scanner evidence from inference. If the available context cannot answer a question, answer unknown. Return JSON only.${workflowBlock}\nFinding:\n${JSON.stringify(safeFinding, null, 2)}${contextBlock}\n\nAssess these seven gates:\n${gateQuestions.map(([id, question], index) => `${index + 1}. ${id}: ${question}`).join("\n")}\n\nReturn exactly this shape:\n{\n "verdict": "confirmed|likely|uncertain|false-positive",\n "confidence": 0.0,\n "severity": "critical|high|medium|low|info|unknown",\n "summary": "short summary",\n "rationale": "brief evidence-grounded rationale",\n "gate": [{"id":"concrete","answer":"yes|no|unknown","note":"brief note"}],\n "remediation": "brief defensive remediation"\n}`; +} + +export async function reviewFinding( + finding: Finding, + config: OpenAiCompatibleConfig, + context?: FindingContext, + reviewInstructions?: string, +): Promise { + if (finding.category === "secret" && context) { + throw new Error("Source context is prohibited for secret findings at the AI provider boundary."); + } + + const baseUrl = config.baseUrl.replace(/\/$/, ""); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), config.timeoutMs ?? 90_000); + + try { + const headers: Record = { "content-type": "application/json" }; + if (config.apiKey) headers.authorization = `Bearer ${config.apiKey}`; + + const response = await fetch(`${baseUrl}/chat/completions`, { + method: "POST", + headers, + signal: controller.signal, + body: JSON.stringify({ + model: config.model, + temperature: 0, + messages: [ + { + role: "system", + content: "Perform concise defensive repository vulnerability triage. Return valid JSON only.", + }, + { role: "user", content: buildPrompt(finding, context, reviewInstructions) }, + ], + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`AI provider returned HTTP ${response.status}: ${text.slice(0, 500)}`); + } + + const payload = (await response.json()) as ChatCompletionResponse; + const content = payload.choices?.[0]?.message?.content; + if (!content) throw new Error("AI provider returned no message content."); + const parsed = JSON.parse(stripCodeFence(content)) as unknown; + return normalizeReview(parsed, finding, config.model); + } finally { + clearTimeout(timeout); + } +} diff --git a/packages/ai/tsconfig.json b/packages/ai/tsconfig.json new file mode 100644 index 00000000..6f767f25 --- /dev/null +++ b/packages/ai/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [ + { "path": "../core" }, + { "path": "../repository" } + ], + "include": ["src/**/*.ts"] +} diff --git a/packages/config/package.json b/packages/config/package.json new file mode 100644 index 00000000..4d653b6a --- /dev/null +++ b/packages/config/package.json @@ -0,0 +1,15 @@ +{ + "name": "@synsec/config", + "version": "0.2.0", + "private": true, + "type": "module", + "exports": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@synsec/core": "0.1.0" + } +} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts new file mode 100644 index 00000000..c9199013 --- /dev/null +++ b/packages/config/src/index.ts @@ -0,0 +1,184 @@ +import { access, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import type { Severity } from "@synsec/core"; + +export const SYNSEC_CONFIG_FILENAME = "synsec.config.json"; + +export interface AiConfig { + enabled: boolean; + provider: "openai-compatible"; + baseUrl?: string; + /** Default model when no workflow-specific route is configured. */ + model?: string; + /** Optional model route keyed by workflow id, e.g. dependency-review. */ + workflowModels?: Record; + sendSourceContext: boolean; +} + +export interface ReportConfig { + json: string; + html: string; + sarif: string; + markdown: string; +} + +export interface SynSecConfig { + schemaVersion: 1; + scanners: string[]; + parallelism: number; + timeoutMs: number; + failOn: Severity | "none"; + reports: ReportConfig; + baseline?: string; + ai: AiConfig; +} + +export const defaultConfig: SynSecConfig = { + schemaVersion: 1, + scanners: [ + "opengrep", + "betterleaks", + "osv-scanner", + "trivy", + "grype", + "checkov", + "syft", + "scorecard", + ], + parallelism: 3, + timeoutMs: 15 * 60_000, + failOn: "none", + reports: { + json: ".synsec/report.json", + html: ".synsec/report.html", + sarif: ".synsec/report.sarif", + markdown: ".synsec/report.md", + }, + ai: { + enabled: false, + provider: "openai-compatible", + sendSourceContext: false, + }, +}; + +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function stringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) return undefined; + return value; +} + +function stringMap(value: unknown): Record | undefined { + const record = asRecord(value); + if (!record) return undefined; + const entries = Object.entries(record) + .filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1].trim().length > 0) + .map(([key, model]) => [key.trim(), model.trim()] as const) + .filter(([key]) => key.length > 0); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +function severity(value: unknown): SynSecConfig["failOn"] | undefined { + if ( + value === "critical" || + value === "high" || + value === "medium" || + value === "low" || + value === "info" || + value === "unknown" || + value === "none" + ) return value; + return undefined; +} + +function positiveInteger(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback; +} + +export function parseConfig(value: unknown): SynSecConfig { + const root = asRecord(value); + if (!root) throw new Error("SynSec configuration must be a JSON object."); + if (root.schemaVersion !== undefined && root.schemaVersion !== 1) { + throw new Error(`Unsupported SynSec configuration schemaVersion: ${String(root.schemaVersion)}`); + } + + const reportsValue = asRecord(root.reports); + const aiValue = asRecord(root.ai); + + const reports: ReportConfig = { + json: typeof reportsValue?.json === "string" ? reportsValue.json : defaultConfig.reports.json, + html: typeof reportsValue?.html === "string" ? reportsValue.html : defaultConfig.reports.html, + sarif: typeof reportsValue?.sarif === "string" ? reportsValue.sarif : defaultConfig.reports.sarif, + markdown: typeof reportsValue?.markdown === "string" ? reportsValue.markdown : defaultConfig.reports.markdown, + }; + + const ai: AiConfig = { + enabled: typeof aiValue?.enabled === "boolean" ? aiValue.enabled : defaultConfig.ai.enabled, + provider: "openai-compatible", + sendSourceContext: + typeof aiValue?.sendSourceContext === "boolean" + ? aiValue.sendSourceContext + : defaultConfig.ai.sendSourceContext, + }; + if (typeof aiValue?.baseUrl === "string") ai.baseUrl = aiValue.baseUrl; + if (typeof aiValue?.model === "string") ai.model = aiValue.model; + const workflowModels = stringMap(aiValue?.workflowModels); + if (workflowModels) ai.workflowModels = workflowModels; + + const config: SynSecConfig = { + schemaVersion: 1, + scanners: stringArray(root.scanners) ?? [...defaultConfig.scanners], + parallelism: positiveInteger(root.parallelism, defaultConfig.parallelism), + timeoutMs: positiveInteger(root.timeoutMs, defaultConfig.timeoutMs), + failOn: severity(root.failOn) ?? defaultConfig.failOn, + reports, + ai, + }; + if (typeof root.baseline === "string") config.baseline = root.baseline; + return config; +} + +export function resolveAiModel( + config: AiConfig, + options: { workflowId?: string; overrideModel?: string; environmentModel?: string } = {}, +): string | undefined { + const override = options.overrideModel?.trim(); + if (override) return override; + if (options.workflowId) { + const routed = config.workflowModels?.[options.workflowId]?.trim(); + if (routed) return routed; + } + const configured = config.model?.trim(); + if (configured) return configured; + const environment = options.environmentModel?.trim(); + return environment || undefined; +} + +export async function findConfig(startPath: string): Promise { + const candidate = join(resolve(startPath), SYNSEC_CONFIG_FILENAME); + return await access(candidate).then(() => candidate).catch(() => undefined); +} + +export async function loadConfig(rootPath: string, explicitPath?: string): Promise<{ config: SynSecConfig; path?: string }> { + const path = explicitPath ? resolve(explicitPath) : await findConfig(rootPath); + if (!path) return { config: structuredClone(defaultConfig) }; + const parsed = JSON.parse(await readFile(path, "utf8")) as unknown; + return { config: parseConfig(parsed), path }; +} + +export async function writeDefaultConfig(path: string): Promise { + await writeFile(path, `${JSON.stringify(defaultConfig, null, 2)}\n`, { encoding: "utf8", flag: "wx" }); +} + +export function resolveReportPaths(rootPath: string, config: SynSecConfig): ReportConfig { + return { + json: resolve(rootPath, config.reports.json), + html: resolve(rootPath, config.reports.html), + sarif: resolve(rootPath, config.reports.sarif), + markdown: resolve(rootPath, config.reports.markdown), + }; +} diff --git a/packages/config/tsconfig.json b/packages/config/tsconfig.json new file mode 100644 index 00000000..ebe9ac5b --- /dev/null +++ b/packages/config/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [ + { "path": "../core" } + ], + "include": ["src/**/*.ts"] +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 568c0fc2..e4c8ad53 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -47,10 +47,32 @@ export interface Finding { identifiers?: FindingIdentifiers; evidence?: string; remediation?: string; + /** Native scanner fingerprint when one exists. SynSec computes its own correlation fingerprint. */ fingerprint?: string; metadata?: Record; } +export interface SbomPackage { + name: string; + version?: string; + type?: string; + purl?: string; + licenses?: string[]; + locations?: string[]; +} + +export interface SbomArtifact { + type: "sbom"; + format: "syft-json"; + producer: string; + generatedAt: string; + packageCount: number; + packages: SbomPackage[]; + metadata?: Record; +} + +export type ScanArtifact = SbomArtifact; + export interface ScanTarget { path: string; repositoryUrl?: string; @@ -58,6 +80,18 @@ export interface ScanTarget { branch?: string; } +export type ScannerExecutionMode = + | "repository" + | "changed-files-native" + | "repository-then-filtered"; + +export interface ScannerExecutionScope { + mode: ScannerExecutionMode; + changedFileCount?: number; + /** Execution mode is provenance for scanner work, not proof that unselected code is unaffected. */ + interpretation: "scanner-execution-scope-not-coverage-proof"; +} + export interface ScanResult { scanner: string; startedAt: string; @@ -65,9 +99,12 @@ export interface ScanResult { target: ScanTarget; findings: Finding[]; diagnostics: string[]; + artifacts?: ScanArtifact[]; + executionScope?: ScannerExecutionScope; } export interface CorrelatedFinding { + /** Stable SynSec correlation fingerprint, independent of the source scanner fingerprint. */ fingerprint: string; primary: Finding; duplicates: Finding[]; @@ -83,35 +120,99 @@ const severityWeight: Record = { unknown: 0, }; -function normalizedIdentifierSet(finding: Finding): string { - const ids = finding.identifiers; - if (!ids) return ""; +function normalizedValues(values: readonly string[]): string[] { + return [...new Set(values.map((value) => value.trim().toLowerCase()).filter(Boolean))].sort(); +} - return [ +function normalizedIdentifierSet(finding: Finding): string[] { + const ids = finding.identifiers; + if (!ids) return []; + return normalizedValues([ ...(ids.cwe ?? []), ...(ids.cve ?? []), ...(ids.osv ?? []), ...(ids.ghsa ?? []), - ] - .map((value) => value.trim().toLowerCase()) - .sort() - .join(","); + ]); } -export function findingFingerprint(finding: Finding): string { - if (finding.fingerprint) return finding.fingerprint; +function strongVulnerabilityIdentifiers(finding: Finding): string[] { + const ids = finding.identifiers; + if (!ids) return []; + + // Prefer globally interoperable aliases when a scanner gives us several + // names for the same advisory. This lets an OSV/GHSA-centric scanner and a + // CVE-centric scanner converge on the same SynSec key instead of diverging + // merely because one result contains more aliases. + const cve = normalizedValues(ids.cve ?? []); + if (cve.length > 0) return cve; + const ghsa = normalizedValues(ids.ghsa ?? []); + if (ghsa.length > 0) return ghsa; + return normalizedValues(ids.osv ?? []); +} - const location = finding.location; - const canonical = [ +function normalizedPath(finding: Finding): string { + return (finding.location?.path ?? "") + .replaceAll("\\", "/") + .replace(/^\.\//, "") + .toLowerCase(); +} + +function normalizedTitle(finding: Finding): string { + return finding.title.trim().toLowerCase().replace(/\s+/g, " "); +} + +function metadataString(finding: Finding, key: string): string { + const value = finding.metadata?.[key]; + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +function packageIdentity(finding: Finding): string { + return metadataString(finding, "purl") || metadataString(finding, "package"); +} + +function correlationCanonical(finding: Finding): string { + const path = normalizedPath(finding); + const line = finding.location?.startLine?.toString() ?? ""; + const strongIds = strongVulnerabilityIdentifiers(finding); + + // Dependency engines frequently use different rule IDs and titles for the + // same advisory. Advisory IDs plus package identity are substantially more + // reliable than scanner-specific fingerprints for cross-tool correlation. + if ((finding.category === "dependency" || finding.category === "container" || finding.category === "supply-chain") && strongIds.length > 0) { + return ["advisory", finding.category, strongIds.join(","), packageIdentity(finding) || path].join("|"); + } + + // Secret scanners use different rule names for the same value. SynSec never + // hashes the secret itself; a shared source location is the safest common + // deterministic signal we can use without retaining credentials. + if (finding.category === "secret" && path && line) { + return ["secret-location", path, line].join("|"); + } + + // Two SAST engines that agree on the same CWE at the same source location + // should normally be presented as corroborating evidence for one issue. + const cwes = normalizedValues(finding.identifiers?.cwe ?? []); + if (finding.category === "sast" && path && line && cwes.length > 0) { + return ["sast-location-cwe", path, line, cwes.join(",")].join("|"); + } + + // Fall back to a conservative scanner-aware key when there is not enough + // evidence to safely merge alerts from unrelated engines. + return [ + "exact", finding.category, - finding.scanner.ruleId ?? "", - location?.path.toLowerCase() ?? "", - location?.startLine?.toString() ?? "", - normalizedIdentifierSet(finding), - finding.title.trim().toLowerCase(), + finding.scanner.name.toLowerCase(), + (finding.scanner.ruleId ?? "").toLowerCase(), + path, + line, + normalizedIdentifierSet(finding).join(","), + normalizedTitle(finding), ].join("|"); +} - return createHash("sha256").update(canonical).digest("hex"); +/** Compute SynSec's correlation fingerprint. Native scanner fingerprints remain on Finding.fingerprint. */ +export function findingFingerprint(finding: Finding): string { + return createHash("sha256").update(correlationCanonical(finding)).digest("hex"); } function shouldReplacePrimary(current: Finding, candidate: Finding): boolean { diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json new file mode 100644 index 00000000..d51986c2 --- /dev/null +++ b/packages/dashboard/package.json @@ -0,0 +1,17 @@ +{ + "name": "@synsec/dashboard", + "version": "0.2.0", + "private": true, + "type": "module", + "exports": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@synsec/lifecycle": "0.2.0", + "@synsec/report": "0.2.0", + "@synsec/repository": "0.2.0" + } +} diff --git a/packages/dashboard/src/index.ts b/packages/dashboard/src/index.ts new file mode 100644 index 00000000..0c61d251 --- /dev/null +++ b/packages/dashboard/src/index.ts @@ -0,0 +1,128 @@ +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import type { LifecycleReviewDeadlineAssessment } from "@synsec/lifecycle/review-deadlines"; +import type { FindingTriageView } from "@synsec/lifecycle/triage-view"; +import { writeFindingTriageHtml } from "@synsec/lifecycle/triage-html"; +import type { SynSecReport } from "@synsec/report"; +import type { ReportHistory } from "@synsec/report/history"; +import { writeHistoryHtml } from "@synsec/report/history-html"; +import { buildSbomView, writeSbomHtml } from "@synsec/report/sbom-html"; +import type { RepositoryPostureSummary } from "@synsec/repository/posture"; +import { writeRepositoryPostureHtml } from "@synsec/repository/posture-html"; +import { + summarizeRouteSecurityReviews, + type RouteSecurityReviewContext, +} from "@synsec/repository/route-security-review"; + +export interface ProjectDashboardInput { + report: SynSecReport; + triage: FindingTriageView; + posture: RepositoryPostureSummary; + history?: ReportHistory; + reviewDeadlines?: LifecycleReviewDeadlineAssessment; + routeSecurityReviews?: readonly RouteSecurityReviewContext[]; +} + +export interface ProjectDashboardPaths { + directory: string; + index: string; + triage: string; + dependencies: string; + posture: string; + history?: string; +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export function renderProjectDashboardIndex(input: ProjectDashboardInput): string { + const sbom = buildSbomView(input.report); + const summary = input.report.summary; + const historyCard = input.history + ? `
${input.history.points.length}
historical scans
score delta ${input.history.scoreDelta >= 0 ? "+" : ""}${input.history.scoreDelta}
` + : ""; + const reviewCard = input.reviewDeadlines + ? `
${input.reviewDeadlines.summary.overdue}
overdue exception reviews
${input.reviewDeadlines.summary.dueSoon} due soon · ${input.reviewDeadlines.summary.unscheduled} unscheduled
` + : ""; + const routeReviewSummary = input.routeSecurityReviews + ? summarizeRouteSecurityReviews(input.routeSecurityReviews) + : undefined; + const routeReviewCard = routeReviewSummary + ? `
${routeReviewSummary.needsAuthReview}
sensitive-sink auth reviews
${routeReviewSummary.signals["sensitive-sink-with-authorization-signal"]} authorization signal · ${routeReviewSummary.signals["sensitive-sink-with-authentication-signal"]} authentication signal
` + : ""; + return ` + + + + + +SynSec project dashboard + + + +

SynSec project dashboard

+

Local sanitized security views · report ${escapeHtml(input.report.reportId)}

+ +
+ ${summary.critical} critical${summary.high} high${summary.medium} medium${summary.low} low${summary.info} info${summary.unknown} unknown +
+

This index links only to locally generated sanitized views. Repository source excerpts, scanner diagnostics, lifecycle notes/owners, tokens, route names/handlers, and arbitrary outbound URLs are not embedded by this dashboard composition. Exception-review counts are governance metadata, not scanner evidence. Route-security counts are validated structural review context, not protection or exploitability verdicts.

+ +\n`; +} + +/** + * Write one local static project dashboard bundle from already-normalized SynSec models. + * + * The bundle has no server, authentication, remote assets, JavaScript, source excerpts, or scanner + * credentials. It is a developer-facing local composition primitive, not the future multi-user web + * application. Optional history is accepted only through the existing trend-safe history model; + * optional exception-review health and route-security review context are rendered only as minimized + * aggregate counts. Route-security contexts cross the summary validator before rendering. All + * generated files are written with restrictive permissions where supported. + */ +export async function writeProjectDashboard( + directory: string, + input: ProjectDashboardInput, +): Promise { + const root = resolve(directory); + await mkdir(root, { recursive: true, mode: 0o700 }); + const paths: ProjectDashboardPaths = { + directory: root, + index: join(root, "index.html"), + triage: join(root, "triage.html"), + dependencies: join(root, "dependencies.html"), + posture: join(root, "posture.html"), + ...(input.history ? { history: join(root, "history.html") } : {}), + }; + + const writes: Promise[] = [ + writeFile(paths.index, renderProjectDashboardIndex(input), { encoding: "utf8", mode: 0o600 }) + .then(() => chmod(paths.index, 0o600).catch(() => undefined)), + writeFindingTriageHtml(paths.triage, input.triage), + writeSbomHtml(paths.dependencies, buildSbomView(input.report)), + writeRepositoryPostureHtml(paths.posture, input.posture), + ]; + if (input.history && paths.history) { + writes.push(writeHistoryHtml(paths.history, input.history, { title: "SynSec project security history" })); + } + await Promise.all(writes); + + return paths; +} diff --git a/packages/dashboard/tsconfig.json b/packages/dashboard/tsconfig.json new file mode 100644 index 00000000..7db03aab --- /dev/null +++ b/packages/dashboard/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [ + { "path": "../report" }, + { "path": "../repository" }, + { "path": "../lifecycle" } + ], + "include": ["src/**/*.ts"] +} diff --git a/packages/engine/package.json b/packages/engine/package.json new file mode 100644 index 00000000..d9e52183 --- /dev/null +++ b/packages/engine/package.json @@ -0,0 +1,20 @@ +{ + "name": "@synsec/engine", + "version": "0.2.0", + "private": true, + "type": "module", + "exports": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@synsec/config": "0.2.0", + "@synsec/core": "0.1.0", + "@synsec/report": "0.2.0", + "@synsec/repository": "0.2.0", + "@synsec/scanner-sdk": "0.1.0", + "@synsec/scanners": "0.1.0" + } +} diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts new file mode 100644 index 00000000..805a7f0c --- /dev/null +++ b/packages/engine/src/index.ts @@ -0,0 +1,566 @@ +import { resolve } from "node:path"; +import type { SynSecConfig } from "@synsec/config"; +import type { Finding, ScannerExecutionScope, ScanResult, ScanTarget, Severity } from "@synsec/core"; +import { buildReport, type SynSecReport } from "@synsec/report"; +import { applyEvidenceAwareBaseline } from "@synsec/report/baseline"; +import { inventoryRepository } from "@synsec/repository"; +import { + buildRepositoryIndex, + findingRepositoryContext, + packageNameFromPurl, + type RepositoryIndex, +} from "@synsec/repository/analysis"; +import { findExternalDependencyUsage } from "@synsec/repository/dependency-usage"; +import { buildModuleGraph, type ModuleGraph } from "@synsec/repository/module-graph"; +import { + buildIncrementalScanPlan, + type IncrementalScanPlan, +} from "@synsec/repository/incremental-plan"; +import { buildRepositoryRouteFlowAnalysis } from "@synsec/repository/route-flow-analysis"; +import { + findingRouteProtectionEvidence, + type RouteProtectionContext, +} from "@synsec/repository/route-protection-context"; +import { + findingRouteSinkFlowEvidence, + type RouteSinkFlowContext, +} from "@synsec/repository/route-sink-flow"; +import { + runProcess, + sanitizeOperationalText, + type ScannerAdapter, + type ScannerAvailability, +} from "@synsec/scanner-sdk"; +import { builtInScanners, scannerSupportsNativeChangedFiles } from "@synsec/scanners"; + +export interface ScannerStatus { + id: string; + displayName: string; + selected: boolean; + availability: ScannerAvailability; +} + +export interface ScannerFailure { + scanner: string; + message: string; +} + +export interface ScanEngineOutcome { + report: SynSecReport; + repositoryIndex: RepositoryIndex; + statuses: ScannerStatus[]; + failures: ScannerFailure[]; + shouldFail: boolean; + changedFiles?: string[]; + changedBase?: string; + incrementalPlan?: IncrementalScanPlan; +} + +const severityRank: Record = { + critical: 5, + high: 4, + medium: 3, + low: 2, + info: 1, + unknown: 0, +}; +const EXECUTION_INTERPRETATION = "scanner-execution-scope-not-coverage-proof" as const; +const MAX_SCANNER_DIAGNOSTICS = 1_000; +const MAX_CHANGED_BASE_LENGTH = 256; +const ENGINE_OWNED_METADATA_KEYS = new Set([ + "dependencyUsage", + "repositoryContext", + "routeFlow", + "routeProtection", +]); + +function sanitizeRemoteUrl(value: string): string { + try { + const url = new URL(value); + if (url.username || url.password) { + url.username = ""; + url.password = ""; + } + return url.toString().replace(/\/$/, ""); + } catch { + return value.replace(/:\/\/[^/@]+@/, "://"); + } +} + +function scannerIdentityLabel(value: string): string { + return sanitizeOperationalText(value, 256) || "scanner"; +} + +/** + * Scanner adapters are an external-process/plugin boundary. Their thrown errors are operational + * diagnostics, never evidence, so redact and bound them before they can enter reports, logs, or + * aggregate engine exceptions. + */ +export function scannerFailureMessage(error: unknown): string { + const raw = error instanceof Error ? error.message : String(error); + return sanitizeOperationalText(raw) || "Scanner failed without an operational diagnostic."; +} + +/** + * Sanitize successful scanner diagnostics at the engine boundary without modifying scanner + * findings or source evidence. Diagnostic volume is bounded independently from finding volume. + */ +export function sanitizeScanDiagnostics(scan: ScanResult): ScanResult { + const diagnostics = scan.diagnostics + .slice(0, MAX_SCANNER_DIAGNOSTICS) + .map((value) => sanitizeOperationalText(value)) + .filter(Boolean); + if (scan.diagnostics.length > MAX_SCANNER_DIAGNOSTICS) { + diagnostics.push(`Additional scanner diagnostics omitted after ${MAX_SCANNER_DIAGNOSTICS} entries.`); + } + return { ...scan, diagnostics }; +} + +function scannerOwnedMetadata(metadata: Finding["metadata"]): Finding["metadata"] { + if (!metadata) return undefined; + const entries = Object.entries(metadata).filter(([key]) => !ENGINE_OWNED_METADATA_KEYS.has(key)); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +/** + * Remove keys reserved for SynSec-derived repository intelligence exactly once when scanner-owned + * findings cross into the engine. Later enrichment may safely populate those keys without being + * erased by another trust-boundary pass. + */ +export function stripScannerReservedMetadata(scans: readonly ScanResult[]): ScanResult[] { + return scans.map((scan) => ({ + ...scan, + findings: scan.findings.map((finding) => { + const metadata = scannerOwnedMetadata(finding.metadata); + if (metadata) return { ...finding, metadata }; + const output = { ...finding }; + delete output.metadata; + return output; + }), + })); +} + +async function gitValue(root: string, args: string[]): Promise { + try { + const output = await runProcess("git", ["-C", root, ...args], { timeoutMs: 5_000 }); + if (output.exitCode !== 0) return undefined; + const value = output.stdout.trim(); + return value || undefined; + } catch { + return undefined; + } +} + +export async function discoverTarget(rootPath: string): Promise { + const path = resolve(rootPath); + const [commitSha, branch, repositoryUrl] = await Promise.all([ + gitValue(path, ["rev-parse", "HEAD"]), + gitValue(path, ["branch", "--show-current"]), + gitValue(path, ["config", "--get", "remote.origin.url"]), + ]); + + const target: ScanTarget = { path }; + if (commitSha) target.commitSha = commitSha; + if (branch) target.branch = branch; + if (repositoryUrl) target.repositoryUrl = sanitizeRemoteUrl(repositoryUrl); + return target; +} + +function normalizeRepositoryPath(path: string, root: string): string { + const normalizedRoot = resolve(root).replace(/\\/g, "/").replace(/\/$/, ""); + let normalized = path.replace(/\\/g, "/"); + if (normalized.startsWith(`${normalizedRoot}/`)) normalized = normalized.slice(normalizedRoot.length + 1); + normalized = normalized.replace(/^\.\//, "").replace(/^\//, ""); + return normalized; +} + +function normalizeProvidedChangedFiles(files: readonly string[], root: string): string[] { + if (files.length > 10_000) throw new Error("Externally supplied changed-file scope exceeds 10000 paths."); + const normalized: string[] = []; + for (const value of files) { + if (typeof value !== "string" || value.includes("\0")) throw new Error("Externally supplied changed-file scope contains an invalid path."); + const candidate = value.trim().replaceAll("\\", "/").replace(/^\.\//, ""); + if (!candidate || candidate.startsWith("/") || /^[A-Za-z]:\//.test(candidate)) { + throw new Error("Externally supplied changed-file scope must contain repository-relative paths."); + } + const segments = candidate.split("/"); + if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) { + throw new Error("Externally supplied changed-file scope contains an unsafe path segment."); + } + normalized.push(normalizeRepositoryPath(candidate, root)); + } + return [...new Map(normalized.map((path) => [path.toLowerCase(), path])).values()].sort(); +} + +function normalizeChangedBase(value: string): string { + const base = value.trim(); + if (!base + || base.length > MAX_CHANGED_BASE_LENGTH + || base.startsWith("-") + || /[\u0000-\u001f\u007f]/.test(base)) { + throw new Error("Changed-file base revision is invalid."); + } + return base; +} + +async function resolveChangedBaseCommit(root: string, base: string): Promise { + try { + const output = await runProcess( + "git", + ["-C", root, "rev-parse", "--verify", "--end-of-options", `${base}^{commit}`], + { timeoutMs: 5_000 }, + ); + if (output.exitCode !== 0) return undefined; + const commit = output.stdout.trim(); + return /^[0-9a-f]{40,64}$/i.test(commit) ? commit : undefined; + } catch { + return undefined; + } +} + +export async function discoverChangedFiles(rootPath: string, requestedBase?: string): Promise<{ base: string; files: string[] }> { + const root = resolve(rootPath); + const githubBaseRaw = process.env.GITHUB_BASE_REF?.trim(); + const candidates: string[] = []; + + if (requestedBase !== undefined) { + candidates.push(normalizeChangedBase(requestedBase)); + } else if (githubBaseRaw) { + const githubBase = normalizeChangedBase(githubBaseRaw); + candidates.push(`origin/${githubBase}`, githubBase); + } else { + candidates.push("HEAD~1"); + } + + let lastDiagnostic = ""; + for (const base of candidates) { + const commit = await resolveChangedBaseCommit(root, base); + if (!commit) continue; + const output = await runProcess( + "git", + ["-C", root, "diff", "--name-only", "--diff-filter=ACMRTUXB", `${commit}...HEAD`], + { timeoutMs: 10_000 }, + ); + if (output.exitCode !== 0) { + lastDiagnostic = sanitizeOperationalText(output.stderr.trim(), 2_048); + continue; + } + + const files = [...new Set( + output.stdout + .split(/\r?\n/) + .map((value) => normalizeRepositoryPath(value.trim(), root)) + .filter(Boolean), + )].sort(); + return { base, files }; + } + + throw new Error( + `Unable to determine changed files from a verified base revision.${lastDiagnostic ? ` ${lastDiagnostic}` : ""}`, + ); +} + +function findingMatchesChangedFiles(finding: Finding, changed: Set, root: string): boolean { + if (!finding.location?.path) return true; + const path = normalizeRepositoryPath(finding.location.path, root).toLowerCase(); + return changed.has(path); +} + +function scopeScansToChangedFiles(scans: readonly ScanResult[], root: string, files: readonly string[]): ScanResult[] { + const changed = new Set(files.map((file) => normalizeRepositoryPath(file, root).toLowerCase())); + return scans.map((scan) => { + const before = scan.findings.length; + const findings = scan.findings.filter((finding) => findingMatchesChangedFiles(finding, changed, root)); + const dropped = before - findings.length; + return { + ...scan, + findings, + diagnostics: dropped > 0 + ? [...scan.diagnostics, `Changed-file scope omitted ${dropped} finding(s) outside the requested diff.`] + : scan.diagnostics, + }; + }); +} + +function dependencyPackageName(finding: Finding): string | undefined { + const direct = finding.metadata?.package; + if (typeof direct === "string" && direct.trim()) return direct.trim(); + const purl = finding.metadata?.purl; + return typeof purl === "string" ? packageNameFromPurl(purl) : undefined; +} + +function enrichDependencyUsage( + scans: readonly ScanResult[], + index: RepositoryIndex, + moduleGraph: ModuleGraph, +): ScanResult[] { + return scans.map((scan) => ({ + ...scan, + findings: scan.findings.map((finding) => { + if (finding.category !== "dependency" && finding.category !== "container" && finding.category !== "supply-chain") { + return finding; + } + const packageName = dependencyPackageName(finding); + if (!packageName) return finding; + const usage = findExternalDependencyUsage(index, moduleGraph, packageName); + return { + ...finding, + metadata: { + ...(finding.metadata ?? {}), + dependencyUsage: usage, + }, + }; + }), + })); +} + +/** + * Attach minimized repository intelligence only when it can be correlated to the finding's exact + * normalized location. Inputs are expected to have crossed stripScannerReservedMetadata() first. + * Secret findings remain outside this enrichment boundary. Route-protection context is structural + * auth evidence only; it never upgrades or suppresses scanner evidence. + */ +export function enrichRepositorySecurityContext( + scans: readonly ScanResult[], + index: RepositoryIndex, + routeFlows: readonly RouteSinkFlowContext[], + routeProtections: readonly RouteProtectionContext[] = [], +): ScanResult[] { + return scans.map((scan) => ({ + ...scan, + findings: scan.findings.map((finding) => { + // Secret findings intentionally stay on the narrowest metadata boundary. + if (finding.category === "secret" || !finding.location?.path) return finding; + const context = findingRepositoryContext(index, finding.location.path, finding.location.startLine); + const routeFlow = findingRouteSinkFlowEvidence( + routeFlows, + finding.location.path, + finding.location.startLine, + ); + const routeProtection = findingRouteProtectionEvidence( + routeProtections, + routeFlows, + finding.location.path, + finding.location.startLine, + ); + const hasContext = + context.nearbyRoutes.length > 0 || + context.nearbyAuthSignals.length > 0 || + context.nearbySinks.length > 0; + if (!hasContext && routeFlow.length === 0 && routeProtection.length === 0) return finding; + return { + ...finding, + metadata: { + ...(finding.metadata ?? {}), + ...(hasContext ? { repositoryContext: context } : {}), + ...(routeFlow.length > 0 ? { routeFlow } : {}), + ...(routeProtection.length > 0 ? { routeProtection } : {}), + }, + }; + }), + })); +} + +export async function scannerStatuses(config: SynSecConfig): Promise { + const selectedIds = new Set(config.scanners); + const scanners = builtInScanners(); + const knownIds = new Set(scanners.map((scanner) => scanner.id)); + const statuses = await Promise.all( + scanners.map(async (scanner) => { + let availability: ScannerAvailability; + try { + availability = await scanner.checkAvailability(); + } catch (error) { + availability = { + available: false, + reason: scannerFailureMessage(error), + }; + } + return { + id: scanner.id, + displayName: scanner.displayName, + selected: selectedIds.has(scanner.id), + availability, + }; + }), + ); + + for (const id of selectedIds) { + if (!knownIds.has(id)) { + const label = scannerIdentityLabel(id); + statuses.push({ + id: label, + displayName: label, + selected: true, + availability: { available: false, reason: "Unknown scanner id in configuration." }, + }); + } + } + return statuses; +} + +function defaultScannerExecutionScope(scannerId: string, changedFiles?: readonly string[]): ScannerExecutionScope { + if (!changedFiles) return { mode: "repository", interpretation: EXECUTION_INTERPRETATION }; + return { + mode: scannerSupportsNativeChangedFiles(scannerId) ? "changed-files-native" : "repository-then-filtered", + changedFileCount: changedFiles.length, + interpretation: EXECUTION_INTERPRETATION, + }; +} + +async function runSelectedScanners( + target: ScanTarget, + config: SynSecConfig, + statuses: readonly ScannerStatus[], + changedFiles?: string[], +): Promise<{ scans: ScanResult[]; failures: ScannerFailure[] }> { + const statusById = new Map(statuses.map((status) => [status.id, status])); + const selected = builtInScanners().filter((scanner) => { + const status = statusById.get(scanner.id); + return Boolean(status?.selected && status.availability.available); + }); + + const queue: ScannerAdapter[] = [...selected]; + const scans: ScanResult[] = []; + const failures: ScannerFailure[] = []; + const workers = Math.max(1, Math.min(config.parallelism, queue.length || 1)); + + await Promise.all( + Array.from({ length: workers }, async () => { + while (queue.length > 0) { + const scanner = queue.shift(); + if (!scanner) return; + try { + const result = await scanner.scan({ target, timeoutMs: config.timeoutMs, changedFiles }); + const sanitized = sanitizeScanDiagnostics(result); + scans.push({ + ...sanitized, + executionScope: sanitized.executionScope ?? defaultScannerExecutionScope(scanner.id, changedFiles), + }); + } catch (error) { + failures.push({ + scanner: scanner.id, + message: scannerFailureMessage(error), + }); + } + } + }), + ); + + scans.sort((a, b) => a.scanner.localeCompare(b.scanner)); + failures.sort((a, b) => a.scanner.localeCompare(b.scanner)); + return { scans, failures }; +} + +export function reportMeetsFailureThreshold(report: SynSecReport, failOn: SynSecConfig["failOn"]): boolean { + if (failOn === "none") return false; + const threshold = severityRank[failOn]; + return report.findings.some((group) => severityRank[group.primary.severity] >= threshold); +} + +function unavailableSummary(statuses: readonly ScannerStatus[]): string { + const selected = statuses.filter((status) => status.selected); + if (selected.length === 0) return "No scanner engines are selected in the SynSec configuration."; + const detail = selected + .map((status) => `${scannerIdentityLabel(status.displayName)}: ${sanitizeOperationalText(status.availability.reason ?? "unavailable", 2_048) || "unavailable"}`) + .join("; "); + return `No selected scanner engines are available. ${detail}`; +} + +export async function runScanEngine(input: { + rootPath: string; + config: SynSecConfig; + baseline?: SynSecReport; + toolVersion?: string; + changedOnly?: boolean; + changedBase?: string; + /** Repository-relative paths derived by a trusted caller from exact commit provenance. */ + changedFiles?: readonly string[]; +}): Promise { + const root = resolve(input.rootPath); + const [target, statuses, inventory] = await Promise.all([ + discoverTarget(root), + scannerStatuses(input.config), + inventoryRepository(root), + ]); + + const availableSelected = statuses.filter( + (status) => status.selected && status.availability.available, + ); + if (availableSelected.length === 0) throw new Error(unavailableSummary(statuses)); + + const repositoryIndex = await buildRepositoryIndex(root, inventory.files); + const moduleGraph = buildModuleGraph(repositoryIndex, inventory.files); + let routeFlows: RouteSinkFlowContext[] = []; + let routeProtections: RouteProtectionContext[] = []; + if (repositoryIndex.routes.length > 0 && repositoryIndex.sinks.length > 0) { + const routeAnalysis = await buildRepositoryRouteFlowAnalysis( + root, + inventory.files, + repositoryIndex, + moduleGraph, + ); + routeFlows = routeAnalysis.routeFlows; + routeProtections = routeAnalysis.routeProtectionContexts; + } + + let requestedScope: { base: string; files: string[] } | undefined; + if (input.changedFiles !== undefined) { + if (!input.changedOnly) throw new Error("Externally supplied changed files require changedOnly=true."); + const base = input.changedBase?.trim(); + if (!base) throw new Error("Externally supplied changed files require an explicit changedBase provenance identifier."); + requestedScope = { base, files: normalizeProvidedChangedFiles(input.changedFiles, root) }; + } else if (input.changedOnly) { + requestedScope = await discoverChangedFiles(root, input.changedBase); + } + + let changedScope: { base: string; files: string[] } | undefined; + let incrementalPlan: IncrementalScanPlan | undefined; + if (requestedScope) { + incrementalPlan = buildIncrementalScanPlan(moduleGraph, requestedScope.files); + if (incrementalPlan.mode === "targeted" && incrementalPlan.selectedFiles.length > 0) { + changedScope = { base: requestedScope.base, files: incrementalPlan.selectedFiles }; + } + } + + const result = await runSelectedScanners(target, input.config, statuses, changedScope?.files); + const scannerBounded = stripScannerReservedMetadata(result.scans); + const dependencyEnriched = enrichDependencyUsage(scannerBounded, repositoryIndex, moduleGraph); + const enrichedScans = enrichRepositorySecurityContext( + dependencyEnriched, + repositoryIndex, + routeFlows, + routeProtections, + ); + const scans = changedScope ? scopeScansToChangedFiles(enrichedScans, root, changedScope.files) : enrichedScans; + const failures = result.failures; + if (scans.length === 0) { + const details = failures.map((failure) => `${failure.scanner}: ${failure.message}`).join("; "); + throw new Error(`All available scanner engines failed.${details ? ` ${details}` : ""}`); + } + + let report = buildReport({ + target, + scans, + toolVersion: input.toolVersion ?? "0.2.0", + repository: inventory.metadata, + scope: changedScope + ? { mode: "changed-files", baseRef: changedScope.base, changedFiles: changedScope.files } + : { mode: "repository" }, + }); + if (input.baseline) report = applyEvidenceAwareBaseline(report, input.baseline); + + const outcome: ScanEngineOutcome = { + report, + repositoryIndex, + statuses, + failures, + shouldFail: reportMeetsFailureThreshold(report, input.config.failOn), + }; + if (changedScope) { + outcome.changedFiles = changedScope.files; + outcome.changedBase = changedScope.base; + } + if (incrementalPlan) outcome.incrementalPlan = incrementalPlan; + return outcome; +} diff --git a/packages/engine/tsconfig.json b/packages/engine/tsconfig.json new file mode 100644 index 00000000..8be504aa --- /dev/null +++ b/packages/engine/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [ + { "path": "../config" }, + { "path": "../core" }, + { "path": "../report" }, + { "path": "../repository" }, + { "path": "../scanner-sdk" }, + { "path": "../scanners" } + ], + "include": ["src/**/*.ts"] +} diff --git a/packages/github/package.json b/packages/github/package.json new file mode 100644 index 00000000..1632e2e6 --- /dev/null +++ b/packages/github/package.json @@ -0,0 +1,85 @@ +{ + "name": "@synsec/github", + "version": "0.2.0", + "private": true, + "type": "module", + "exports": { + ".": "./dist/index.js", + "./publisher": "./dist/publisher.js", + "./orchestrator": "./dist/orchestrator.js", + "./actions-runner": "./dist/actions-runner.js", + "./sarif-publisher": "./dist/sarif-publisher.js", + "./baseline": "./dist/baseline.js", + "./base-scan": "./dist/base-scan.js", + "./app": "./dist/app.js", + "./app-token-provider": "./dist/app-token-provider.js", + "./app-permissions": "./dist/app-permissions.js", + "./app-setup": "./dist/app-setup.js", + "./app-provisioning": "./dist/app-provisioning.js", + "./hosted-installation-ownership": "./dist/hosted-installation-ownership.js", + "./hosted-installation-reverification": "./dist/hosted-installation-reverification.js", + "./hosted-installation-reverification-sweep": "./dist/hosted-installation-reverification-sweep.js", + "./postgres-hosted-installation-ownership": "./dist/postgres-hosted-installation-ownership.js", + "./credential-rotation": "./dist/credential-rotation.js", + "./credential-reload": "./dist/credential-reload.js", + "./credential-reload-freshness": "./dist/credential-reload-freshness.js", + "./runtime-credentials": "./dist/runtime-credentials.js", + "./mounted-runtime-credentials": "./dist/mounted-runtime-credentials.js", + "./app-deployment": "./dist/app-deployment.js", + "./app-host-profile": "./dist/app-host-profile.js", + "./app-intake-host": "./dist/app-intake-host.js", + "./app-worker-host": "./dist/app-worker-host.js", + "./app-upgrade": "./dist/app-upgrade.js", + "./app-drain": "./dist/app-drain.js", + "./app-worker-drain": "./dist/app-worker-drain.js", + "./app-maintenance": "./dist/app-maintenance.js", + "./app-service-lifecycle": "./dist/app-service-lifecycle.js", + "./app-recovery": "./dist/app-recovery.js", + "./app-operator-status": "./dist/app-operator-status.js", + "./scanner-isolation-profile": "./dist/scanner-isolation-profile.js", + "./scanner-production-readiness": "./dist/scanner-production-readiness.js", + "./production-readiness": "./dist/production-readiness.js", + "./shared-state-contract": "./dist/shared-state-contract.js", + "./shared-state-conformance": "./dist/shared-state-conformance.js", + "./shared-state-conformance-runner": "./dist/shared-state-conformance-runner.js", + "./shared-state-evidence": "./dist/shared-state-evidence.js", + "./shared-runtime": "./dist/shared-runtime.js", + "./postgres-shared-state": "./dist/postgres-shared-state.js", + "./postgres-installation-store": "./dist/postgres-installation-store.js", + "./postgres-shared-backend": "./dist/postgres-shared-backend.js", + "./postgres-lease-observer": "./dist/postgres-lease-observer.js", + "./app-status": "./dist/app-status.js", + "./app-readiness-policy": "./dist/app-readiness-policy.js", + "./app-server": "./dist/app-server.js", + "./app-intake": "./dist/app-intake.js", + "./app-dispatch": "./dist/app-dispatch.js", + "./app-handler": "./dist/app-handler.js", + "./app-http": "./dist/app-http.js", + "./app-runtime": "./dist/app-runtime.js", + "./app-worker": "./dist/app-worker.js", + "./app-worker-runner": "./dist/app-worker-runner.js", + "./replay-store": "./dist/replay-store.js", + "./installation-store": "./dist/installation-store.js", + "./installation-sync": "./dist/installation-sync.js", + "./scan-queue": "./dist/scan-queue.js", + "./retention": "./dist/retention.js", + "./repository-acquisition": "./dist/repository-acquisition.js", + "./workspace-ownership": "./dist/workspace-ownership.js", + "./remediation-writer": "./dist/remediation-writer.js", + "./exact-tree-diff": "./dist/exact-tree-diff.js" + }, + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@synsec/config": "0.2.0", + "@synsec/core": "0.1.0", + "@synsec/engine": "0.2.0", + "@synsec/report": "0.2.0", + "@synsec/scanner-sdk": "0.1.0", + "@synsec/scanners": "0.1.0", + "@synsec/workflows": "0.2.0" + } +} diff --git a/packages/github/src/actions-runner.ts b/packages/github/src/actions-runner.ts new file mode 100644 index 00000000..85a962c8 --- /dev/null +++ b/packages/github/src/actions-runner.ts @@ -0,0 +1,125 @@ +import type { SynSecConfig } from "@synsec/config"; +import { runScanEngine, type ScanEngineOutcome } from "@synsec/engine"; +import type { SynSecReport } from "@synsec/report"; +import { scanGitHubBaseCommit } from "./base-scan.js"; +import { loadValidatedGitHubBaseline } from "./baseline.js"; +import { loadGitHubContext, type GitHubPullRequestContext } from "./index.js"; +import { + publishSynSecReportToGitHub, + type GitHubReportPublicationOptions, + type GitHubReportPublicationResult, +} from "./orchestrator.js"; +import { + publishGitHubSarif, + type GitHubSarifPublication, +} from "./sarif-publisher.js"; + +export interface GitHubActionsRepositoryScanOptions extends GitHubReportPublicationOptions { + config: SynSecConfig; + rootPath?: string; + baseline?: SynSecReport; + baselinePath?: string; + baselineExpectedCommitSha?: string; + autoBaseline?: boolean; + toolVersion?: string; + changedOnly?: boolean; + changedBase?: string; + publishSarif?: boolean; + scan?: typeof runScanEngine; +} + +export interface GitHubActionsRepositoryScanResult { + context: GitHubPullRequestContext; + outcome: ScanEngineOutcome; + publication: GitHubReportPublicationResult; + sarifPublication?: GitHubSarifPublication; + baselineSource?: "provided" | "file" | "base-scan"; +} + +/** + * Run the existing repository scanner engine for the current GitHub Actions checkout and publish + * the completed report as a check run. Pull-request contexts default to changed-file scanning; + * push/other contexts default to a full repository scan. Optional code-scanning publication uses + * the same completed report and fixed GitHub host. A local baseline path is size-bounded and + * commit-bound before it enters the scan engine. Auto-baseline mode scans the exact PR base commit + * from a temporary local worktree and never performs a remote fetch or live-target discovery. + */ +export async function runGitHubActionsRepositoryScan( + token: string, + options: GitHubActionsRepositoryScanOptions, +): Promise { + const env = options.env ?? process.env; + const context = await loadGitHubContext(env); + if (!context) { + throw new Error("Unable to resolve a valid GitHub repository and commit context for repository scanning."); + } + if (options.baseline && options.baselinePath) { + throw new Error("Provide either an in-memory baseline or baselinePath, not both."); + } + + const rootPath = options.rootPath ?? process.cwd(); + const scan = options.scan ?? runScanEngine; + let baseline: SynSecReport | undefined; + let baselineSource: GitHubActionsRepositoryScanResult["baselineSource"]; + if (options.baselinePath) { + baseline = await loadValidatedGitHubBaseline(options.baselinePath, context, { + expectedCommitSha: options.baselineExpectedCommitSha, + }); + baselineSource = "file"; + } else if (options.baseline) { + baseline = options.baseline; + baselineSource = "provided"; + } else if (options.autoBaseline && context.pullRequestNumber) { + const baseSha = context.baseSha?.trim(); + if (!baseSha) { + throw new Error("Automatic GitHub baseline generation requires the pull-request base SHA from GITHUB_EVENT_PATH."); + } + baseline = (await scanGitHubBaseCommit(options.config, rootPath, baseSha, { + toolVersion: options.toolVersion, + scan, + })).report; + baselineSource = "base-scan"; + } + + const changedOnly = options.changedOnly ?? Boolean(context.pullRequestNumber); + const changedBase = options.changedBase + ?? (changedOnly && context.baseRef ? `origin/${context.baseRef}` : undefined); + const outcome = await scan({ + rootPath, + config: options.config, + baseline, + toolVersion: options.toolVersion, + changedOnly, + changedBase, + }); + + if (!outcome.report.target.commitSha?.trim()) { + throw new Error("GitHub Actions repository scans must produce a report with a commit SHA before publication."); + } + + const publication = await publishSynSecReportToGitHub(outcome.report, token, { + env, + threshold: options.threshold, + onlyNewAnnotations: options.onlyNewAnnotations, + maxAnnotations: options.maxAnnotations, + apiVersion: options.apiVersion, + userAgent: options.userAgent, + fetch: options.fetch, + }); + + const sarifPublication = options.publishSarif + ? await publishGitHubSarif(outcome.report, context, token, { + apiVersion: options.apiVersion, + userAgent: options.userAgent, + fetch: options.fetch, + }) + : undefined; + + return { + context, + outcome, + publication, + ...(sarifPublication ? { sarifPublication } : {}), + ...(baselineSource ? { baselineSource } : {}), + }; +} diff --git a/packages/github/src/app-deployment.ts b/packages/github/src/app-deployment.ts new file mode 100644 index 00000000..68b8032f --- /dev/null +++ b/packages/github/src/app-deployment.ts @@ -0,0 +1,346 @@ +import { isAbsolute, relative, resolve } from "node:path"; +import type { GitHubWebhookSecret } from "./app.js"; + +export type GitHubAppTlsMode = "local" | "terminated-upstream" | "none"; +export type GitHubAppDeploymentIssueLevel = "error" | "warning"; +export type GitHubAppScannerProcessBoundary = "container" | "sandbox" | "host"; +export type GitHubAppScannerNetworkPolicy = "none" | "egress-filtered" | "host"; +export type GitHubAppScannerRepositoryFilesystem = "read-only" | "writable"; +export type GitHubAppStateBackendKind = "filesystem" | "shared-transactional"; + +export interface GitHubAppScannerIsolationConfig { + processBoundary: GitHubAppScannerProcessBoundary; + cpuLimit: boolean; + memoryLimit: boolean; + networkPolicy: GitHubAppScannerNetworkPolicy; + repositoryFilesystem: GitHubAppScannerRepositoryFilesystem; +} + +/** + * Atomicity guarantees a shared backend must provide before more than one SynSec runtime can + * safely coordinate webhook intake, repository authorization, and scan workers. + * + * These flags are an operator/backend integration contract only. SynSec's built-in filesystem + * stores do not implement or inherit these guarantees merely because a filesystem is networked. + */ +export interface GitHubAppSharedStateCapabilities { + atomicReplayClaim: boolean; + atomicQueueInsertion: boolean; + atomicQueueClaimWithFence: boolean; + compareAndSetLeaseRenewal: boolean; + fencedQueueTransitions: boolean; + transactionalInstallationState: boolean; + sharedAuthorizationState: boolean; +} + +export type GitHubAppSharedStateCapability = keyof GitHubAppSharedStateCapabilities; + +export interface GitHubAppSharedStateCapabilityAssessment { + complete: boolean; + missing: GitHubAppSharedStateCapability[]; +} + +export const REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES = [ + "atomicReplayClaim", + "atomicQueueInsertion", + "atomicQueueClaimWithFence", + "compareAndSetLeaseRenewal", + "fencedQueueTransitions", + "transactionalInstallationState", + "sharedAuthorizationState", +] as const satisfies readonly GitHubAppSharedStateCapability[]; + +export interface GitHubAppStateBackendConfig { + kind: GitHubAppStateBackendKind; + capabilities?: GitHubAppSharedStateCapabilities; +} + +export interface GitHubAppDeploymentConfig { + appId: number | string; + privateKey: string; + webhookSecret: GitHubWebhookSecret; + listenHost: string; + tlsMode: GitHubAppTlsMode; + stateDirectory: string; + workspaceDirectory: string; + scannerIsolation?: GitHubAppScannerIsolationConfig; + /** Fail deployment readiness when scanner isolation is absent or incomplete. */ + requireScannerIsolation?: boolean; + /** Number of application replicas that can concurrently access durable GitHub App state. */ + replicaCount?: number; + /** Durable-state backend contract. Omitted means the built-in single-host filesystem backend. */ + stateBackend?: GitHubAppStateBackendConfig; +} + +export interface GitHubAppDeploymentIssue { + level: GitHubAppDeploymentIssueLevel; + code: + | "invalid-app-id" + | "invalid-private-key" + | "invalid-webhook-secret-set" + | "weak-webhook-secret" + | "invalid-listen-host" + | "plaintext-public-listener" + | "relative-state-directory" + | "relative-workspace-directory" + | "overlapping-runtime-directories" + | "scanner-isolation-missing" + | "scanner-process-unisolated" + | "scanner-resource-limits-missing" + | "scanner-network-unrestricted" + | "scanner-repository-writable" + | "invalid-replica-count" + | "shared-state-required" + | "shared-state-capabilities-incomplete"; + message: string; + /** Present only for shared-state capability failures; contains names, never backend secrets. */ + missingCapabilities?: GitHubAppSharedStateCapability[]; +} + +export interface GitHubAppDeploymentReadiness { + ready: boolean; + issues: GitHubAppDeploymentIssue[]; +} + +const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]); +const MAX_WEBHOOK_SECRET_BYTES = 4096; +const MAX_REPLICA_COUNT = 1000; + +function isPositiveAppId(value: number | string): boolean { + if (typeof value === "number") return Number.isSafeInteger(value) && value > 0; + return /^[1-9]\d*$/.test(value.trim()); +} + +function looksLikePemPrivateKey(value: string): boolean { + const trimmed = value.trim(); + if (trimmed.startsWith("-----BEGIN RSA PRIVATE KEY-----")) { + return trimmed.endsWith("-----END RSA PRIVATE KEY-----"); + } + if (trimmed.startsWith("-----BEGIN PRIVATE KEY-----")) { + return trimmed.endsWith("-----END PRIVATE KEY-----"); + } + return false; +} + +function directoriesOverlap(left: string, right: string): boolean { + const resolvedLeft = resolve(left); + const resolvedRight = resolve(right); + if (resolvedLeft === resolvedRight) return true; + + const leftToRight = relative(resolvedLeft, resolvedRight); + const rightToLeft = relative(resolvedRight, resolvedLeft); + const isDescendant = (value: string): boolean => Boolean(value) && !value.startsWith("..") && !isAbsolute(value); + return isDescendant(leftToRight) || isDescendant(rightToLeft); +} + +function isolationLevel(config: GitHubAppDeploymentConfig): GitHubAppDeploymentIssueLevel { + return config.requireScannerIsolation ? "error" : "warning"; +} + +function validateWebhookSecrets(config: GitHubAppDeploymentConfig, issues: GitHubAppDeploymentIssue[]): void { + const secrets = typeof config.webhookSecret === "string" ? [config.webhookSecret] : [...config.webhookSecret]; + if (secrets.length < 1 || secrets.length > 2 || new Set(secrets.map((secret) => secret.trim())).size !== secrets.length) { + issues.push({ + level: "error", + code: "invalid-webhook-secret-set", + message: "Webhook verification requires one secret, or exactly two distinct secrets during rotation overlap.", + }); + return; + } + if (secrets.some((secret) => { + const bytes = Buffer.byteLength(secret, "utf8"); + return bytes < 32 || bytes > MAX_WEBHOOK_SECRET_BYTES; + })) { + issues.push({ + level: "error", + code: "weak-webhook-secret", + message: `Every webhook secret must contain between 32 and ${MAX_WEBHOOK_SECRET_BYTES} bytes.`, + }); + } +} + +function validateScannerIsolation(config: GitHubAppDeploymentConfig, issues: GitHubAppDeploymentIssue[]): void { + const level = isolationLevel(config); + const isolation = config.scannerIsolation; + if (!isolation) { + issues.push({ + level, + code: "scanner-isolation-missing", + message: "Scanner process/resource/network/filesystem isolation has not been declared for this deployment.", + }); + return; + } + + if (isolation.processBoundary === "host") { + issues.push({ + level, + code: "scanner-process-unisolated", + message: "Scanner execution must use a container or equivalent sandbox boundary for production isolation.", + }); + } + if (!isolation.cpuLimit || !isolation.memoryLimit) { + issues.push({ + level, + code: "scanner-resource-limits-missing", + message: "Scanner execution must declare both CPU and memory limits.", + }); + } + if (isolation.networkPolicy === "host") { + issues.push({ + level, + code: "scanner-network-unrestricted", + message: "Scanner execution must disable network access or use an explicit egress-filtered network policy.", + }); + } + if (isolation.repositoryFilesystem === "writable") { + issues.push({ + level, + code: "scanner-repository-writable", + message: "Scanner execution should mount repository source read-only; writable scratch space must be separate.", + }); + } +} + +/** + * Return the exact missing guarantees from a declared shared-state capability set. + * + * This is intentionally deterministic and secret-free so deployment tooling can render actionable + * readiness diagnostics without parsing human messages or receiving backend credentials. + */ +export function assessGitHubAppSharedStateCapabilities( + capabilities: GitHubAppSharedStateCapabilities | undefined, +): GitHubAppSharedStateCapabilityAssessment { + const missing = REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES.filter( + (capability) => capabilities?.[capability] !== true, + ); + return { complete: missing.length === 0, missing: [...missing] }; +} + +function validateStateBackend(config: GitHubAppDeploymentConfig, issues: GitHubAppDeploymentIssue[]): void { + const replicaCount = config.replicaCount ?? 1; + if (!Number.isSafeInteger(replicaCount) || replicaCount < 1 || replicaCount > MAX_REPLICA_COUNT) { + issues.push({ + level: "error", + code: "invalid-replica-count", + message: `GitHub App replica count must be an integer between 1 and ${MAX_REPLICA_COUNT}.`, + }); + return; + } + + if (replicaCount === 1) return; + + if (!config.stateBackend || config.stateBackend.kind !== "shared-transactional") { + issues.push({ + level: "error", + code: "shared-state-required", + message: "Multiple GitHub App replicas require a shared transactional state backend; the filesystem backend is single-host only.", + }); + return; + } + + const assessment = assessGitHubAppSharedStateCapabilities(config.stateBackend.capabilities); + if (!assessment.complete) { + issues.push({ + level: "error", + code: "shared-state-capabilities-incomplete", + message: `The shared state backend is missing ${assessment.missing.length} required transactional coordination guarantee(s).`, + missingCapabilities: assessment.missing, + }); + } +} + +/** + * Validate operator-controlled GitHub App deployment settings before a hosted runtime starts. + * + * The result deliberately contains only categorical diagnostics. Secret values and filesystem + * contents are never echoed into messages, making the result safe to surface in startup logs. + * Scanner-isolation fields describe controls enforced by the surrounding container/sandbox runtime; + * this preflight validates that contract and does not pretend Node child processes implement it. + * Shared-state fields similarly validate a backend integration contract and do not make the + * built-in filesystem stores transactional or multi-host safe. + */ +export function validateGitHubAppDeployment( + config: GitHubAppDeploymentConfig, +): GitHubAppDeploymentReadiness { + const issues: GitHubAppDeploymentIssue[] = []; + const host = config.listenHost.trim().toLowerCase().replace(/^\[|\]$/g, ""); + + if (!isPositiveAppId(config.appId)) { + issues.push({ + level: "error", + code: "invalid-app-id", + message: "GitHub App ID must be a positive integer.", + }); + } + + if (!looksLikePemPrivateKey(config.privateKey)) { + issues.push({ + level: "error", + code: "invalid-private-key", + message: "GitHub App private key must be a PEM-encoded private key.", + }); + } + + validateWebhookSecrets(config, issues); + + if (!host || host === "*" || /[\s/]/.test(host)) { + issues.push({ + level: "error", + code: "invalid-listen-host", + message: "Listener host must be a host name or IP address, not a URL, wildcard, or path.", + }); + } else if (config.tlsMode === "none" && !LOOPBACK_HOSTS.has(host)) { + issues.push({ + level: "error", + code: "plaintext-public-listener", + message: "A non-loopback webhook listener requires local TLS or explicit upstream TLS termination.", + }); + } + + if (!isAbsolute(config.stateDirectory)) { + issues.push({ + level: "error", + code: "relative-state-directory", + message: "Durable state directory must be an absolute path.", + }); + } + + if (!isAbsolute(config.workspaceDirectory)) { + issues.push({ + level: "error", + code: "relative-workspace-directory", + message: "Repository workspace directory must be an absolute path.", + }); + } + + if ( + isAbsolute(config.stateDirectory) && + isAbsolute(config.workspaceDirectory) && + directoriesOverlap(config.stateDirectory, config.workspaceDirectory) + ) { + issues.push({ + level: "error", + code: "overlapping-runtime-directories", + message: "Durable state and repository workspaces must use separate, non-nested directory trees.", + }); + } + + validateScannerIsolation(config, issues); + validateStateBackend(config, issues); + + return { + ready: !issues.some((issue) => issue.level === "error"), + issues, + }; +} + +export function assertGitHubAppDeploymentReady(config: GitHubAppDeploymentConfig): void { + const readiness = validateGitHubAppDeployment(config); + if (readiness.ready) return; + + const codes = readiness.issues + .filter((issue) => issue.level === "error") + .map((issue) => issue.code) + .join(", "); + throw new Error(`GitHub App deployment configuration is not ready: ${codes}`); +} diff --git a/packages/github/src/app-dispatch.ts b/packages/github/src/app-dispatch.ts new file mode 100644 index 00000000..0298e437 --- /dev/null +++ b/packages/github/src/app-dispatch.ts @@ -0,0 +1,68 @@ +import type { GitHubAppWebhookIntakeResult } from "./app-intake.js"; +import type { GitHubScanJob, GitHubScanJobInput } from "./scan-queue.js"; + +export interface GitHubInstallationAuthorizer { + isRepositoryAllowed(installationId: number, repository: string): Promise; +} + +export interface GitHubScanJobEnqueuer { + enqueue(input: GitHubScanJobInput): Promise; +} + +export type GitHubAppDispatchResult = + | { status: "ignored"; reason: "duplicate" | "non_scan_event" } + | { status: "rejected"; reason: "installation_not_authorized" } + | { status: "queued"; job: GitHubScanJob }; + +/** + * Apply the final hosted-App authorization gate before durable queueing. + * + * This function consumes only a verified/deduplicated intake result. It never follows + * payload URLs and requires durable installation state to authorize the normalized + * owner/name repository before constructing a commit-pinned queue descriptor. + */ +export async function dispatchGitHubAppWebhookScan(input: { + intake: GitHubAppWebhookIntakeResult; + installationStore: GitHubInstallationAuthorizer; + queue: GitHubScanJobEnqueuer; +}): Promise { + const { intake } = input; + if (intake.duplicate) return { status: "ignored", reason: "duplicate" }; + if (!intake.shouldScan) return { status: "ignored", reason: "non_scan_event" }; + + const webhook = intake.webhook; + if (!webhook.installationId || !webhook.repository || !webhook.headSha || !webhook.deliveryId) { + throw new Error("Scan-eligible GitHub App webhook is missing normalized queue identity."); + } + if (!await input.installationStore.isRepositoryAllowed(webhook.installationId, webhook.repository)) { + return { status: "rejected", reason: "installation_not_authorized" }; + } + + if (webhook.event === "pull_request") { + if (!webhook.baseSha || !webhook.pullRequestNumber) { + throw new Error("Scan-eligible pull request webhook is missing base commit or pull request identity."); + } + const job = await input.queue.enqueue({ + deliveryId: webhook.deliveryId, + installationId: webhook.installationId, + repository: webhook.repository, + headSha: webhook.headSha, + event: "pull_request", + baseSha: webhook.baseSha, + pullRequestNumber: webhook.pullRequestNumber, + }); + return { status: "queued", job }; + } + + if (webhook.event !== "push") { + throw new Error("Only push and pull_request webhooks may reach scan dispatch."); + } + const job = await input.queue.enqueue({ + deliveryId: webhook.deliveryId, + installationId: webhook.installationId, + repository: webhook.repository, + headSha: webhook.headSha, + event: "push", + }); + return { status: "queued", job }; +} diff --git a/packages/github/src/app-drain.ts b/packages/github/src/app-drain.ts new file mode 100644 index 00000000..7d0e548f --- /dev/null +++ b/packages/github/src/app-drain.ts @@ -0,0 +1,121 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; + +const DEFAULT_DRAIN_TIMEOUT_MS = 30_000; +const MIN_DRAIN_TIMEOUT_MS = 1_000; +const MAX_DRAIN_TIMEOUT_MS = 120_000; + +export interface SynSecGitHubAppDrainStatus { + acceptingWebhooks: boolean; + activeWebhookRequests: number; +} + +export interface SynSecGitHubAppDrainController { + readonly webhookHandler: (request: IncomingMessage, response: ServerResponse) => Promise; + beginDrain(): SynSecGitHubAppDrainStatus; + resumeAdmission(): SynSecGitHubAppDrainStatus; + status(): SynSecGitHubAppDrainStatus; + waitForDrained(timeoutMs?: number): Promise; +} + +function boundedTimeout(value: number | undefined): number { + const resolved = value ?? DEFAULT_DRAIN_TIMEOUT_MS; + if (!Number.isSafeInteger(resolved) || resolved < MIN_DRAIN_TIMEOUT_MS || resolved > MAX_DRAIN_TIMEOUT_MS) { + throw new Error( + `GitHub App drain timeout must be between ${MIN_DRAIN_TIMEOUT_MS} and ${MAX_DRAIN_TIMEOUT_MS} milliseconds.`, + ); + } + return resolved; +} + +function sendDraining(response: ServerResponse): void { + const body = '{"status":"draining"}\n'; + response.statusCode = 503; + response.setHeader("content-type", "application/json; charset=utf-8"); + response.setHeader("content-length", Buffer.byteLength(body)); + response.setHeader("cache-control", "no-store"); + response.setHeader("x-content-type-options", "nosniff"); + response.setHeader("retry-after", "1"); + response.end(body); +} + +/** + * Wrap a GitHub App webhook handler with an enforced local admission-drain boundary. + * + * beginDrain() prevents new webhook requests from entering the wrapped handler while existing + * requests are allowed to finish. Rejected requests receive a bounded aggregate-only 503 response + * so GitHub can retry them; payloads, repository identities, delivery ids, and backend errors are + * never reflected. waitForDrained() observes only the requests admitted through this controller. + * It does not cancel workers, revoke queue leases, stop a process, or prove fleet-wide drainage. + */ +export function createSynSecGitHubAppDrainController( + handler: (request: IncomingMessage, response: ServerResponse) => Promise, +): SynSecGitHubAppDrainController { + if (typeof handler !== "function") throw new Error("GitHub App webhook handler is required."); + + let acceptingWebhooks = true; + let activeWebhookRequests = 0; + const drainedWaiters = new Set<() => void>(); + + const currentStatus = (): SynSecGitHubAppDrainStatus => ({ + acceptingWebhooks, + activeWebhookRequests, + }); + + const notifyDrained = (): void => { + if (activeWebhookRequests !== 0) return; + for (const resolve of drainedWaiters) resolve(); + drainedWaiters.clear(); + }; + + const webhookHandler = async (request: IncomingMessage, response: ServerResponse): Promise => { + if (!acceptingWebhooks) { + sendDraining(response); + return; + } + + activeWebhookRequests += 1; + try { + await handler(request, response); + } finally { + activeWebhookRequests -= 1; + notifyDrained(); + } + }; + + return { + webhookHandler, + beginDrain() { + acceptingWebhooks = false; + return currentStatus(); + }, + resumeAdmission() { + acceptingWebhooks = true; + return currentStatus(); + }, + status: currentStatus, + async waitForDrained(timeoutMs?: number): Promise { + if (activeWebhookRequests === 0) return; + const timeout = boundedTimeout(timeoutMs); + await new Promise((resolve, reject) => { + let settled = false; + let timer: NodeJS.Timeout; + const onDrained = (): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + drainedWaiters.delete(onDrained); + resolve(); + }; + drainedWaiters.add(onDrained); + timer = setTimeout(() => { + if (settled) return; + settled = true; + drainedWaiters.delete(onDrained); + reject(new Error("GitHub App webhook admission drain did not complete before the configured timeout.")); + }, timeout); + timer.unref?.(); + if (activeWebhookRequests === 0) onDrained(); + }); + }, + }; +} diff --git a/packages/github/src/app-handler.ts b/packages/github/src/app-handler.ts new file mode 100644 index 00000000..74036b6f --- /dev/null +++ b/packages/github/src/app-handler.ts @@ -0,0 +1,109 @@ +import { sanitizeOperationalText } from "@synsec/scanner-sdk"; +import { + intakeGitHubAppWebhook, + type GitHubWebhookReplayClaimer, +} from "./app-intake.js"; +import type { GitHubWebhookSecret } from "./app.js"; +import { + dispatchGitHubAppWebhookScan, + type GitHubScanJobEnqueuer, +} from "./app-dispatch.js"; +import { + synchronizeVerifiedGitHubInstallationWebhook, + type GitHubInstallationStateStore, +} from "./installation-sync.js"; +import type { GitHubScanJob } from "./scan-queue.js"; + +export interface GitHubAppInstallationStore extends GitHubInstallationStateStore { + isRepositoryAllowed(installationId: number, repository: string): Promise; +} + +export interface GitHubWebhookReplayManager extends GitHubWebhookReplayClaimer { + release(deliveryId: string, receivedAt: string): Promise; +} + +export type GitHubAppWebhookHandleResult = + | { status: "ignored"; reason: "duplicate" | "non_scan_event" } + | { status: "rejected"; reason: "installation_not_authorized" } + | { status: "queued"; job: GitHubScanJob } + | { status: "installation_updated"; installationId: number } + | { status: "installation_removed"; installationId: number; existed: boolean }; + +function safeError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return sanitizeOperationalText(message, 1000) || "GitHub App webhook processing failed."; +} + +/** + * Execute the durable hosted-App intake boundary for one webhook delivery. + * + * The order is deliberate: verify/normalize -> replay claim -> installation bookkeeping + * or authorization-gated scan dispatch. Duplicate authenticated deliveries never mutate + * installation state or enqueue work. Installation-management events never trigger scans. + * If durable processing fails after an accepted replay claim, that exact unexpired claim is + * released before the error is propagated so GitHub can retry rather than losing the delivery. + * Operational failures are sanitized before they cross this hosted boundary so queue/database + * credentials or credential-bearing URLs cannot be forwarded into HTTP/operator logging hooks. + */ +export async function handleGitHubAppWebhook(input: { + body: string | Uint8Array; + signatureHeader?: string; + webhookSecret: GitHubWebhookSecret; + eventName: string; + deliveryId: string; + replayStore: GitHubWebhookReplayManager; + installationStore: GitHubAppInstallationStore; + queue: GitHubScanJobEnqueuer; + now?: number; +}): Promise { + const deliveryId = input.deliveryId.trim(); + const intake = await intakeGitHubAppWebhook({ + body: input.body, + signatureHeader: input.signatureHeader, + webhookSecret: input.webhookSecret, + eventName: input.eventName, + deliveryId, + replayStore: input.replayStore, + }); + + if (intake.duplicate) return { status: "ignored", reason: "duplicate" }; + + try { + if (intake.webhook.event === "installation" || intake.webhook.event === "installation_repositories") { + const result = await synchronizeVerifiedGitHubInstallationWebhook({ + body: input.body, + signatureHeader: input.signatureHeader, + webhookSecret: input.webhookSecret, + eventName: input.eventName, + store: input.installationStore, + ...(input.now !== undefined ? { now: input.now } : {}), + }); + if (result.status === "removed") { + return { + status: "installation_removed", + installationId: result.installationId, + existed: result.existed, + }; + } + return { status: "installation_updated", installationId: result.record.installationId }; + } + + return await dispatchGitHubAppWebhookScan({ + intake, + installationStore: input.installationStore, + queue: input.queue, + }); + } catch (error) { + const processingError = safeError(error); + let released: boolean; + try { + released = await input.replayStore.release(deliveryId, intake.replayReceivedAt); + } catch (releaseError) { + throw new Error(`${processingError} Replay claim release failed: ${safeError(releaseError)}`); + } + if (!released) { + throw new Error(`${processingError} Replay claim could not be released safely for retry.`); + } + throw new Error(processingError); + } +} diff --git a/packages/github/src/app-host-profile.ts b/packages/github/src/app-host-profile.ts new file mode 100644 index 00000000..d6a74aff --- /dev/null +++ b/packages/github/src/app-host-profile.ts @@ -0,0 +1,165 @@ +import { isAbsolute, relative, resolve } from "node:path"; + +const MAX_IDENTIFIER_LENGTH = 128; +const MAX_ENVIRONMENT_NAME_LENGTH = 64; +const MAX_COMMAND_LENGTH = 256; +const MAX_IMAGE_LENGTH = 512; +const MAX_REPLICA_COUNT = 1000; +const SAFE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:@/+\-]*$/; +const SAFE_ENVIRONMENT_NAME = /^[A-Z][A-Z0-9_]*$/; +const ALLOWED_KEYS = new Set([ + "releaseId", + "replicaId", + "replicaCount", + "appId", + "credentialDirectory", + "postgresUrlEnvironment", + "listenHost", + "port", + "tlsMode", + "workspaceDirectory", + "scannerRuntimeCommand", + "scannerImage", + "operatorStatusPath", +]); + +export interface GitHubAppHostProfile { + releaseId: string; + replicaId: string; + replicaCount: number; + appId: number; + credentialDirectory: string; + postgresUrlEnvironment: string; + listenHost: string; + port: number; + tlsMode: "local" | "terminated-upstream"; + workspaceDirectory: string; + scannerRuntimeCommand: string; + scannerImage: string; + operatorStatusPath: string; +} + +export interface NormalizedGitHubAppHostProfile extends GitHubAppHostProfile { + version: 1; + interpretation: "secret-free-host-wiring-contract-not-runtime-readiness"; +} + +function identifier(value: unknown, label: string): string { + if (typeof value !== "string") throw new Error(`${label} must be a string.`); + const normalized = value.trim(); + if (!normalized || normalized.length > MAX_IDENTIFIER_LENGTH || !SAFE_IDENTIFIER.test(normalized)) { + throw new Error(`${label} must be a bounded non-secret identifier.`); + } + return normalized; +} + +function positiveInteger(value: unknown, label: string, maximum: number): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > maximum) { + throw new Error(`${label} must be an integer between 1 and ${maximum}.`); + } + return value; +} + +function absoluteDirectory(value: unknown, label: string): string { + if (typeof value !== "string" || !isAbsolute(value) || value.includes("\0")) { + throw new Error(`${label} must be an absolute path.`); + } + return resolve(value); +} + +function pathsOverlap(left: string, right: string): boolean { + if (left === right) return true; + const child = (from: string, to: string): boolean => { + const value = relative(from, to); + return Boolean(value) && !value.startsWith("..") && !isAbsolute(value); + }; + return child(left, right) || child(right, left); +} + +function environmentName(value: unknown): string { + if (typeof value !== "string") throw new Error("PostgreSQL URL environment reference must be a string."); + const normalized = value.trim(); + if (!normalized || normalized.length > MAX_ENVIRONMENT_NAME_LENGTH || !SAFE_ENVIRONMENT_NAME.test(normalized)) { + throw new Error("PostgreSQL URL environment reference must be a bounded environment-variable name, not a connection string."); + } + return normalized; +} + +function listenHost(value: unknown): string { + if (typeof value !== "string") throw new Error("GitHub App host listener must be a string."); + const normalized = value.trim().toLowerCase().replace(/^\[|\]$/g, ""); + if (!normalized || normalized === "*" || normalized.length > 255 || /[\s/?#\u0000-\u001f\u007f]/.test(normalized)) { + throw new Error("GitHub App host listener must be a bounded host name or IP address."); + } + return normalized; +} + +function runtimeCommand(value: unknown): string { + if (typeof value !== "string") throw new Error("Scanner runtime command must be a string."); + const normalized = value.trim(); + if (!normalized || normalized.length > MAX_COMMAND_LENGTH || /[\s\u0000-\u001f\u007f]/.test(normalized)) { + throw new Error("Scanner runtime command must be one bounded command token."); + } + return normalized; +} + +function immutableImage(value: unknown): string { + if (typeof value !== "string") throw new Error("Scanner image must be a string."); + const normalized = value.trim(); + if (!normalized || normalized.length > MAX_IMAGE_LENGTH || /[\s\u0000-\u001f\u007f]/.test(normalized) || !/@sha256:[a-f0-9]{64}$/i.test(normalized)) { + throw new Error("Scanner image must be a bounded immutable sha256 digest reference."); + } + return normalized; +} + +function statusPath(value: unknown): string { + if (typeof value !== "string") throw new Error("Operator status path must be a string."); + const normalized = value.trim(); + if (!normalized.startsWith("/") || normalized.length > 128 || normalized.includes("?") || normalized.includes("#") || /[\u0000-\u001f\u007f]/.test(normalized)) { + throw new Error("Operator status path must be a bounded absolute path."); + } + return normalized; +} + +/** + * Validate the non-secret wiring that hosting code may load from a declarative JSON file. + * + * The object is exact-keyed: unknown keys fail closed so fields such as privateKey, webhookSecret, + * databaseUrl, tokens, or arbitrary metadata cannot silently become part of the deployment profile. + * Actual credential files and the PostgreSQL connection value remain outside this object and must be + * resolved by the trusted host from the mounted-credential and environment/secret-manager boundaries. + */ +export function parseGitHubAppHostProfile(value: unknown): NormalizedGitHubAppHostProfile { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("GitHub App host profile must be an object."); + const object = value as Record; + if (Object.keys(object).some((key) => !ALLOWED_KEYS.has(key)) || Object.keys(object).length !== ALLOWED_KEYS.size) { + throw new Error("GitHub App host profile must contain exactly the supported non-secret fields."); + } + + const credentialDirectory = absoluteDirectory(object.credentialDirectory, "GitHub App credential directory"); + const workspaceDirectory = absoluteDirectory(object.workspaceDirectory, "GitHub App workspace directory"); + if (pathsOverlap(credentialDirectory, workspaceDirectory)) { + throw new Error("GitHub App credential and repository workspace directories must be separate, non-nested trees."); + } + if (object.tlsMode !== "local" && object.tlsMode !== "terminated-upstream") { + throw new Error("GitHub App production host TLS mode must be local or terminated-upstream."); + } + + return { + version: 1, + releaseId: identifier(object.releaseId, "GitHub App release id"), + replicaId: identifier(object.replicaId, "GitHub App replica id"), + replicaCount: positiveInteger(object.replicaCount, "GitHub App replica count", MAX_REPLICA_COUNT), + appId: positiveInteger(object.appId, "GitHub App id", Number.MAX_SAFE_INTEGER), + credentialDirectory, + postgresUrlEnvironment: environmentName(object.postgresUrlEnvironment), + listenHost: listenHost(object.listenHost), + port: positiveInteger(object.port, "GitHub App listener port", 65535), + tlsMode: object.tlsMode, + workspaceDirectory, + scannerRuntimeCommand: runtimeCommand(object.scannerRuntimeCommand), + scannerImage: immutableImage(object.scannerImage), + operatorStatusPath: statusPath(object.operatorStatusPath), + interpretation: "secret-free-host-wiring-contract-not-runtime-readiness", + }; +} diff --git a/packages/github/src/app-http.ts b/packages/github/src/app-http.ts new file mode 100644 index 00000000..616b8336 --- /dev/null +++ b/packages/github/src/app-http.ts @@ -0,0 +1,170 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { sanitizeOperationalText } from "@synsec/scanner-sdk"; +import type { GitHubWebhookSecret } from "./app.js"; +import { + handleGitHubAppWebhook, + type GitHubAppInstallationStore, + type GitHubAppWebhookHandleResult, + type GitHubWebhookReplayManager, +} from "./app-handler.js"; +import type { GitHubScanJobEnqueuer } from "./app-dispatch.js"; + +const MAX_WEBHOOK_BODY_BYTES = 10 * 1024 * 1024; +const DEFAULT_PATH = "/github/webhooks"; + +export type GitHubWebhookSecretSource = GitHubWebhookSecret | (() => GitHubWebhookSecret); + +export interface GitHubAppWebhookHttpOptions { + /** Static secret set or memory-only supplier resolved immediately before signature verification. */ + webhookSecret: GitHubWebhookSecretSource; + replayStore: GitHubWebhookReplayManager; + installationStore: GitHubAppInstallationStore; + queue: GitHubScanJobEnqueuer; + path?: string; + onError?: (error: unknown) => void; +} + +function header(request: IncomingMessage, name: string): string | undefined { + const value = request.headers[name]; + if (Array.isArray(value)) return value[0]?.trim() || undefined; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function sendJson(response: ServerResponse, statusCode: number, payload: Record): void { + const body = `${JSON.stringify(payload)}\n`; + response.statusCode = statusCode; + response.setHeader("content-type", "application/json; charset=utf-8"); + response.setHeader("content-length", Buffer.byteLength(body)); + response.setHeader("cache-control", "no-store"); + response.end(body); +} + +function resultStatus(result: GitHubAppWebhookHandleResult): number { + return result.status === "queued" ? 202 : 200; +} + +function publicResult(result: GitHubAppWebhookHandleResult): Record { + if (result.status === "queued") return { status: "queued" }; + if (result.status === "installation_updated") return { status: "installation_updated" }; + if (result.status === "installation_removed") return { status: "installation_removed" }; + if (result.status === "rejected") return { status: "ignored", reason: "installation_not_authorized" }; + return { status: "ignored", reason: result.reason }; +} + +function safeCallbackError(error: unknown): Error { + const message = error instanceof Error ? error.message : String(error); + return new Error(sanitizeOperationalText(message, 1000) || "GitHub App webhook processing failed."); +} + +function validateWebhookSecret(value: GitHubWebhookSecret): GitHubWebhookSecret { + const values = typeof value === "string" ? [value] : [...value]; + if (values.length < 1 || values.length > 2 || values.some((secret) => typeof secret !== "string" || !secret)) { + throw new Error("GitHub App webhook secret source returned an invalid secret set."); + } + return value; +} + +function webhookSecretSupplier(value: GitHubWebhookSecretSource): () => GitHubWebhookSecret { + if (typeof value === "function") return () => validateWebhookSecret(value()); + const fixed = validateWebhookSecret(value); + return () => fixed; +} + +async function readBoundedBody(request: IncomingMessage): Promise { + const declared = header(request, "content-length"); + if (declared !== undefined) { + const length = Number(declared); + if (!Number.isSafeInteger(length) || length < 0) throw new Error("invalid_content_length"); + if (length > MAX_WEBHOOK_BODY_BYTES) throw new Error("body_too_large"); + } + + const chunks: Buffer[] = []; + let bytes = 0; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytes += buffer.byteLength; + if (bytes > MAX_WEBHOOK_BODY_BYTES) throw new Error("body_too_large"); + chunks.push(buffer); + } + return Buffer.concat(chunks, bytes); +} + +/** + * Create a framework-free GitHub App webhook endpoint suitable for mounting behind HTTPS. + * + * The handler accepts only POST requests to one configured path, bounds the raw body before + * signature processing, requires GitHub's event/delivery/signature headers, and delegates to the + * replay-protected durable App handler. A webhook-secret supplier, when configured, is resolved + * immediately before every verification so a validated memory-only credential source can atomically + * rotate one/two-secret overlap generations without rebuilding the HTTP handler. Supplier failures + * fail the request closed. Internal error details are never returned to the caller. Errors forwarded + * to the optional operator callback are sanitized and bounded so hosted logging integrations cannot + * accidentally persist credentials from backend/process failures. A durable-processing failure + * returns 500 only after the App handler has attempted to release the exact replay claim, allowing + * GitHub to retry the delivery instead of losing it. + */ +export function createGitHubAppWebhookHttpHandler(options: GitHubAppWebhookHttpOptions) { + const path = options.path?.trim() || DEFAULT_PATH; + if (!path.startsWith("/") || path.includes("?") || path.includes("#")) { + throw new Error("GitHub App webhook path must be an absolute path without query or fragment components."); + } + const getWebhookSecret = webhookSecretSupplier(options.webhookSecret); + + return async function githubAppWebhookHttpHandler( + request: IncomingMessage, + response: ServerResponse, + ): Promise { + const requestPath = (request.url ?? "").split("?", 1)[0]; + if (requestPath !== path) { + sendJson(response, 404, { status: "not_found" }); + return; + } + if (request.method !== "POST") { + response.setHeader("allow", "POST"); + sendJson(response, 405, { status: "method_not_allowed" }); + return; + } + const contentType = header(request, "content-type")?.toLowerCase(); + if (!contentType?.startsWith("application/json")) { + sendJson(response, 415, { status: "unsupported_media_type" }); + return; + } + + const signatureHeader = header(request, "x-hub-signature-256"); + const eventName = header(request, "x-github-event"); + const deliveryId = header(request, "x-github-delivery"); + if (!signatureHeader || !eventName || !deliveryId) { + sendJson(response, 400, { status: "bad_request" }); + return; + } + + let body: Buffer; + try { + body = await readBoundedBody(request); + } catch (error) { + if (error instanceof Error && error.message === "body_too_large") { + sendJson(response, 413, { status: "payload_too_large" }); + return; + } + sendJson(response, 400, { status: "bad_request" }); + return; + } + + try { + const result = await handleGitHubAppWebhook({ + body, + signatureHeader, + webhookSecret: getWebhookSecret(), + eventName, + deliveryId, + replayStore: options.replayStore, + installationStore: options.installationStore, + queue: options.queue, + }); + sendJson(response, resultStatus(result), publicResult(result)); + } catch (error) { + options.onError?.(safeCallbackError(error)); + sendJson(response, 500, { status: "error" }); + } + }; +} diff --git a/packages/github/src/app-intake-host.ts b/packages/github/src/app-intake-host.ts new file mode 100644 index 00000000..4220d133 --- /dev/null +++ b/packages/github/src/app-intake-host.ts @@ -0,0 +1,127 @@ +import type { GitHubAppRuntimeCredentialSnapshot, GitHubAppRuntimeCredentialStatus } from "./runtime-credentials.js"; +import { createGitHubAppRuntimeCredentialSource } from "./runtime-credentials.js"; +import { loadMountedGitHubAppRuntimeCredentialSnapshot } from "./mounted-runtime-credentials.js"; +import { parseGitHubAppHostProfile, type NormalizedGitHubAppHostProfile } from "./app-host-profile.js"; +import { createSynSecGitHubAppDrainController, type SynSecGitHubAppDrainController } from "./app-drain.js"; +import { createGitHubAppWebhookHttpHandler } from "./app-http.js"; +import { + createGitHubAppServer, + type GitHubAppServer, + type GitHubAppServerAddress, + type GitHubAppServerTlsOptions, +} from "./app-server.js"; +import { + buildSynSecGitHubPostgresBackendContract, + createSynSecGitHubPostgresSharedStores, + migrateSynSecGitHubPostgresBackend, +} from "./postgres-shared-backend.js"; +import type { PostgresPoolLike, PostgresGitHubSharedStateOptions } from "./postgres-shared-state.js"; +import { assessGitHubAppSharedStateConformanceEvidence } from "./shared-state-evidence.js"; + +export interface SynSecGitHubAppIntakeHostOptions { + /** Exact-keyed non-secret deployment profile. */ + profile: unknown; + /** Caller-owned PostgreSQL pool. Connection material never enters the profile or returned status. */ + pool: PostgresPoolLike; + /** Portable report produced by the real-backend canonical conformance suite for this adapter build. */ + conformanceReport: unknown; + /** Optional local TLS material owned by the trusted hosting process. */ + tls?: GitHubAppServerTlsOptions; + /** Optional shared-state tuning; bounded by the concrete PostgreSQL stores. */ + sharedStateOptions?: PostgresGitHubSharedStateOptions; + webhookPath?: string; + onWebhookError?: (error: Error) => void; + /** Test/hosting seam. Defaults to the fixed-filename mounted credential loader. */ + loadCredentials?: () => Promise; +} + +export interface SynSecGitHubAppIntakeHost { + readonly profile: NormalizedGitHubAppHostProfile; + readonly server: GitHubAppServer; + readonly drain: SynSecGitHubAppDrainController; + readonly interpretation: "executable-intake-host-boundary-not-worker-or-fleet-readiness"; + credentialStatus(): GitHubAppRuntimeCredentialStatus; + reloadCredentials(): Promise; + start(): Promise; + /** Close webhook admission, wait for locally admitted webhook requests, then close the listener. */ + close(timeoutMs?: number): Promise; +} + +function categoricalWebhookError(callback: ((error: Error) => void) | undefined): ((error: unknown) => void) | undefined { + if (!callback) return undefined; + return () => callback(new Error("GitHub App webhook processing failed.")); +} + +/** + * Compose SynSec's concrete PostgreSQL webhook intake path into one executable host boundary. + * + * Activation fails closed before credential loading or database migration unless the supplied + * canonical conformance report is complete and bound to this exact built-in PostgreSQL adapter + * id/version. Credentials are then loaded from the operator-owned mounted source into the existing + * memory-only atomic generation, migrations run through the serialized PostgreSQL migration path, + * and webhook intake is wrapped by the enforced local admission-drain controller before the bounded + * HTTP(S) listener is created. + * + * This host intentionally does not run scanner workers, ownership sweeps, or service-manager logic. + * A listening intake process therefore proves neither fleet readiness nor scan completion. Worker + * deployments must independently use the durable fenced queue and their own lifecycle/drain gates. + */ +export async function createSynSecGitHubAppIntakeHost( + options: SynSecGitHubAppIntakeHostOptions, +): Promise { + if (!options || typeof options !== "object") throw new Error("GitHub App intake host options are required."); + const profile = parseGitHubAppHostProfile(options.profile); + + const contract = buildSynSecGitHubPostgresBackendContract(); + const evidence = assessGitHubAppSharedStateConformanceEvidence(contract, options.conformanceReport); + if (!evidence.ready) { + throw new Error(`GitHub App intake host shared-state evidence is not ready: ${evidence.issues.map((issue) => issue.code).join(", ")}`); + } + + if (profile.tlsMode === "local" && (!options.tls?.key || !options.tls.cert)) { + throw new Error("GitHub App intake host local TLS requires caller-owned key and certificate material."); + } + if (profile.tlsMode !== "local" && options.tls !== undefined) { + throw new Error("GitHub App intake host TLS material is accepted only for local TLS mode."); + } + + const loadCredentials = options.loadCredentials + ?? (() => loadMountedGitHubAppRuntimeCredentialSnapshot(profile.credentialDirectory)); + const credentialSource = createGitHubAppRuntimeCredentialSource(await loadCredentials()); + + await migrateSynSecGitHubPostgresBackend(options.pool); + const stores = createSynSecGitHubPostgresSharedStores(options.pool, options.sharedStateOptions); + const rawWebhookHandler = createGitHubAppWebhookHttpHandler({ + webhookSecret: () => credentialSource.getWebhookSecret(), + replayStore: stores.replayStore, + installationStore: stores.installationStore, + queue: stores.queue, + ...(options.webhookPath ? { path: options.webhookPath } : {}), + ...(categoricalWebhookError(options.onWebhookError) + ? { onError: categoricalWebhookError(options.onWebhookError) } + : {}), + }); + const drain = createSynSecGitHubAppDrainController(rawWebhookHandler); + const server = createGitHubAppServer({ + host: profile.listenHost, + port: profile.port, + tlsMode: profile.tlsMode, + webhookHandler: drain.webhookHandler, + ...(profile.tlsMode === "local" && options.tls ? { tls: options.tls } : {}), + }); + + return { + profile, + server, + drain, + interpretation: "executable-intake-host-boundary-not-worker-or-fleet-readiness", + credentialStatus: () => credentialSource.getStatus(), + reloadCredentials: () => credentialSource.reload(loadCredentials), + start: () => server.start(), + async close(timeoutMs?: number): Promise { + drain.beginDrain(); + await drain.waitForDrained(timeoutMs); + await server.close(); + }, + }; +} diff --git a/packages/github/src/app-intake.ts b/packages/github/src/app-intake.ts new file mode 100644 index 00000000..f8503f93 --- /dev/null +++ b/packages/github/src/app-intake.ts @@ -0,0 +1,72 @@ +import { sanitizeOperationalText } from "@synsec/scanner-sdk"; +import { + parseVerifiedGitHubAppWebhook, + shouldScanGitHubAppWebhook, + type GitHubAppWebhook, + type GitHubWebhookSecret, +} from "./app.js"; + +export interface GitHubWebhookReplayClaimer { + claim(deliveryId: string): Promise<{ accepted: boolean; deliveryId: string; receivedAt: string }>; +} + +export interface GitHubAppWebhookIntakeResult { + webhook: GitHubAppWebhook; + duplicate: boolean; + shouldScan: boolean; + replayReceivedAt: string; +} + +function safeReplayError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return sanitizeOperationalText(message, 1000) || "Webhook replay claim failed."; +} + +/** + * Verify, normalize, deduplicate, and classify one GitHub App webhook delivery. + * + * Signature verification intentionally happens before the durable replay claim so + * unauthenticated traffic cannot fill the replay store. A duplicate authenticated + * delivery is returned for idempotent HTTP handling but is never scan-eligible. + * The accepted claim timestamp is retained so a higher-level handler can release + * exactly that claim if downstream durable processing fails. Replay-backend failures + * are sanitized before crossing the intake boundary so connection credentials cannot + * be forwarded into hosted logging/error hooks. + */ +export async function intakeGitHubAppWebhook(input: { + body: string | Uint8Array; + signatureHeader?: string; + webhookSecret: GitHubWebhookSecret; + eventName: string; + deliveryId: string; + replayStore: GitHubWebhookReplayClaimer; +}): Promise { + const deliveryId = input.deliveryId.trim(); + if (!deliveryId) throw new Error("GitHub webhook delivery id is required for replay protection."); + + const webhook = parseVerifiedGitHubAppWebhook({ + body: input.body, + signatureHeader: input.signatureHeader, + webhookSecret: input.webhookSecret, + eventName: input.eventName, + deliveryId, + }); + + let claim: Awaited>; + try { + claim = await input.replayStore.claim(deliveryId); + } catch (error) { + throw new Error(safeReplayError(error)); + } + if (claim.deliveryId !== deliveryId) { + throw new Error("Webhook replay store returned a mismatched delivery id."); + } + + const duplicate = !claim.accepted; + return { + webhook, + duplicate, + shouldScan: !duplicate && shouldScanGitHubAppWebhook(webhook), + replayReceivedAt: claim.receivedAt, + }; +} diff --git a/packages/github/src/app-maintenance.ts b/packages/github/src/app-maintenance.ts new file mode 100644 index 00000000..81d80f25 --- /dev/null +++ b/packages/github/src/app-maintenance.ts @@ -0,0 +1,182 @@ +import type { SynSecGitHubAppDrainController } from "./app-drain.js"; +import type { SynSecGitHubAppWorkerDrainController } from "./app-worker-drain.js"; + +const DEFAULT_TIMEOUT_MS = 30_000; +const MIN_TIMEOUT_MS = 100; +const MAX_TIMEOUT_MS = 5 * 60 * 1000; +const DEFAULT_POLL_INTERVAL_MS = 250; +const MIN_POLL_INTERVAL_MS = 10; +const MAX_POLL_INTERVAL_MS = 5_000; +const MAX_ACTIVE_LEASES = 1_000_000; + +export interface SynSecGitHubAppMaintenanceOptions { + webhookDrain: SynSecGitHubAppDrainController; + workerDrain: SynSecGitHubAppWorkerDrainController; + /** + * Read the current durable fenced-lease count from the shared backend. This callback belongs to + * trusted hosting code; repository content, webhook payloads, and scanner output must never supply it. + */ + countActiveLeases(): Promise; + pollIntervalMs?: number; +} + +export interface SynSecGitHubAppMaintenanceStatus { + acceptingWebhooks: boolean; + acceptingWorkerRuns: boolean; + activeWebhookRequests: number; + activeWorkerRuns: number; +} + +export interface SynSecGitHubAppServiceStopEvidence { + webhookAdmissionClosed: true; + workerAdmissionClosed: true; + localWebhookRequests: 0; + localWorkerRuns: 0; + /** Durable fenced leases observed after local admission was closed. */ + activeLeases: 0; +} + +export interface SynSecGitHubAppMaintenanceController { + beginDrain(): SynSecGitHubAppMaintenanceStatus; + resumeAdmission(): SynSecGitHubAppMaintenanceStatus; + status(): SynSecGitHubAppMaintenanceStatus; + /** + * Close both admission boundaries, wait for already-admitted local work, then require the trusted + * durable backend observer to report zero active fenced leases before a service manager stops the + * process. This method never stops/restarts a process or performs a deployment itself. + */ + prepareForServiceStop(timeoutMs?: number): Promise; +} + +function boundedInteger(value: number | undefined, fallback: number, minimum: number, maximum: number, label: string): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved < minimum || resolved > maximum) { + throw new Error(`${label} must be an integer between ${minimum} and ${maximum} milliseconds.`); + } + return resolved; +} + +function activeLeaseCount(value: number): number { + if (!Number.isSafeInteger(value) || value < 0 || value > MAX_ACTIVE_LEASES) { + throw new Error("GitHub App durable active-lease observation is invalid."); + } + return value; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +/** + * Compose the enforced webhook and background-worker admission drains into one service-manager + * maintenance boundary. + * + * The controller intentionally distinguishes local process evidence from durable shared-state + * evidence. Local run/request counts prove only that operations admitted through these in-process + * controllers have completed. A stop becomes eligible only after a caller-owned durable observer + * independently reports zero fenced leases while both admission boundaries remain closed. + * + * Observer failures are converted to a categorical error so backend connection strings, queries, + * customer data, or other untrusted diagnostic material are not reflected through this boundary. + */ +export function createSynSecGitHubAppMaintenanceController( + options: SynSecGitHubAppMaintenanceOptions, +): SynSecGitHubAppMaintenanceController { + if (!options || typeof options !== "object") throw new Error("GitHub App maintenance options are required."); + if (!options.webhookDrain || typeof options.webhookDrain.beginDrain !== "function") { + throw new Error("GitHub App webhook drain controller is required."); + } + if (!options.workerDrain || typeof options.workerDrain.beginDrain !== "function") { + throw new Error("GitHub App worker drain controller is required."); + } + if (typeof options.countActiveLeases !== "function") { + throw new Error("GitHub App durable active-lease observer is required."); + } + const pollIntervalMs = boundedInteger( + options.pollIntervalMs, + DEFAULT_POLL_INTERVAL_MS, + MIN_POLL_INTERVAL_MS, + MAX_POLL_INTERVAL_MS, + "GitHub App maintenance poll interval", + ); + + const currentStatus = (): SynSecGitHubAppMaintenanceStatus => { + const webhooks = options.webhookDrain.status(); + const workers = options.workerDrain.status(); + return { + acceptingWebhooks: webhooks.acceptingWebhooks, + acceptingWorkerRuns: workers.acceptingWorkerRuns, + activeWebhookRequests: webhooks.activeWebhookRequests, + activeWorkerRuns: workers.activeWorkerRuns, + }; + }; + + const beginDrain = (): SynSecGitHubAppMaintenanceStatus => { + options.webhookDrain.beginDrain(); + options.workerDrain.beginDrain(); + return currentStatus(); + }; + + return { + beginDrain, + resumeAdmission() { + options.webhookDrain.resumeAdmission(); + options.workerDrain.resumeAdmission(); + return currentStatus(); + }, + status: currentStatus, + async prepareForServiceStop(timeoutMs?: number): Promise { + const timeout = boundedInteger( + timeoutMs, + DEFAULT_TIMEOUT_MS, + MIN_TIMEOUT_MS, + MAX_TIMEOUT_MS, + "GitHub App maintenance timeout", + ); + const startedAt = Date.now(); + beginDrain(); + + const remaining = (): number => Math.max(0, timeout - (Date.now() - startedAt)); + const localTimeout = remaining(); + if (localTimeout < MIN_TIMEOUT_MS) { + throw new Error("GitHub App maintenance drain did not complete before the configured timeout."); + } + try { + await Promise.all([ + options.webhookDrain.waitForDrained(localTimeout), + options.workerDrain.waitForDrained(localTimeout), + ]); + } catch { + throw new Error("GitHub App maintenance drain did not complete before the configured timeout."); + } + + for (;;) { + if (remaining() <= 0) { + throw new Error("GitHub App durable leases did not drain before the configured timeout."); + } + let leases: number; + try { + leases = activeLeaseCount(await options.countActiveLeases()); + } catch { + throw new Error("GitHub App durable active-lease observation failed."); + } + if (leases === 0) { + const status = currentStatus(); + if (status.acceptingWebhooks || status.acceptingWorkerRuns || status.activeWebhookRequests !== 0 || status.activeWorkerRuns !== 0) { + throw new Error("GitHub App admission state changed while preparing for service stop."); + } + return { + webhookAdmissionClosed: true, + workerAdmissionClosed: true, + localWebhookRequests: 0, + localWorkerRuns: 0, + activeLeases: 0, + }; + } + await sleep(Math.min(pollIntervalMs, remaining())); + } + }, + }; +} diff --git a/packages/github/src/app-operator-status.ts b/packages/github/src/app-operator-status.ts new file mode 100644 index 00000000..556a61d8 --- /dev/null +++ b/packages/github/src/app-operator-status.ts @@ -0,0 +1,224 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { GitHubAppRuntimeCredentialStatus } from "./runtime-credentials.js"; + +const DEFAULT_PATH = "/_synsec/operator/status"; +const MAX_PATH_LENGTH = 128; +const MAX_IDENTIFIER_LENGTH = 128; +const SAFE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:@/+\-]*$/; +const MAX_COUNTER = 1_000_000_000; + +export type GitHubAppOperatorRecoveryPhase = "idle" | "isolated" | "recovering" | "ready" | "failed"; +export type GitHubAppOperatorAdmissionState = "open" | "closed"; + +export interface GitHubAppOperatorStatusObservation { + releaseId: string; + schemaVersion: number; + ready: boolean; + credentialStatus: GitHubAppRuntimeCredentialStatus; + webhookAdmission: GitHubAppOperatorAdmissionState; + workerAdmission: GitHubAppOperatorAdmissionState; + activeWebhookRequests: number; + activeWorkerRuns: number; + durableActiveLeases: number; + recoveryPhase: GitHubAppOperatorRecoveryPhase; + observedAt: string | Date; +} + +export interface GitHubAppOperatorStatusSnapshot { + version: 1; + release: { id: string; schemaVersion: number }; + ready: boolean; + credentials: { + generation: string; + webhookSecretCount: 1 | 2; + reloadCount: number; + }; + admission: { + webhook: GitHubAppOperatorAdmissionState; + worker: GitHubAppOperatorAdmissionState; + activeWebhookRequests: number; + activeWorkerRuns: number; + }; + durable: { activeLeases: number }; + recovery: { phase: GitHubAppOperatorRecoveryPhase }; + observedAt: string; + interpretation: "aggregate-operator-observation-not-external-security-proof"; +} + +export interface GitHubAppOperatorStatusHttpOptions { + path?: string; + /** Trusted hosting authentication/authorization boundary. False and thrown errors fail closed. */ + authorize(request: IncomingMessage): boolean | Promise; + /** Collect only the bounded observation contract below. Do not pass arbitrary backend payloads. */ + observe(): GitHubAppOperatorStatusObservation | Promise; + /** Receives categorical errors only; original backend/authentication errors are discarded. */ + onError?: (error: Error) => void; +} + +function boundedIdentifier(value: unknown, label: string): string { + if (typeof value !== "string") throw new Error(`${label} must be a string.`); + const normalized = value.trim(); + if (!normalized || normalized.length > MAX_IDENTIFIER_LENGTH || !SAFE_IDENTIFIER.test(normalized)) { + throw new Error(`${label} must be a bounded non-secret identifier.`); + } + return normalized; +} + +function boundedCounter(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > MAX_COUNTER) { + throw new Error(`${label} must be an integer between 0 and ${MAX_COUNTER}.`); + } + return value; +} + +function positiveVersion(value: unknown): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > Number.MAX_SAFE_INTEGER) { + throw new Error("GitHub App operator schema version must be a positive safe integer."); + } + return value; +} + +function admission(value: unknown, label: string): GitHubAppOperatorAdmissionState { + if (value !== "open" && value !== "closed") throw new Error(`${label} must be open or closed.`); + return value; +} + +function recoveryPhase(value: unknown): GitHubAppOperatorRecoveryPhase { + if (value !== "idle" && value !== "isolated" && value !== "recovering" && value !== "ready" && value !== "failed") { + throw new Error("GitHub App operator recovery phase is invalid."); + } + return value; +} + +function timestamp(value: unknown): string { + const date = value instanceof Date ? value : typeof value === "string" ? new Date(value) : undefined; + if (!date || !Number.isFinite(date.getTime())) throw new Error("GitHub App operator observedAt must be a valid timestamp."); + return date.toISOString(); +} + +function credentialStatus(value: GitHubAppRuntimeCredentialStatus): GitHubAppOperatorStatusSnapshot["credentials"] { + if (!value || value.version !== 1 || value.interpretation !== "memory-only-runtime-credential-generation") { + throw new Error("GitHub App operator credential status is invalid."); + } + if (value.webhookSecretCount !== 1 && value.webhookSecretCount !== 2) { + throw new Error("GitHub App operator webhook secret count is invalid."); + } + return { + generation: boundedIdentifier(value.generation, "GitHub App credential generation"), + webhookSecretCount: value.webhookSecretCount, + reloadCount: boundedCounter(value.reloadCount, "GitHub App credential reload count"), + }; +} + +/** + * Convert trusted operational observations into a fixed, aggregate-only snapshot. + * + * The function reconstructs every field instead of spreading caller objects, so backend errors, + * tenant metadata, filesystem paths, tokens, keys, scanner output, and other untrusted properties + * cannot accidentally cross this disclosure boundary. The resulting status is operator evidence + * only: it does not prove GitHub credential acceptance, repository authorization, runtime safety, + * fleet-wide health, exploitability, or absence of vulnerabilities. + */ +export function buildGitHubAppOperatorStatusSnapshot( + observation: GitHubAppOperatorStatusObservation, +): GitHubAppOperatorStatusSnapshot { + if (!observation || typeof observation !== "object") throw new Error("GitHub App operator observation is required."); + if (typeof observation.ready !== "boolean") throw new Error("GitHub App operator readiness must be boolean."); + return { + version: 1, + release: { + id: boundedIdentifier(observation.releaseId, "GitHub App release id"), + schemaVersion: positiveVersion(observation.schemaVersion), + }, + ready: observation.ready, + credentials: credentialStatus(observation.credentialStatus), + admission: { + webhook: admission(observation.webhookAdmission, "GitHub App webhook admission"), + worker: admission(observation.workerAdmission, "GitHub App worker admission"), + activeWebhookRequests: boundedCounter(observation.activeWebhookRequests, "GitHub App active webhook request count"), + activeWorkerRuns: boundedCounter(observation.activeWorkerRuns, "GitHub App active worker run count"), + }, + durable: { + activeLeases: boundedCounter(observation.durableActiveLeases, "GitHub App durable active lease count"), + }, + recovery: { phase: recoveryPhase(observation.recoveryPhase) }, + observedAt: timestamp(observation.observedAt), + interpretation: "aggregate-operator-observation-not-external-security-proof", + }; +} + +function statusPath(value: string | undefined): string { + const path = value?.trim() || DEFAULT_PATH; + if (!path.startsWith("/") || path.length > MAX_PATH_LENGTH || path.includes("?") || path.includes("#") || /[\u0000-\u001f\u007f]/.test(path)) { + throw new Error("GitHub App operator status path must be a bounded absolute path without query or fragment components."); + } + return path; +} + +function sendJson(response: ServerResponse, statusCode: number, payload: Record): void { + const body = `${JSON.stringify(payload)}\n`; + response.statusCode = statusCode; + response.setHeader("content-type", "application/json; charset=utf-8"); + response.setHeader("content-length", Buffer.byteLength(body)); + response.setHeader("cache-control", "no-store"); + response.setHeader("x-content-type-options", "nosniff"); + response.end(body); +} + +function categoricalError(options: GitHubAppOperatorStatusHttpOptions, code: "authorization_failed" | "observation_failed"): void { + try { + options.onError?.(new Error(`GitHub App operator status ${code}.`)); + } catch { + // Logging/telemetry callbacks are outside the response trust boundary. + } +} + +/** + * Create a framework-free protected operator-status endpoint. + * + * Authorization is caller-owned because SynSec cannot infer the deployment's operator identity + * system. Unauthorized requests and authorization failures both receive 404 so the endpoint does + * not disclose its presence. Observation failures return a categorical 503. Original errors are + * never reflected to either HTTP clients or the optional callback. + */ +export function createGitHubAppOperatorStatusHttpHandler(options: GitHubAppOperatorStatusHttpOptions) { + if (!options || typeof options.authorize !== "function" || typeof options.observe !== "function") { + throw new Error("GitHub App operator status requires authorize and observe callbacks."); + } + const path = statusPath(options.path); + + return async function githubAppOperatorStatusHttpHandler( + request: IncomingMessage, + response: ServerResponse, + ): Promise { + const requestPath = (request.url ?? "").split("?", 1)[0]; + if (requestPath !== path) { + sendJson(response, 404, { status: "not_found" }); + return; + } + if (request.method !== "GET") { + response.setHeader("allow", "GET"); + sendJson(response, 405, { status: "method_not_allowed" }); + return; + } + + let authorized = false; + try { + authorized = (await options.authorize(request)) === true; + } catch { + categoricalError(options, "authorization_failed"); + } + if (!authorized) { + sendJson(response, 404, { status: "not_found" }); + return; + } + + try { + const snapshot = buildGitHubAppOperatorStatusSnapshot(await options.observe()); + sendJson(response, 200, snapshot as unknown as Record); + } catch { + categoricalError(options, "observation_failed"); + sendJson(response, 503, { status: "unavailable" }); + } + }; +} diff --git a/packages/github/src/app-permissions.ts b/packages/github/src/app-permissions.ts new file mode 100644 index 00000000..a08a73da --- /dev/null +++ b/packages/github/src/app-permissions.ts @@ -0,0 +1,92 @@ +import type { + GitHubInstallationPermissionLevel, + GitHubInstallationPermissions, +} from "./app.js"; + +export interface GitHubAppPermissionRequirement { + permission: string; + level: GitHubInstallationPermissionLevel; + purpose: "repository-acquisition" | "check-publication" | "sarif-publication"; +} + +export interface GitHubAppPermissionDiagnostic extends GitHubAppPermissionRequirement { + actual?: GitHubInstallationPermissionLevel; + status: "satisfied" | "missing" | "insufficient" | "unknown"; + message: string; +} + +export interface GitHubAppPermissionDiagnosticResult { + ok: boolean; + metadataAvailable: boolean; + required: GitHubAppPermissionRequirement[]; + diagnostics: GitHubAppPermissionDiagnostic[]; +} + +function satisfies(actual: GitHubInstallationPermissionLevel | undefined, required: GitHubInstallationPermissionLevel): boolean { + if (required === "read") return actual === "read" || actual === "write"; + return actual === "write"; +} + +/** Return the minimum token permissions used by SynSec's current hosted worker operations. */ +export function requiredGitHubAppWorkerPermissions(options: { publishSarif?: boolean } = {}): GitHubAppPermissionRequirement[] { + return [ + { permission: "contents", level: "read", purpose: "repository-acquisition" }, + { permission: "checks", level: "write", purpose: "check-publication" }, + ...(options.publishSarif + ? [{ permission: "security_events", level: "write", purpose: "sarif-publication" } as const] + : []), + ]; +} + +/** + * Explain whether GitHub-reported installation-token permissions satisfy SynSec worker needs. + * + * Missing permission metadata is reported as unknown and `ok=false`; SynSec never interprets an + * unavailable permission map as authorization to continue. This diagnostic describes existing + * permissions only and does not request, broaden, or mutate installation access. + */ +export function diagnoseGitHubAppWorkerPermissions( + permissions: GitHubInstallationPermissions | undefined, + options: { publishSarif?: boolean } = {}, +): GitHubAppPermissionDiagnosticResult { + const required = requiredGitHubAppWorkerPermissions(options); + const metadataAvailable = permissions !== undefined; + const diagnostics = required.map((requirement): GitHubAppPermissionDiagnostic => { + const actual = permissions?.[requirement.permission]; + if (!metadataAvailable) { + return { + ...requirement, + status: "unknown", + message: `GitHub did not provide permission metadata for ${requirement.permission}:${requirement.level}.`, + }; + } + if (actual === undefined) { + return { + ...requirement, + status: "missing", + message: `Missing GitHub App permission ${requirement.permission}:${requirement.level} for ${requirement.purpose}.`, + }; + } + if (!satisfies(actual, requirement.level)) { + return { + ...requirement, + actual, + status: "insufficient", + message: `GitHub App permission ${requirement.permission}:${actual} is insufficient; ${requirement.level} is required for ${requirement.purpose}.`, + }; + } + return { + ...requirement, + actual, + status: "satisfied", + message: `GitHub App permission ${requirement.permission}:${actual} satisfies ${requirement.purpose}.`, + }; + }); + + return { + ok: diagnostics.every((diagnostic) => diagnostic.status === "satisfied"), + metadataAvailable, + required, + diagnostics, + }; +} diff --git a/packages/github/src/app-provisioning.ts b/packages/github/src/app-provisioning.ts new file mode 100644 index 00000000..db6d8ddc --- /dev/null +++ b/packages/github/src/app-provisioning.ts @@ -0,0 +1,304 @@ +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { buildSynSecGitHubAppSetupContract, type SynSecGitHubAppSetupOptions } from "./app-setup.js"; + +const MAX_URL_LENGTH = 2048; +const MAX_NAME_LENGTH = 100; +const MAX_DESCRIPTION_LENGTH = 255; +const MAX_ORGANIZATION_LENGTH = 39; +const MAX_STATE_LENGTH = 256; +const MAX_CODE_LENGTH = 512; +const MAX_PRIVATE_KEY_BYTES = 64 * 1024; +const MAX_WEBHOOK_SECRET_BYTES = 4096; +const MAX_GENERATION_LENGTH = 128; + +export interface SynSecGitHubAppManifestOptions extends SynSecGitHubAppSetupOptions { + homepageUrl: string; + webhookUrl: string; + redirectUrl: string; + setupUrl?: string; + name?: string; + description?: string; + public?: boolean; + setupOnUpdate?: boolean; +} + +export interface SynSecGitHubAppManifest { + name?: string; + url: string; + hook_attributes: { + url: string; + active: true; + }; + redirect_url: string; + setup_url?: string; + setup_on_update?: boolean; + public: boolean; + default_permissions: Record; + default_events: string[]; + description?: string; +} + +export interface SynSecGitHubAppManifestRegistration { + version: 1; + method: "POST"; + action: string; + fields: { + manifest: string; + state: string; + }; + interpretation: "registration-request-not-provisioning-success"; +} + +export interface SynSecGitHubAppManifestCallback { + version: 1; + code: string; + interpretation: "validated-callback-not-conversion-success"; +} + +export interface SynSecGitHubAppProvisioningCredentials { + appId: number; + privateKey: string; + webhookSecret: string; +} + +export interface SynSecGitHubAppProvisioningActivation { + generation: string; +} + +export interface SynSecGitHubAppProvisioningResult { + version: 1; + appId: number; + generation: string; + interpretation: "secret-manager-handoff-complete-not-runtime-readiness"; +} + +function boundedSingleLine(value: string, label: string, maximum: number): string { + if (typeof value !== "string") throw new Error(`${label} must be a string.`); + const normalized = value.trim(); + if (!normalized || normalized.length > maximum || /[\u0000-\u001f\u007f]/.test(normalized)) { + throw new Error(`${label} must be a bounded non-empty single-line value.`); + } + return normalized; +} + +function httpsUrl(value: string, label: string): string { + const normalized = boundedSingleLine(value, label, MAX_URL_LENGTH); + let parsed: URL; + try { + parsed = new URL(normalized); + } catch { + throw new Error(`${label} must be an absolute HTTPS URL.`); + } + if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.hash) { + throw new Error(`${label} must be an absolute HTTPS URL without credentials or a fragment.`); + } + return parsed.toString(); +} + +function optionalText(value: string | undefined, label: string, maximum: number): string | undefined { + return value === undefined ? undefined : boundedSingleLine(value, label, maximum); +} + +function organization(value: string): string { + const normalized = boundedSingleLine(value, "GitHub organization", MAX_ORGANIZATION_LENGTH); + if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(normalized) || normalized.includes("--")) { + throw new Error("GitHub organization is invalid."); + } + return normalized; +} + +function stateToken(value: string): string { + const normalized = boundedSingleLine(value, "GitHub App manifest state", MAX_STATE_LENGTH); + if (!/^[A-Za-z0-9_-]+$/.test(normalized)) throw new Error("GitHub App manifest state contains unsupported characters."); + return normalized; +} + +function callbackCode(value: string): string { + const normalized = boundedSingleLine(value, "GitHub App manifest callback code", MAX_CODE_LENGTH); + if (!/^[A-Za-z0-9_-]+$/.test(normalized)) throw new Error("GitHub App manifest callback code contains unsupported characters."); + return normalized; +} + +function equalState(actual: string, expected: string): boolean { + const actualBytes = Buffer.from(actual, "utf8"); + const expectedBytes = Buffer.from(expected, "utf8"); + if (actualBytes.length !== expectedBytes.length) return false; + return timingSafeEqual(actualBytes, expectedBytes); +} + +function positiveAppId(value: unknown): number { + const normalized = typeof value === "number" ? value : Number(value); + if (!Number.isSafeInteger(normalized) || normalized <= 0) throw new Error("GitHub App manifest conversion returned an invalid App id."); + return normalized; +} + +function provisioningPrivateKey(value: unknown): string { + if (typeof value !== "string" || !value.trim()) throw new Error("GitHub App manifest conversion did not return a private key."); + if (Buffer.byteLength(value, "utf8") > MAX_PRIVATE_KEY_BYTES) throw new Error("GitHub App manifest conversion private key exceeds the supported bound."); + const normalized = value.trim(); + const pkcs1 = normalized.startsWith("-----BEGIN RSA PRIVATE KEY-----") + && normalized.endsWith("-----END RSA PRIVATE KEY-----"); + const pkcs8 = normalized.startsWith("-----BEGIN PRIVATE KEY-----") + && normalized.endsWith("-----END PRIVATE KEY-----"); + if (!pkcs1 && !pkcs8) throw new Error("GitHub App manifest conversion private key must be PEM encoded."); + return value; +} + +function provisioningWebhookSecret(value: unknown): string { + if (typeof value !== "string") throw new Error("GitHub App manifest conversion did not return a webhook secret."); + const bytes = Buffer.byteLength(value, "utf8"); + if (bytes < 32 || bytes > MAX_WEBHOOK_SECRET_BYTES) { + throw new Error("GitHub App manifest conversion webhook secret is outside the supported size bound."); + } + return value; +} + +function provisioningGeneration(value: unknown): string { + if (typeof value !== "string") throw new Error("GitHub App provisioning activation must return a generation identifier."); + const normalized = value.trim(); + if (!normalized || normalized.length > MAX_GENERATION_LENGTH || !/^[A-Za-z0-9][A-Za-z0-9._:@/-]*$/.test(normalized)) { + throw new Error("GitHub App provisioning generation must be a bounded non-secret identifier."); + } + return normalized; +} + +/** + * Build the feature-aware GitHub App manifest used for initial registration. + * + * The output intentionally contains no webhook secret, private key, client secret, installation + * token, or durable SynSec state. HTTPS is mandatory because these URLs become trust-boundary + * redirects/webhook destinations in a production GitHub App registration. + */ +export function buildSynSecGitHubAppManifest(options: SynSecGitHubAppManifestOptions): SynSecGitHubAppManifest { + const setup = buildSynSecGitHubAppSetupContract(options); + const name = optionalText(options.name, "GitHub App name", MAX_NAME_LENGTH); + const description = optionalText(options.description, "GitHub App description", MAX_DESCRIPTION_LENGTH); + const setupUrl = options.setupUrl === undefined ? undefined : httpsUrl(options.setupUrl, "GitHub App setup URL"); + if (options.setupOnUpdate === true && !setupUrl) { + throw new Error("GitHub App setupOnUpdate requires a setup URL."); + } + + return { + ...(name ? { name } : {}), + url: httpsUrl(options.homepageUrl, "GitHub App homepage URL"), + hook_attributes: { + url: httpsUrl(options.webhookUrl, "GitHub App webhook URL"), + active: true, + }, + redirect_url: httpsUrl(options.redirectUrl, "GitHub App manifest redirect URL"), + ...(setupUrl ? { setup_url: setupUrl } : {}), + ...(setupUrl ? { setup_on_update: options.setupOnUpdate ?? true } : {}), + public: options.public ?? false, + default_permissions: { ...setup.permissions }, + default_events: [...setup.events], + ...(description ? { description } : {}), + }; +} + +/** + * Build the exact POST target/fields for GitHub's App Manifest registration handshake. + * + * Callers must keep the returned state in a short-lived server-side session and submit these fields + * as form data. This helper does not perform a browser redirect, persist state, or claim that an App + * was created merely because a registration request was generated. + */ +export function createSynSecGitHubAppManifestRegistration(input: { + manifest: SynSecGitHubAppManifest; + organization?: string; + state?: string; +}): SynSecGitHubAppManifestRegistration { + const state = input.state === undefined ? randomBytes(32).toString("base64url") : stateToken(input.state); + const action = input.organization + ? `https://github.com/organizations/${encodeURIComponent(organization(input.organization))}/settings/apps/new` + : "https://github.com/settings/apps/new"; + const serialized = JSON.stringify(input.manifest); + if (Buffer.byteLength(serialized, "utf8") > 32 * 1024) throw new Error("GitHub App manifest exceeds 32 KiB."); + + return { + version: 1, + method: "POST", + action, + fields: { manifest: serialized, state }, + interpretation: "registration-request-not-provisioning-success", + }; +} + +/** + * Validate the redirect from GitHub before a caller exchanges the one-time manifest code. + * + * The code is credential-adjacent and deliberately returned only to the immediate caller; this + * module never logs, persists, or serializes it into status/readiness output. Conversion to the App + * id/private key/webhook secret remains a hosting/secret-manager boundary. + */ +export function validateSynSecGitHubAppManifestCallback(input: { + code: string | undefined; + state: string | undefined; + expectedState: string; +}): SynSecGitHubAppManifestCallback { + const expected = stateToken(input.expectedState); + if (input.state === undefined || input.code === undefined) { + throw new Error("GitHub App manifest callback is missing code or state."); + } + const actual = stateToken(input.state); + if (!equalState(actual, expected)) throw new Error("GitHub App manifest callback state does not match."); + + return { + version: 1, + code: callbackCode(input.code), + interpretation: "validated-callback-not-conversion-success", + }; +} + +/** + * Exchange a validated one-time manifest code through caller-owned transport and hand the generated + * credentials directly to a caller-owned secret-manager/service-manager activation boundary. + * + * SynSec validates only the fields it needs (App id, private key, webhook secret), ignores unrelated + * conversion response metadata, never returns the credentials, and replaces transport/activation + * failures with bounded generic errors so an untrusted backend error cannot disclose secrets through + * normal CLI/HTTP status paths. The returned generation is operator metadata, not proof that every + * runtime replica has reloaded the new credentials or that GitHub has accepted a subsequent use. + */ +export async function provisionSynSecGitHubAppManifestConversion(input: { + callback: SynSecGitHubAppManifestCallback; + exchange: (code: string) => Promise; + activate: (credentials: SynSecGitHubAppProvisioningCredentials) => Promise; +}): Promise { + if (!input.callback || input.callback.interpretation !== "validated-callback-not-conversion-success") { + throw new Error("A validated GitHub App manifest callback is required before conversion."); + } + if (typeof input.exchange !== "function" || typeof input.activate !== "function") { + throw new Error("GitHub App manifest conversion requires exchange and activation boundaries."); + } + const code = callbackCode(input.callback.code); + + let response: unknown; + try { + response = await input.exchange(code); + } catch { + throw new Error("GitHub App manifest conversion transport failed."); + } + if (!response || typeof response !== "object" || Array.isArray(response)) { + throw new Error("GitHub App manifest conversion returned an invalid response."); + } + const raw = response as Record; + const credentials: SynSecGitHubAppProvisioningCredentials = { + appId: positiveAppId(raw.id), + privateKey: provisioningPrivateKey(raw.pem), + webhookSecret: provisioningWebhookSecret(raw.webhook_secret), + }; + + let activation: SynSecGitHubAppProvisioningActivation; + try { + activation = await input.activate(credentials); + } catch { + throw new Error("GitHub App credential activation failed."); + } + const generation = provisioningGeneration(activation?.generation); + return { + version: 1, + appId: credentials.appId, + generation, + interpretation: "secret-manager-handoff-complete-not-runtime-readiness", + }; +} diff --git a/packages/github/src/app-readiness-policy.ts b/packages/github/src/app-readiness-policy.ts new file mode 100644 index 00000000..727dc76c --- /dev/null +++ b/packages/github/src/app-readiness-policy.ts @@ -0,0 +1,105 @@ +import type { GitHubAppRuntimeStatus } from "./app-status.js"; + +export type GitHubAppRuntimeReadinessCode = + | "invalid-status" + | "expired-leases" + | "pending-backlog" + | "failed-backlog"; + +export interface GitHubAppRuntimeReadinessPolicy { + /** Maximum expired leases allowed before routing readiness fails. Defaults to 0. */ + maxExpiredLeases?: number; + /** Optional maximum pending queue depth. Omit when backlog size is not a routing signal. */ + maxPendingJobs?: number; + /** Optional maximum retained failed-job count. Omit when failures are not a routing signal. */ + maxFailedJobs?: number; +} + +export interface GitHubAppRuntimeReadinessAssessment { + ready: boolean; + codes: GitHubAppRuntimeReadinessCode[]; + interpretation: "aggregate-runtime-routing-policy-not-security-certification"; +} + +const MAX_COUNT = 1_000_000_000; + +function boundedThreshold(value: number | undefined, name: string): number | undefined { + if (value === undefined) return undefined; + if (!Number.isSafeInteger(value) || value < 0 || value > MAX_COUNT) { + throw new Error(`${name} must be an integer between 0 and ${MAX_COUNT}.`); + } + return value; +} + +function boundedCount(value: unknown): value is number { + return typeof value === "number" + && Number.isSafeInteger(value) + && value >= 0 + && value <= MAX_COUNT; +} + +function statusIsConsistent(status: GitHubAppRuntimeStatus): boolean { + const installationCounts = [ + status.installations.total, + status.installations.active, + status.installations.suspended, + status.installations.allRepositories, + status.installations.selectedRepositories, + ]; + const queueCounts = [ + status.queue.total, + status.queue.pending, + status.queue.leased, + status.queue.expiredLeases, + status.queue.failed, + ]; + if (![...installationCounts, ...queueCounts].every(boundedCount)) return false; + if (status.installations.active + status.installations.suspended !== status.installations.total) return false; + if (status.installations.allRepositories + status.installations.selectedRepositories !== status.installations.total) return false; + if (status.queue.pending + status.queue.leased + status.queue.failed !== status.queue.total) return false; + if (status.queue.expiredLeases > status.queue.leased) return false; + return true; +} + +/** + * Evaluate aggregate hosted-runtime state for routing readiness without exposing tenant identity. + * + * The default policy fails on any expired lease because an expired worker lease indicates reclaimable + * work and can signal a stalled/lost worker. Pending/failed backlog thresholds are opt-in because + * acceptable queue depth is deployment-specific. This policy does not certify scanner isolation, + * shared-state safety, GitHub authorization, or credential correctness. + */ +export function assessGitHubAppRuntimeReadiness( + status: GitHubAppRuntimeStatus, + policy: GitHubAppRuntimeReadinessPolicy = {}, +): GitHubAppRuntimeReadinessAssessment { + const maxExpiredLeases = boundedThreshold(policy.maxExpiredLeases ?? 0, "maxExpiredLeases") ?? 0; + const maxPendingJobs = boundedThreshold(policy.maxPendingJobs, "maxPendingJobs"); + const maxFailedJobs = boundedThreshold(policy.maxFailedJobs, "maxFailedJobs"); + const codes: GitHubAppRuntimeReadinessCode[] = []; + + if (!statusIsConsistent(status)) { + codes.push("invalid-status"); + } else { + if (status.queue.expiredLeases > maxExpiredLeases) codes.push("expired-leases"); + if (maxPendingJobs !== undefined && status.queue.pending > maxPendingJobs) codes.push("pending-backlog"); + if (maxFailedJobs !== undefined && status.queue.failed > maxFailedJobs) codes.push("failed-backlog"); + } + + return { + ready: codes.length === 0, + codes, + interpretation: "aggregate-runtime-routing-policy-not-security-certification", + }; +} + +/** Build the minimal boolean predicate accepted by createGitHubAppServer(). */ +export function createGitHubAppRuntimeReadinessPredicate( + policy: GitHubAppRuntimeReadinessPolicy = {}, +): (status: GitHubAppRuntimeStatus) => boolean { + // Validate the policy once at construction time rather than only on the first probe. + boundedThreshold(policy.maxExpiredLeases ?? 0, "maxExpiredLeases"); + boundedThreshold(policy.maxPendingJobs, "maxPendingJobs"); + boundedThreshold(policy.maxFailedJobs, "maxFailedJobs"); + return (status) => assessGitHubAppRuntimeReadiness(status, policy).ready; +} diff --git a/packages/github/src/app-recovery.ts b/packages/github/src/app-recovery.ts new file mode 100644 index 00000000..6ca1a88c --- /dev/null +++ b/packages/github/src/app-recovery.ts @@ -0,0 +1,247 @@ +import type { + SynSecGitHubAppMaintenanceController, + SynSecGitHubAppMaintenanceStatus, +} from "./app-maintenance.js"; + +const DEFAULT_TIMEOUT_MS = 30_000; +const MIN_TIMEOUT_MS = 100; +const MAX_TIMEOUT_MS = 5 * 60 * 1000; +const DEFAULT_POLL_INTERVAL_MS = 250; +const MIN_POLL_INTERVAL_MS = 10; +const MAX_POLL_INTERVAL_MS = 5_000; + +export type SynSecGitHubAppRecoveryReason = + | "shared-state-unavailable" + | "runtime-credentials-unavailable" + | "github-control-plane-unavailable" + | "operator"; + +export type SynSecGitHubAppRecoveryState = + | "running" + | "isolated" + | "verifying" + | "recovery-failed"; + +export interface SynSecGitHubAppRecoveryStatus { + state: SynSecGitHubAppRecoveryState; + reason?: SynSecGitHubAppRecoveryReason; + attempts: number; + interpretation: "local-admission-recovery-boundary-not-external-health-proof"; +} + +export interface SynSecGitHubAppRecoveryProbeResult { + sharedStateReady: boolean; + runtimeCredentialsReady: boolean; + githubControlPlaneReady: boolean; +} + +export interface SynSecGitHubAppRecoveryOptions { + maintenance: SynSecGitHubAppMaintenanceController; + /** + * Trusted hosting probe. It owns database/GitHub/secret-manager credentials and returns only + * booleans. Repository content, webhook payloads, scanner output, and stored artifacts must never + * supply this callback or its result. + * + * A true value is operator/runtime evidence only. In particular, runtimeCredentialsReady does not + * prove GitHub has accepted a newly rolled credential unless the hosting probe actually performs + * that check, and githubControlPlaneReady does not establish repository authorization. + */ + probe(): Promise; + pollIntervalMs?: number; +} + +export interface SynSecGitHubAppRecoveryController { + status(): SynSecGitHubAppRecoveryStatus; + /** Immediately close local webhook and worker admission for a categorical incident. */ + isolate(reason: SynSecGitHubAppRecoveryReason): SynSecGitHubAppRecoveryStatus; + /** + * Wait for locally admitted work to finish, then require every trusted recovery probe to pass + * before reopening admission. Concurrent callers share one recovery attempt. + */ + recover(timeoutMs?: number): Promise; +} + +function boundedInteger( + value: number | undefined, + fallback: number, + minimum: number, + maximum: number, + label: string, +): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved < minimum || resolved > maximum) { + throw new Error(`${label} must be an integer between ${minimum} and ${maximum} milliseconds.`); + } + return resolved; +} + +function validateReason(value: SynSecGitHubAppRecoveryReason): SynSecGitHubAppRecoveryReason { + if ( + value !== "shared-state-unavailable" + && value !== "runtime-credentials-unavailable" + && value !== "github-control-plane-unavailable" + && value !== "operator" + ) { + throw new Error("GitHub App recovery reason is invalid."); + } + return value; +} + +function localDrainComplete(status: SynSecGitHubAppMaintenanceStatus): boolean { + return status.acceptingWebhooks === false + && status.acceptingWorkerRuns === false + && status.activeWebhookRequests === 0 + && status.activeWorkerRuns === 0; +} + +function validProbeResult(value: unknown): value is SynSecGitHubAppRecoveryProbeResult { + if (!value || typeof value !== "object") return false; + const result = value as Partial; + return typeof result.sharedStateReady === "boolean" + && typeof result.runtimeCredentialsReady === "boolean" + && typeof result.githubControlPlaneReady === "boolean"; +} + +function allReady(result: SynSecGitHubAppRecoveryProbeResult): boolean { + return result.sharedStateReady && result.runtimeCredentialsReady && result.githubControlPlaneReady; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Enforce a process-local recovery boundary around GitHub App admission. + * + * isolate() synchronously closes both webhook and worker admission through the existing maintenance + * controller. recover() never invokes the trusted probe while pre-isolation local work is still + * active, and never resumes admission until all three recovery prerequisites report ready in one + * probe observation. A thrown/malformed probe fails closed with a categorical status and leaves + * admission closed; original diagnostics are intentionally discarded. + * + * This controller does not restart processes, mutate durable queue/tenant state, release ownership + * fences, certify PostgreSQL/GitHub/secret-manager health, or coordinate recovery across replicas. + * Those remain external trust boundaries. Multi-replica operators must isolate/recover each replica + * under their service manager and continue relying on SynSec's durable fencing/authorization checks. + */ +export function createSynSecGitHubAppRecoveryController( + options: SynSecGitHubAppRecoveryOptions, +): SynSecGitHubAppRecoveryController { + if (!options || typeof options !== "object") throw new Error("GitHub App recovery options are required."); + if ( + !options.maintenance + || typeof options.maintenance.beginDrain !== "function" + || typeof options.maintenance.resumeAdmission !== "function" + || typeof options.maintenance.status !== "function" + ) { + throw new Error("GitHub App maintenance controller is required."); + } + if (typeof options.probe !== "function") throw new Error("GitHub App recovery probe is required."); + const pollIntervalMs = boundedInteger( + options.pollIntervalMs, + DEFAULT_POLL_INTERVAL_MS, + MIN_POLL_INTERVAL_MS, + MAX_POLL_INTERVAL_MS, + "GitHub App recovery poll interval", + ); + + let state: SynSecGitHubAppRecoveryState = "running"; + let reason: SynSecGitHubAppRecoveryReason | undefined; + let attempts = 0; + let inFlight: Promise | undefined; + + const current = (): SynSecGitHubAppRecoveryStatus => ({ + state, + ...(reason ? { reason } : {}), + attempts, + interpretation: "local-admission-recovery-boundary-not-external-health-proof", + }); + + return { + status: current, + isolate(requestedReason) { + const validatedReason = validateReason(requestedReason); + if (inFlight) { + throw new Error("GitHub App recovery verification is already in progress."); + } + options.maintenance.beginDrain(); + state = "isolated"; + reason = validatedReason; + return current(); + }, + recover(timeoutMs) { + if (state === "running") return Promise.resolve(current()); + if (inFlight) return inFlight; + const timeout = boundedInteger( + timeoutMs, + DEFAULT_TIMEOUT_MS, + MIN_TIMEOUT_MS, + MAX_TIMEOUT_MS, + "GitHub App recovery timeout", + ); + const startedAt = Date.now(); + state = "verifying"; + inFlight = (async () => { + try { + for (;;) { + const elapsed = Date.now() - startedAt; + if (elapsed >= timeout) { + state = "recovery-failed"; + return current(); + } + + const maintenanceStatus = options.maintenance.status(); + if ( + maintenanceStatus.acceptingWebhooks + || maintenanceStatus.acceptingWorkerRuns + ) { + state = "recovery-failed"; + return current(); + } + + if (!localDrainComplete(maintenanceStatus)) { + await sleep(Math.min(pollIntervalMs, Math.max(1, timeout - elapsed))); + continue; + } + + attempts += 1; + let probe: SynSecGitHubAppRecoveryProbeResult; + try { + const observed = await options.probe(); + if (!validProbeResult(observed)) { + state = "recovery-failed"; + return current(); + } + probe = observed; + } catch { + state = "recovery-failed"; + return current(); + } + + if (allReady(probe)) { + const beforeResume = options.maintenance.status(); + if (!localDrainComplete(beforeResume)) { + state = "recovery-failed"; + return current(); + } + options.maintenance.resumeAdmission(); + state = "running"; + reason = undefined; + return current(); + } + + const remaining = timeout - (Date.now() - startedAt); + if (remaining <= 0) { + state = "recovery-failed"; + return current(); + } + await sleep(Math.min(pollIntervalMs, remaining)); + } + } finally { + inFlight = undefined; + } + })(); + return inFlight; + }, + }; +} diff --git a/packages/github/src/app-runtime.ts b/packages/github/src/app-runtime.ts new file mode 100644 index 00000000..fb18af41 --- /dev/null +++ b/packages/github/src/app-runtime.ts @@ -0,0 +1,237 @@ +import { mkdir } from "node:fs/promises"; +import { isAbsolute, join, relative, resolve } from "node:path"; +import type { SynSecConfig } from "@synsec/config"; +import type { ApprovedRemediationExecution } from "@synsec/workflows/remediation"; +import type { GitHubWebhookSecret } from "./app.js"; +import { createGitHubAppWebhookHttpHandler } from "./app-http.js"; +import { buildGitHubAppRuntimeStatus, type GitHubAppRuntimeStatus } from "./app-status.js"; +import { createGitHubAppInstallationTokenProvider } from "./app-token-provider.js"; +import { runConfiguredGitHubAppWorkerOnce, type ConfiguredGitHubAppWorkerOptions } from "./app-worker-runner.js"; +import { FileGitHubInstallationStore } from "./installation-store.js"; +import { FileGitHubWebhookReplayStore } from "./replay-store.js"; +import { pruneGitHubAppFailedJobs, type GitHubAppRetentionResult } from "./retention.js"; +import { acquireGitHubRepositoryCommit } from "./repository-acquisition.js"; +import { + createApprovedGitHubRemediationPullRequest, + type GitHubRemediationPullRequestResult, +} from "./remediation-writer.js"; +import { FileGitHubScanQueue } from "./scan-queue.js"; +import { + reconcileGitHubOwnedWorkspaces, + type GitHubWorkspaceReconciliationResult, +} from "./workspace-ownership.js"; +import type { GitHubCheckThreshold } from "./index.js"; +import type { GitHubPublisherOptions } from "./publisher.js"; + +export interface LocalGitHubAppRuntimeOptions extends GitHubPublisherOptions { + stateDirectory: string; + workspaceRoot: string; + /** One active webhook secret, or [new, previous] during a bounded rotation overlap. */ + webhookSecret: GitHubWebhookSecret; + appId: string | number; + privateKey: string; + config: SynSecConfig; + /** Explicit deployment cardinality. The local filesystem runtime supports exactly one replica. */ + replicaCount?: number; + webhookPath?: string; + replayRetentionMs?: number; + queueLeaseMs?: number; + failedJobRetentionMs?: number; + retentionMaxDeletes?: number; + workspaceRetentionMs?: number; + workspaceMaxDeletes?: number; + deleteStaleOwnedWorkspaces?: boolean; + threshold?: GitHubCheckThreshold; + publishSarif?: boolean; + toolVersion?: string; + onWebhookError?: (error: unknown) => void; + now?: () => number; +} + +export interface LocalGitHubAppMaintenanceResult { + expiredReplayRecordsDeleted: number; + failedJobs: GitHubAppRetentionResult; + workspaces: GitHubWorkspaceReconciliationResult; +} + +export interface LocalGitHubAppRemediationInput { + installationId: number; + repository: string; + baseBranch: string; + execution: ApprovedRemediationExecution; +} + +export interface LocalGitHubAppRuntime { + stateDirectory: string; + workspaceRoot: string; + replayStore: FileGitHubWebhookReplayStore; + installationStore: FileGitHubInstallationStore; + queue: FileGitHubScanQueue; + webhookHandler: ReturnType; + runWorkerOnce(): ReturnType; + runMaintenance(): Promise; + getStatus(): Promise; + createRemediationPullRequest(input: LocalGitHubAppRemediationInput): Promise; +} + +function requiredDirectory(value: string, label: string): string { + const normalized = value.trim(); + if (!normalized) throw new Error(`${label} is required.`); + return resolve(normalized); +} + +function isSameOrDescendant(parent: string, candidate: string): boolean { + const path = relative(parent, candidate); + return path === "" || (!isAbsolute(path) && path !== ".." && !path.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)); +} + +function pathsOverlap(a: string, b: string): boolean { + return isSameOrDescendant(a, b) || isSameOrDescendant(b, a); +} + +function assertSingleLocalReplica(replicaCount: number | undefined): void { + const replicas = replicaCount ?? 1; + if (!Number.isSafeInteger(replicas) || replicas !== 1) { + throw new Error("Local GitHub App filesystem runtime supports exactly one application replica; use a shared transactional backend for horizontal scaling."); + } +} + +/** + * Compose SynSec's single-host GitHub App primitives without opening a network listener. + * + * State and source workspaces must be separate directory trees so scanner working copies are never + * created inside durable authorization/queue storage. App credentials remain in the returned + * token-provider closure only; they are not written to any local store. Webhook verification accepts + * at most two distinct in-memory secrets so operators can overlap a new and previous secret during a + * coordinated rotation without weakening replay or authorization checks. The token provider also + * fails closed when GitHub reports that the installation lacks the permissions required for + * repository acquisition, publication, or an explicitly invoked approved remediation write. + * Workspace maintenance observes only marker-proven SynSec acquisition directories by default; + * deletion requires an explicit bounded runtime option. This factory is deliberately single-replica: + * the filesystem queue and installation synchronization do not provide transactional multi-host + * coordination. The caller still owns TLS, listener binding, process/container isolation, network + * policy, and secret injection/reload. + */ +export async function createLocalGitHubAppRuntime(options: LocalGitHubAppRuntimeOptions): Promise { + assertSingleLocalReplica(options.replicaCount); + const stateDirectory = requiredDirectory(options.stateDirectory, "GitHub App state directory"); + const workspaceRoot = requiredDirectory(options.workspaceRoot, "GitHub App workspace root"); + if (pathsOverlap(stateDirectory, workspaceRoot)) { + throw new Error("GitHub App state directory and workspace root must be separate directory trees."); + } + const secretCount = typeof options.webhookSecret === "string" ? 1 : options.webhookSecret.length; + if (secretCount < 1) throw new Error("GitHub App webhook secret is required."); + + await mkdir(stateDirectory, { recursive: true, mode: 0o700 }); + await mkdir(workspaceRoot, { recursive: true, mode: 0o700 }); + + const replayStore = new FileGitHubWebhookReplayStore(join(stateDirectory, "replay"), { + ...(options.replayRetentionMs !== undefined ? { retentionMs: options.replayRetentionMs } : {}), + ...(options.now ? { now: options.now } : {}), + }); + const installationStore = new FileGitHubInstallationStore(join(stateDirectory, "installations")); + const queue = new FileGitHubScanQueue(join(stateDirectory, "queue"), { + ...(options.queueLeaseMs !== undefined ? { leaseMs: options.queueLeaseMs } : {}), + ...(options.now ? { now: options.now } : {}), + }); + const getInstallationToken = createGitHubAppInstallationTokenProvider({ + appId: options.appId, + privateKey: options.privateKey, + requiredPermissionsByPurpose: { + acquire: { contents: "read" }, + publish: { + checks: "write", + ...(options.publishSarif ? { security_events: "write" as const } : {}), + }, + remediate: { + contents: "write", + pull_requests: "write", + }, + }, + ...(options.apiVersion ? { apiVersion: options.apiVersion } : {}), + ...(options.userAgent ? { userAgent: options.userAgent } : {}), + ...(options.fetch ? { fetch: options.fetch } : {}), + ...(options.now ? { now: options.now } : {}), + }); + + const webhookHandler = createGitHubAppWebhookHttpHandler({ + webhookSecret: options.webhookSecret, + replayStore, + installationStore, + queue, + ...(options.webhookPath ? { path: options.webhookPath } : {}), + ...(options.onWebhookError ? { onError: options.onWebhookError } : {}), + }); + + const workerOptions: ConfiguredGitHubAppWorkerOptions = { + queue, + installationStore, + config: options.config, + getInstallationToken, + acquisitionOptions: { + workspaceRoot, + ...(options.now ? { now: options.now } : {}), + }, + ...(options.threshold ? { threshold: options.threshold } : {}), + ...(options.publishSarif !== undefined ? { publishSarif: options.publishSarif } : {}), + ...(options.toolVersion ? { toolVersion: options.toolVersion } : {}), + ...(options.apiVersion ? { apiVersion: options.apiVersion } : {}), + ...(options.userAgent ? { userAgent: options.userAgent } : {}), + ...(options.fetch ? { fetch: options.fetch } : {}), + }; + + return { + stateDirectory, + workspaceRoot, + replayStore, + installationStore, + queue, + webhookHandler, + runWorkerOnce: () => runConfiguredGitHubAppWorkerOnce(workerOptions), + runMaintenance: async () => ({ + expiredReplayRecordsDeleted: await replayStore.pruneExpired(), + failedJobs: await pruneGitHubAppFailedJobs(queue, { + ...(options.failedJobRetentionMs !== undefined ? { failedJobRetentionMs: options.failedJobRetentionMs } : {}), + ...(options.retentionMaxDeletes !== undefined ? { maxDeletes: options.retentionMaxDeletes } : {}), + ...(options.now ? { now: options.now } : {}), + }), + workspaces: await reconcileGitHubOwnedWorkspaces(workspaceRoot, { + ...(options.workspaceRetentionMs !== undefined ? { retentionMs: options.workspaceRetentionMs } : {}), + ...(options.workspaceMaxDeletes !== undefined ? { maxDeletes: options.workspaceMaxDeletes } : {}), + ...(options.deleteStaleOwnedWorkspaces !== undefined ? { deleteOwned: options.deleteStaleOwnedWorkspaces } : {}), + ...(options.now ? { now: options.now } : {}), + }), + }), + getStatus: () => buildGitHubAppRuntimeStatus({ installationStore, queue }), + createRemediationPullRequest: async (input) => { + if (!(await installationStore.isRepositoryAllowed(input.installationId, input.repository))) { + throw new Error("GitHub installation is not authorized to remediate this repository."); + } + const acquisitionToken = await getInstallationToken(input.installationId, "acquire"); + const acquired = await acquireGitHubRepositoryCommit({ + repository: input.repository, + commitSha: input.execution.targetCommitSha, + installationToken: acquisitionToken, + }, { + workspaceRoot, + ...(options.now ? { now: options.now } : {}), + }); + try { + const remediationToken = await getInstallationToken(input.installationId, "remediate"); + return await createApprovedGitHubRemediationPullRequest({ + repository: input.repository, + baseBranch: input.baseBranch, + workspace: acquired.workspace, + installationToken: remediationToken, + execution: input.execution, + }, { + ...(options.apiVersion ? { apiVersion: options.apiVersion } : {}), + ...(options.userAgent ? { userAgent: options.userAgent } : {}), + ...(options.fetch ? { fetch: options.fetch } : {}), + }); + } finally { + await acquired.cleanup(); + } + }, + }; +} diff --git a/packages/github/src/app-server.ts b/packages/github/src/app-server.ts new file mode 100644 index 00000000..e3fa02f2 --- /dev/null +++ b/packages/github/src/app-server.ts @@ -0,0 +1,278 @@ +import { createServer as createHttpServer, type Server as HttpServer } from "node:http"; +import { createServer as createHttpsServer, type Server as HttpsServer } from "node:https"; +import type { AddressInfo } from "node:net"; +import type { GitHubAppRuntimeStatus } from "./app-status.js"; + +const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]); +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +const DEFAULT_HEADERS_TIMEOUT_MS = 10_000; +const DEFAULT_KEEP_ALIVE_TIMEOUT_MS = 5_000; +const DEFAULT_SHUTDOWN_TIMEOUT_MS = 10_000; +const DEFAULT_MAX_CONCURRENT_WEBHOOKS = 100; +const MIN_TIMEOUT_MS = 1_000; +const MAX_TIMEOUT_MS = 120_000; +const MAX_REQUESTS_PER_SOCKET = 100; +const MAX_CONCURRENT_WEBHOOKS = 1_000; + +export type GitHubAppServerTlsMode = "local" | "terminated-upstream" | "none"; + +export interface GitHubAppServerTlsOptions { + key: string | Buffer; + cert: string | Buffer; +} + +export interface GitHubAppServerOptions { + host: string; + port: number; + tlsMode: GitHubAppServerTlsMode; + webhookHandler(request: import("node:http").IncomingMessage, response: import("node:http").ServerResponse): Promise; + tls?: GitHubAppServerTlsOptions; + healthPath?: string; + /** Minimal routing-readiness probe path. Defaults to /readyz and must differ from healthPath. */ + readinessPath?: string; + getStatus?: () => Promise; + /** + * Optional fail-closed readiness policy evaluated only after aggregate runtime status loads. + * The predicate result is never serialized into the response; callers receive only ready/not_ready. + */ + isReady?: (status: GitHubAppRuntimeStatus) => boolean | Promise; + requestTimeoutMs?: number; + headersTimeoutMs?: number; + keepAliveTimeoutMs?: number; + shutdownTimeoutMs?: number; + /** Maximum webhook handlers allowed to execute concurrently in this listener process. */ + maxConcurrentWebhooks?: number; +} + +export interface GitHubAppServerAddress { + host: string; + port: number; + protocol: "http" | "https"; +} + +export interface GitHubAppServer { + readonly server: HttpServer | HttpsServer; + start(): Promise; + close(): Promise; +} + +function boundedTimeout(value: number | undefined, fallback: number, label: string): number { + const timeout = value ?? fallback; + if (!Number.isSafeInteger(timeout) || timeout < MIN_TIMEOUT_MS || timeout > MAX_TIMEOUT_MS) { + throw new Error(`${label} must be between ${MIN_TIMEOUT_MS} and ${MAX_TIMEOUT_MS} milliseconds.`); + } + return timeout; +} + +function boundedConcurrentWebhooks(value: number | undefined): number { + const limit = value ?? DEFAULT_MAX_CONCURRENT_WEBHOOKS; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_CONCURRENT_WEBHOOKS) { + throw new Error(`GitHub App concurrent webhook limit must be between 1 and ${MAX_CONCURRENT_WEBHOOKS}.`); + } + return limit; +} + +function normalizedHost(value: string): string { + const host = value.trim().toLowerCase().replace(/^\[|\]$/g, ""); + if (!host || host === "*" || /[\s/]/.test(host)) { + throw new Error("GitHub App listener host must be a host name or IP address, not a URL, wildcard, or path."); + } + return host; +} + +function normalizedPort(value: number): number { + if (!Number.isSafeInteger(value) || value < 0 || value > 65_535) { + throw new Error("GitHub App listener port must be an integer between 0 and 65535."); + } + return value; +} + +function normalizedProbePath(value: string | undefined, fallback: string, label: string): string { + const path = value?.trim() || fallback; + if (!path.startsWith("/") || path.includes("?") || path.includes("#") || /[\r\n]/.test(path)) { + throw new Error(`${label} must be an absolute path without query, fragment, or control components.`); + } + return path; +} + +function sendJson(response: import("node:http").ServerResponse, statusCode: number, payload: Record): void { + const body = `${JSON.stringify(payload)}\n`; + response.statusCode = statusCode; + response.setHeader("content-type", "application/json; charset=utf-8"); + response.setHeader("content-length", Buffer.byteLength(body)); + response.setHeader("cache-control", "no-store"); + response.setHeader("x-content-type-options", "nosniff"); + response.end(body); +} + +function safeStatus(status: GitHubAppRuntimeStatus): Record { + return { + status: "ok", + installations: { + total: status.installations.total, + active: status.installations.active, + suspended: status.installations.suspended, + allRepositories: status.installations.allRepositories, + selectedRepositories: status.installations.selectedRepositories, + }, + queue: { + total: status.queue.total, + pending: status.queue.pending, + leased: status.queue.leased, + expiredLeases: status.queue.expiredLeases, + failed: status.queue.failed, + }, + }; +} + +/** + * Create a bounded single-process listener for SynSec's hosted GitHub App runtime. + * + * Plain HTTP is restricted to loopback unless the operator explicitly declares upstream TLS + * termination. Local TLS requires an in-memory key/certificate pair. Request/header/keep-alive + * timeouts, per-socket request counts, and in-process concurrent webhook handlers are bounded. + * Excess webhook concurrency fails fast with a retryable aggregate-only 503 response while the + * health and readiness probes remain available. The health surface contains aggregate runtime + * counts only. The readiness surface is intentionally smaller: it confirms durable status can be + * loaded and, when configured, that a local readiness predicate accepts that status, but returns + * only `ready` or `not_ready`. Repository identities, delivery ids, commit SHAs, source paths, + * credentials, predicate diagnostics, and arbitrary durable-record fields are never serialized. + */ +export function createGitHubAppServer(options: GitHubAppServerOptions): GitHubAppServer { + const host = normalizedHost(options.host); + const port = normalizedPort(options.port); + const healthPath = normalizedProbePath(options.healthPath, "/healthz", "GitHub App health path"); + const readinessPath = normalizedProbePath(options.readinessPath, "/readyz", "GitHub App readiness path"); + if (healthPath === readinessPath) { + throw new Error("GitHub App health and readiness paths must be distinct."); + } + if (options.isReady && !options.getStatus) { + throw new Error("GitHub App readiness policy requires aggregate runtime status collection."); + } + + const requestTimeoutMs = boundedTimeout(options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS, "GitHub App request timeout"); + const headersTimeoutMs = boundedTimeout(options.headersTimeoutMs, DEFAULT_HEADERS_TIMEOUT_MS, "GitHub App headers timeout"); + const keepAliveTimeoutMs = boundedTimeout(options.keepAliveTimeoutMs, DEFAULT_KEEP_ALIVE_TIMEOUT_MS, "GitHub App keep-alive timeout"); + const shutdownTimeoutMs = boundedTimeout(options.shutdownTimeoutMs, DEFAULT_SHUTDOWN_TIMEOUT_MS, "GitHub App shutdown timeout"); + const maxConcurrentWebhooks = boundedConcurrentWebhooks(options.maxConcurrentWebhooks); + + if (options.tlsMode === "none" && !LOOPBACK_HOSTS.has(host)) { + throw new Error("A plaintext GitHub App listener is allowed only on loopback."); + } + if (options.tlsMode === "local" && (!options.tls?.key || !options.tls.cert)) { + throw new Error("Local GitHub App TLS requires both key and certificate material."); + } + if (options.tlsMode !== "local" && options.tls !== undefined) { + throw new Error("GitHub App TLS key/certificate material is accepted only in local TLS mode."); + } + + let activeWebhookRequests = 0; + + const requestListener = async ( + request: import("node:http").IncomingMessage, + response: import("node:http").ServerResponse, + ): Promise => { + const requestPath = (request.url ?? "").split("?", 1)[0]; + if (requestPath === healthPath) { + if (request.method !== "GET") { + response.setHeader("allow", "GET"); + sendJson(response, 405, { status: "method_not_allowed" }); + return; + } + try { + const status = options.getStatus ? safeStatus(await options.getStatus()) : { status: "ok" }; + sendJson(response, 200, status); + } catch { + sendJson(response, 503, { status: "unavailable" }); + } + return; + } + + if (requestPath === readinessPath) { + if (request.method !== "GET") { + response.setHeader("allow", "GET"); + sendJson(response, 405, { status: "method_not_allowed" }); + return; + } + try { + if (!options.getStatus) { + sendJson(response, 200, { status: "ready" }); + return; + } + const status = await options.getStatus(); + const ready = options.isReady ? await options.isReady(status) : true; + sendJson(response, ready ? 200 : 503, { status: ready ? "ready" : "not_ready" }); + } catch { + sendJson(response, 503, { status: "not_ready" }); + } + return; + } + + if (activeWebhookRequests >= maxConcurrentWebhooks) { + response.setHeader("retry-after", "1"); + sendJson(response, 503, { status: "busy" }); + return; + } + + activeWebhookRequests += 1; + try { + await options.webhookHandler(request, response); + } catch { + if (!response.headersSent) sendJson(response, 500, { status: "error" }); + else if (!response.writableEnded) response.end(); + } finally { + activeWebhookRequests -= 1; + } + }; + + const server: HttpServer | HttpsServer = options.tlsMode === "local" + ? createHttpsServer({ key: options.tls?.key, cert: options.tls?.cert }, requestListener) + : createHttpServer(requestListener); + + server.requestTimeout = requestTimeoutMs; + server.headersTimeout = Math.min(headersTimeoutMs, requestTimeoutMs); + server.keepAliveTimeout = keepAliveTimeoutMs; + server.maxRequestsPerSocket = MAX_REQUESTS_PER_SOCKET; + + let started = false; + + return { + server, + start: async () => { + if (started) throw new Error("GitHub App server is already started."); + await new Promise((resolvePromise, reject) => { + const onError = (error: Error): void => reject(error); + server.once("error", onError); + server.listen({ host, port, exclusive: true }, () => { + server.off("error", onError); + resolvePromise(); + }); + }); + started = true; + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("GitHub App server did not expose a TCP listener address."); + } + const info = address as AddressInfo; + return { + host: info.address, + port: info.port, + protocol: options.tlsMode === "local" ? "https" : "http", + }; + }, + close: async () => { + if (!started) return; + await new Promise((resolvePromise, reject) => { + const forceTimer = setTimeout(() => server.closeAllConnections?.(), shutdownTimeoutMs); + forceTimer.unref?.(); + server.close((error) => { + clearTimeout(forceTimer); + server.closeIdleConnections?.(); + if (error) reject(error); + else resolvePromise(); + }); + }); + started = false; + }, + }; +} diff --git a/packages/github/src/app-service-lifecycle.ts b/packages/github/src/app-service-lifecycle.ts new file mode 100644 index 00000000..73c0f833 --- /dev/null +++ b/packages/github/src/app-service-lifecycle.ts @@ -0,0 +1,163 @@ +import type { + SynSecGitHubAppMaintenanceController, + SynSecGitHubAppServiceStopEvidence, +} from "./app-maintenance.js"; + +const DEFAULT_STOP_TIMEOUT_MS = 30_000; +const MIN_STOP_TIMEOUT_MS = 100; +const MAX_STOP_TIMEOUT_MS = 5 * 60 * 1000; + +export type SynSecGitHubAppStopReason = "SIGTERM" | "SIGINT" | "operator"; +export type SynSecGitHubAppServiceLifecycleState = "running" | "draining" | "ready-to-stop" | "stop-failed"; + +export interface SynSecGitHubAppServiceLifecycleStatus { + state: SynSecGitHubAppServiceLifecycleState; + reason?: SynSecGitHubAppStopReason; +} + +export interface SynSecGitHubAppServiceLifecycleOptions { + maintenance: SynSecGitHubAppMaintenanceController; + timeoutMs?: number; + /** + * Trusted hosting callback invoked only after SynSec has closed admission, drained local work, + * and observed zero durable fenced leases. This callback may hand control back to systemd, + * Kubernetes, or another process supervisor. Repository/scanner input must never supply it. + */ + onReadyToStop( + evidence: SynSecGitHubAppServiceStopEvidence, + reason: SynSecGitHubAppStopReason, + ): void | Promise; + /** Optional categorical hosting notification. The original backend/process error is not exposed. */ + onStopFailed?(reason: SynSecGitHubAppStopReason): void | Promise; +} + +export interface SynSecGitHubAppServiceLifecycleController { + status(): SynSecGitHubAppServiceLifecycleStatus; + /** Serialized and idempotent while a stop attempt is in progress or has completed successfully. */ + requestStop(reason?: SynSecGitHubAppStopReason): Promise; + /** Resume only after a failed/aborted stop attempt. */ + resume(): SynSecGitHubAppServiceLifecycleStatus; +} + +export interface SynSecGitHubAppSignalSource { + on(signal: "SIGTERM" | "SIGINT", listener: () => void): unknown; + off(signal: "SIGTERM" | "SIGINT", listener: () => void): unknown; +} + +export interface SynSecGitHubAppSignalBinding { + dispose(): void; +} + +function boundedTimeout(value: number | undefined): number { + const timeout = value ?? DEFAULT_STOP_TIMEOUT_MS; + if (!Number.isSafeInteger(timeout) || timeout < MIN_STOP_TIMEOUT_MS || timeout > MAX_STOP_TIMEOUT_MS) { + throw new Error(`GitHub App service stop timeout must be an integer between ${MIN_STOP_TIMEOUT_MS} and ${MAX_STOP_TIMEOUT_MS} milliseconds.`); + } + return timeout; +} + +/** + * Bridge trusted process/service-manager stop requests into SynSec's enforced maintenance boundary. + * + * A successful lifecycle transition means only that this process closed its webhook/worker admission, + * all locally admitted work completed, and the caller-owned durable observer reported zero current + * fenced leases. It does not prove that another replica stopped, that a rollout completed, or that a + * service manager accepted the handoff. + */ +export function createSynSecGitHubAppServiceLifecycleController( + options: SynSecGitHubAppServiceLifecycleOptions, +): SynSecGitHubAppServiceLifecycleController { + if (!options || typeof options !== "object") throw new Error("GitHub App service lifecycle options are required."); + if (!options.maintenance || typeof options.maintenance.prepareForServiceStop !== "function") { + throw new Error("GitHub App maintenance controller is required."); + } + if (typeof options.onReadyToStop !== "function") { + throw new Error("GitHub App ready-to-stop callback is required."); + } + const timeoutMs = boundedTimeout(options.timeoutMs); + + let state: SynSecGitHubAppServiceLifecycleState = "running"; + let reason: SynSecGitHubAppStopReason | undefined; + let inFlight: Promise | undefined; + + const current = (): SynSecGitHubAppServiceLifecycleStatus => ({ + state, + ...(reason ? { reason } : {}), + }); + + const controller: SynSecGitHubAppServiceLifecycleController = { + status: current, + requestStop(requestedReason = "operator") { + if (state === "ready-to-stop") return Promise.resolve(current()); + if (inFlight) return inFlight; + reason = requestedReason; + state = "draining"; + inFlight = (async () => { + try { + const evidence = await options.maintenance.prepareForServiceStop(timeoutMs); + await options.onReadyToStop(evidence, requestedReason); + state = "ready-to-stop"; + } catch { + state = "stop-failed"; + if (options.onStopFailed) { + try { + await options.onStopFailed(requestedReason); + } catch { + // Hosting diagnostics are deliberately non-authoritative and must not replace the + // categorical lifecycle state or expose their original error through this boundary. + } + } + } finally { + inFlight = undefined; + } + return current(); + })(); + return inFlight; + }, + resume() { + if (inFlight || state === "draining") { + throw new Error("GitHub App service stop attempt is still in progress."); + } + if (state === "ready-to-stop") { + throw new Error("GitHub App service lifecycle is already ready to stop and cannot be resumed."); + } + if (state === "stop-failed") options.maintenance.resumeAdmission(); + state = "running"; + reason = undefined; + return current(); + }, + }; + + return controller; +} + +/** + * Bind SIGTERM/SIGINT to the lifecycle controller without calling process.exit() or stopping a + * service directly. The trusted onReadyToStop callback remains the only handoff into hosting code. + */ +export function bindSynSecGitHubAppServiceSignals( + controller: SynSecGitHubAppServiceLifecycleController, + source: SynSecGitHubAppSignalSource = process, +): SynSecGitHubAppSignalBinding { + if (!controller || typeof controller.requestStop !== "function") { + throw new Error("GitHub App service lifecycle controller is required."); + } + if (!source || typeof source.on !== "function" || typeof source.off !== "function") { + throw new Error("GitHub App signal source must provide on/off methods."); + } + + const onSigterm = (): void => { void controller.requestStop("SIGTERM"); }; + const onSigint = (): void => { void controller.requestStop("SIGINT"); }; + source.on("SIGTERM", onSigterm); + source.on("SIGINT", onSigint); + let disposed = false; + + return { + dispose() { + if (disposed) return; + disposed = true; + source.off("SIGTERM", onSigterm); + source.off("SIGINT", onSigint); + }, + }; +} diff --git a/packages/github/src/app-setup.ts b/packages/github/src/app-setup.ts new file mode 100644 index 00000000..c1bdf745 --- /dev/null +++ b/packages/github/src/app-setup.ts @@ -0,0 +1,217 @@ +import type { GitHubInstallationPermissionLevel } from "./app.js"; +import { requiredGitHubAppWorkerPermissions } from "./app-permissions.js"; + +export type SynSecGitHubAppEvent = + | "installation" + | "installation_repositories" + | "pull_request" + | "push"; + +export interface SynSecGitHubAppSetupOptions { + publishSarif?: boolean; + enableRemediationPullRequests?: boolean; +} + +export interface SynSecGitHubAppSetupContract { + version: 1; + permissions: Record; + events: SynSecGitHubAppEvent[]; + remediationWriteEnabled: boolean; + notes: string[]; +} + +export interface SynSecGitHubAppSetupEvaluation { + version: 1; + ready: boolean; + missingPermissions: Array<{ + permission: string; + required: GitHubInstallationPermissionLevel; + actual?: GitHubInstallationPermissionLevel; + }>; + excessiveWritePermissions: string[]; + missingEvents: SynSecGitHubAppEvent[]; + extraEvents: string[]; + interpretation: "setup-comparison-not-runtime-authorization"; +} + +export interface SynSecGitHubAppSetupRecoveryPlan { + version: 1; + ready: boolean; + requiredActions: string[]; + leastPrivilegeReview: string[]; + interpretation: "operator-guidance-not-runtime-authorization"; +} + +function mergePermission( + permissions: Record, + permission: string, + level: GitHubInstallationPermissionLevel, +): void { + const current = permissions[permission]; + if (current === "write" || current === level) return; + permissions[permission] = level; +} + +function permissionSatisfies( + actual: GitHubInstallationPermissionLevel | undefined, + required: GitHubInstallationPermissionLevel, +): boolean { + return actual === "write" || actual === required; +} + +function normalizedPermissions( + value: Record, +): Record { + const entries = Object.entries(value); + if (entries.length > 100) throw new Error("GitHub App setup permission list exceeds 100 entries."); + const result: Record = {}; + for (const [name, level] of entries) { + const permission = name.trim(); + if (!permission || permission.length > 128 || !/^[a-z0-9_]+$/i.test(permission)) { + throw new Error("GitHub App setup contains an invalid permission name."); + } + if (level !== "read" && level !== "write") { + throw new Error(`GitHub App permission ${permission} must be read or write.`); + } + result[permission] = level; + } + return result; +} + +function normalizedEvents(value: readonly string[]): string[] { + if (value.length > 100) throw new Error("GitHub App setup event list exceeds 100 entries."); + const events = value.map((entry) => { + const event = entry.trim(); + if (!event || event.length > 128 || !/^[a-z0-9_]+$/i.test(event)) { + throw new Error("GitHub App setup contains an invalid event name."); + } + return event; + }); + return [...new Set(events)].sort(); +} + +/** + * Return the minimum GitHub App installation contract for the enabled SynSec features. + * + * Remediation write permissions are deliberately opt-in. Enabling repository scanning alone never + * causes this helper to recommend contents:write or pull_requests:write. The returned object is a + * setup description only; it does not create, update, or broaden a GitHub App installation. + */ +export function buildSynSecGitHubAppSetupContract( + options: SynSecGitHubAppSetupOptions = {}, +): SynSecGitHubAppSetupContract { + const permissions: Record = {}; + for (const requirement of requiredGitHubAppWorkerPermissions({ publishSarif: options.publishSarif })) { + mergePermission(permissions, requirement.permission, requirement.level); + } + + const remediationWriteEnabled = options.enableRemediationPullRequests === true; + if (remediationWriteEnabled) { + mergePermission(permissions, "contents", "write"); + mergePermission(permissions, "pull_requests", "write"); + } + + const events: SynSecGitHubAppEvent[] = [ + "installation", + "installation_repositories", + "pull_request", + "push", + ]; + + return { + version: 1, + permissions, + events, + remediationWriteEnabled, + notes: [ + "Subscribe only to the listed repository/install events used by SynSec intake.", + options.publishSarif + ? "security_events:write is required because SARIF publication is enabled." + : "security_events permission is not required when SARIF publication is disabled.", + remediationWriteEnabled + ? "contents:write and pull_requests:write are required only for explicitly approved remediation PR creation." + : "Repository remediation writes are disabled; contents:read is sufficient for acquisition.", + ], + }; +} + +/** + * Compare an operator-declared GitHub App configuration with SynSec's feature-aware minimum. + * + * Missing permission/event capability makes the comparison not ready. Extra subscriptions and + * write permissions are reported separately as least-privilege drift; they do not prove runtime + * authorization and this helper never contacts GitHub or mutates App settings. + */ +export function evaluateSynSecGitHubAppSetup(input: { + permissions: Record; + events: readonly string[]; + options?: SynSecGitHubAppSetupOptions; +}): SynSecGitHubAppSetupEvaluation { + const expected = buildSynSecGitHubAppSetupContract(input.options); + const actualPermissions = normalizedPermissions(input.permissions); + const actualEvents = normalizedEvents(input.events); + const expectedEvents = new Set(expected.events); + + const missingPermissions = Object.entries(expected.permissions) + .filter(([permission, required]) => !permissionSatisfies(actualPermissions[permission], required)) + .map(([permission, required]) => ({ + permission, + required, + ...(actualPermissions[permission] ? { actual: actualPermissions[permission] } : {}), + })); + const excessiveWritePermissions = Object.entries(actualPermissions) + .filter(([permission, level]) => level === "write" && expected.permissions[permission] !== "write") + .map(([permission]) => permission) + .sort(); + const missingEvents = expected.events.filter((event) => !actualEvents.includes(event)); + const extraEvents = actualEvents.filter((event) => !expectedEvents.has(event)); + + return { + version: 1, + ready: missingPermissions.length === 0 && missingEvents.length === 0, + missingPermissions, + excessiveWritePermissions, + missingEvents, + extraEvents, + interpretation: "setup-comparison-not-runtime-authorization", + }; +} + +/** + * Turn the bounded setup comparison into deterministic operator recovery guidance. + * + * Required actions address capabilities SynSec needs to operate. Least-privilege review items are + * intentionally separate: they may be required by another operator-approved integration and are + * never removed automatically. This helper is guidance only and does not contact or mutate GitHub. + */ +export function buildSynSecGitHubAppSetupRecoveryPlan(input: { + permissions: Record; + events: readonly string[]; + options?: SynSecGitHubAppSetupOptions; +}): SynSecGitHubAppSetupRecoveryPlan { + const evaluation = evaluateSynSecGitHubAppSetup(input); + const requiredActions = [ + ...evaluation.missingPermissions.map(({ permission, required, actual }) => + actual + ? `Upgrade GitHub App permission ${permission} from ${actual} to ${required}.` + : `Add GitHub App permission ${permission}:${required}.`, + ), + ...evaluation.missingEvents.map((event) => `Subscribe the GitHub App to the ${event} event.`), + ]; + const leastPrivilegeReview = [ + ...evaluation.excessiveWritePermissions.map( + (permission) => `Review ${permission}:write and remove it if no other operator-approved feature requires it.`, + ), + ...evaluation.extraEvents.map( + (event) => `Review the ${event} event subscription and remove it if no other operator-approved feature requires it.`, + ), + ]; + + return { + version: 1, + ready: evaluation.ready, + requiredActions, + leastPrivilegeReview, + interpretation: "operator-guidance-not-runtime-authorization", + }; +} diff --git a/packages/github/src/app-status.ts b/packages/github/src/app-status.ts new file mode 100644 index 00000000..9036de39 --- /dev/null +++ b/packages/github/src/app-status.ts @@ -0,0 +1,81 @@ +import { FileGitHubInstallationStore } from "./installation-store.js"; +import { FileGitHubScanQueue } from "./scan-queue.js"; + +export interface GitHubAppRuntimeStatus { + installations: { + total: number; + active: number; + suspended: number; + allRepositories: number; + selectedRepositories: number; + }; + queue: { + total: number; + pending: number; + leased: number; + expiredLeases: number; + failed: number; + }; +} + +/** + * Build an aggregate-only local status snapshot suitable for operator health surfaces. + * + * The snapshot deliberately omits installation ids, account names, repository names, commit SHAs, + * delivery ids, source paths, credentials, scanner output, and arbitrary durable-record fields. + * Durable stores still validate every record before aggregation, so malformed state fails closed. + * Expired leases remain counted as leased durable records but are surfaced separately because they + * are immediately eligible for reclaim and are a useful signal of worker stalls or process loss. + */ +export async function buildGitHubAppRuntimeStatus(input: { + installationStore: FileGitHubInstallationStore; + queue: FileGitHubScanQueue; + now?: () => number; +}): Promise { + const [installations, jobs] = await Promise.all([ + input.installationStore.list(), + input.queue.list(), + ]); + const now = (input.now ?? Date.now)(); + if (!Number.isFinite(now) || now <= 0) throw new Error("GitHub App status clock must be a positive timestamp."); + + let active = 0; + let suspended = 0; + let allRepositories = 0; + let selectedRepositories = 0; + for (const installation of installations) { + if (installation.suspendedAt) suspended += 1; + else active += 1; + if (installation.repositorySelection === "all") allRepositories += 1; + else selectedRepositories += 1; + } + + let pending = 0; + let leased = 0; + let expiredLeases = 0; + let failed = 0; + for (const job of jobs) { + if (job.status === "pending") pending += 1; + else if (job.status === "leased") { + leased += 1; + if (Date.parse(job.leaseUntil ?? "") <= now) expiredLeases += 1; + } else failed += 1; + } + + return { + installations: { + total: installations.length, + active, + suspended, + allRepositories, + selectedRepositories, + }, + queue: { + total: jobs.length, + pending, + leased, + expiredLeases, + failed, + }, + }; +} diff --git a/packages/github/src/app-token-provider.ts b/packages/github/src/app-token-provider.ts new file mode 100644 index 00000000..4b88cd6e --- /dev/null +++ b/packages/github/src/app-token-provider.ts @@ -0,0 +1,167 @@ +import { sanitizeOperationalText } from "@synsec/scanner-sdk"; +import { + createGitHubAppJwt, + createGitHubInstallationToken, + type GitHubAppTokenOptions, + type GitHubInstallationPermissionLevel, + type GitHubInstallationToken, +} from "./app.js"; + +const DEFAULT_MIN_REMAINING_MS = 30_000; +const MAX_PRIVATE_KEY_BYTES = 64 * 1024; +const MAX_PERMISSION_REQUIREMENTS = 32; + +export type GitHubPermissionRequirementsByPurpose = Record< + string, + Record +>; + +export interface GitHubAppInstallationTokenProviderOptions extends GitHubAppTokenOptions { + appId: string | number; + /** Static key or memory-only supplier resolved immediately before each JWT signature. */ + privateKey: string | (() => string); + minRemainingMs?: number; + now?: () => number; + exchange?: typeof createGitHubInstallationToken; + requiredPermissionsByPurpose?: GitHubPermissionRequirementsByPurpose; +} + +function boundedPrivateKey(value: string): string { + if (!value.trim()) throw new Error("GitHub App private key is required."); + if (Buffer.byteLength(value, "utf8") > MAX_PRIVATE_KEY_BYTES) { + throw new Error(`GitHub App private key exceeds ${MAX_PRIVATE_KEY_BYTES} bytes.`); + } + return value; +} + +function privateKeySupplier(value: string | (() => string)): () => string { + if (typeof value === "string") { + const fixed = boundedPrivateKey(value); + return () => fixed; + } + if (typeof value !== "function") throw new Error("GitHub App private key or supplier is required."); + return () => boundedPrivateKey(value()); +} + +function minRemainingMs(value: number | undefined): number { + const normalized = value ?? DEFAULT_MIN_REMAINING_MS; + if (!Number.isSafeInteger(normalized) || normalized < 0 || normalized > 10 * 60 * 1000) { + throw new Error("GitHub installation-token minimum remaining lifetime must be between 0 and 600000 milliseconds."); + } + return normalized; +} + +function validateRequirements(value: GitHubPermissionRequirementsByPurpose | undefined): GitHubPermissionRequirementsByPurpose { + if (!value) return {}; + const result: GitHubPermissionRequirementsByPurpose = {}; + let count = 0; + for (const [purpose, permissions] of Object.entries(value)) { + const normalizedPurpose = purpose.trim(); + if (!normalizedPurpose || normalizedPurpose.length > 64) { + throw new Error("GitHub token permission purpose is invalid."); + } + const normalized: Record = {}; + for (const [name, level] of Object.entries(permissions)) { + count += 1; + if (count > MAX_PERMISSION_REQUIREMENTS) { + throw new Error(`GitHub token provider exceeds ${MAX_PERMISSION_REQUIREMENTS} permission requirements.`); + } + const permission = name.trim(); + if (!permission || permission.length > 128 || !/^[a-z0-9_]+$/i.test(permission)) { + throw new Error("GitHub token permission requirement contains an invalid permission name."); + } + if (level !== "read" && level !== "write") { + throw new Error("GitHub token permission requirement must be read or write."); + } + normalized[permission] = level; + } + result[normalizedPurpose] = normalized; + } + return result; +} + +function validateTokenLifetime(token: GitHubInstallationToken, now: number, minimum: number): void { + const expiresAt = Date.parse(token.expiresAt); + if (!Number.isFinite(expiresAt)) { + throw new Error("GitHub installation-token API returned an invalid expiration timestamp."); + } + if (expiresAt - now < minimum) { + throw new Error("GitHub installation token expires too soon for a repository operation."); + } +} + +function permissionSatisfies( + actual: GitHubInstallationPermissionLevel | undefined, + required: GitHubInstallationPermissionLevel, +): boolean { + if (required === "read") return actual === "read" || actual === "write"; + return actual === "write"; +} + +function validateTokenPermissions( + token: GitHubInstallationToken, + requirements: Record | undefined, + purpose: string | undefined, +): void { + if (!requirements || Object.keys(requirements).length === 0) return; + if (!token.permissions) { + throw new Error(`GitHub installation token is missing permission metadata required for ${purpose ?? "this operation"}.`); + } + const missing = Object.entries(requirements) + .filter(([name, required]) => !permissionSatisfies(token.permissions?.[name], required)) + .map(([name, required]) => `${name}:${required}`) + .sort(); + if (missing.length > 0) { + throw new Error(`GitHub installation token lacks required permission(s) for ${purpose ?? "this operation"}: ${missing.join(", ")}.`); + } +} + +function safeExchangeError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return sanitizeOperationalText(message, 1000) || "GitHub installation-token exchange failed."; +} + +/** + * Build a memory-only installation-token provider for hosted App workers. + * + * A fresh short-lived App JWT is signed for every operation and immediately exchanged through the + * fixed GitHub installation-token endpoint. The private key may be supplied dynamically; when a + * supplier is used it is resolved and bounded immediately before each signature so a validated + * in-memory credential controller can atomically rotate keys without rebuilding the worker. A + * supplier failure aborts the operation rather than falling back to an old or empty key. + * Installation tokens are returned to the caller only; this provider deliberately has no token + * cache, disk persistence, scanner integration, or logging. Optional purpose-specific permission + * requirements are checked against GitHub's token metadata before the credential is returned to + * acquisition/publication code. Transport/exchange failures are sanitized before propagation so + * authorization headers, tokens, or credential-bearing proxy URLs cannot leak into caller logging. + */ +export function createGitHubAppInstallationTokenProvider( + options: GitHubAppInstallationTokenProviderOptions, +): (installationId: number, purpose?: string) => Promise { + const getPrivateKey = privateKeySupplier(options.privateKey); + const minimum = minRemainingMs(options.minRemainingMs); + const requirements = validateRequirements(options.requiredPermissionsByPurpose); + const now = options.now ?? Date.now; + const exchange = options.exchange ?? createGitHubInstallationToken; + + return async (installationId: number, purpose?: string): Promise => { + const currentTime = now(); + if (!Number.isFinite(currentTime) || currentTime <= 0) { + throw new Error("GitHub App token-provider clock must be a positive timestamp."); + } + const appJwt = createGitHubAppJwt(options.appId, getPrivateKey(), currentTime); + let token: GitHubInstallationToken; + try { + token = await exchange(installationId, appJwt, { + apiVersion: options.apiVersion, + userAgent: options.userAgent, + fetch: options.fetch, + }); + } catch (error) { + throw new Error(safeExchangeError(error)); + } + validateTokenLifetime(token, currentTime, minimum); + validateTokenPermissions(token, purpose ? requirements[purpose] : undefined, purpose); + return token.token; + }; +} diff --git a/packages/github/src/app-upgrade.ts b/packages/github/src/app-upgrade.ts new file mode 100644 index 00000000..865e829c --- /dev/null +++ b/packages/github/src/app-upgrade.ts @@ -0,0 +1,246 @@ +export type SynSecGitHubAppUpgradeIssueCode = + | "invalid-release-id" + | "invalid-schema-version" + | "invalid-observation-time" + | "duplicate-replica" + | "missing-replica" + | "unexpected-replica" + | "stale-observation" + | "future-observation" + | "replica-not-ready" + | "mixed-schema" + | "target-schema-mismatch" + | "worker-admission-open" + | "active-work-remains" + | "rollback-schema-incompatible" + | "previous-release-unavailable"; + +export interface SynSecGitHubAppReplicaUpgradeObservation { + replicaId: string; + releaseId: string; + schemaVersion: number; + ready: boolean; + /** True only while this replica can admit a new background worker run/queue claim. */ + acceptingWorkerRuns: boolean; + /** Durable fenced leases observed from the shared backend, not the local worker-run count. */ + activeLeases: number; + observedAt: string; +} + +export interface SynSecGitHubAppUpgradeAssessmentInput { + currentReleaseId: string; + targetReleaseId: string; + currentSchemaVersion: number; + targetSchemaVersion: number; + expectedReplicaIds: readonly string[]; + replicas: readonly SynSecGitHubAppReplicaUpgradeObservation[]; + assessedAt: string; + /** Maximum acceptable age for supervisor observations. Defaults to 5 minutes. */ + maxObservationAgeMs?: number; + /** Operator assertion that the previous immutable application artifact is still deployable. */ + previousReleaseAvailable: boolean; + /** Explicit migration property. False means a schema change blocks automatic rollback. */ + rollbackSchemaCompatible: boolean; +} + +export interface SynSecGitHubAppUpgradeIssue { + code: SynSecGitHubAppUpgradeIssueCode; + replicaId?: string; +} + +export interface SynSecGitHubAppUpgradeAssessment { + readyToBeginRollout: boolean; + readyToFinalizeRollout: boolean; + rollbackAllowed: boolean; + targetReplicaCount: number; + previousReplicaCount: number; + issues: SynSecGitHubAppUpgradeIssue[]; +} + +const DEFAULT_MAX_OBSERVATION_AGE_MS = 5 * 60 * 1000; +const MIN_MAX_OBSERVATION_AGE_MS = 10_000; +const MAX_MAX_OBSERVATION_AGE_MS = 60 * 60 * 1000; +const MAX_REPLICAS = 1_000; +const MAX_RELEASE_ID_LENGTH = 128; +const RELEASE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const REPLICA_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; + +function releaseId(value: string): string | undefined { + return typeof value === "string" + && value.length <= MAX_RELEASE_ID_LENGTH + && RELEASE_ID_PATTERN.test(value) + ? value + : undefined; +} + +function replicaId(value: string): string | undefined { + return typeof value === "string" + && value.length <= 128 + && REPLICA_ID_PATTERN.test(value) + ? value + : undefined; +} + +function schemaVersion(value: number): number | undefined { + return Number.isSafeInteger(value) && value > 0 && value <= 2_147_483_647 ? value : undefined; +} + +function canonicalTimestamp(value: string): number | undefined { + if (typeof value !== "string" || value.length > 64) return undefined; + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) return undefined; + return new Date(parsed).toISOString() === value ? parsed : undefined; +} + +function maxObservationAge(value: number | undefined): number { + const resolved = value ?? DEFAULT_MAX_OBSERVATION_AGE_MS; + if (!Number.isSafeInteger(resolved) || resolved < MIN_MAX_OBSERVATION_AGE_MS || resolved > MAX_MAX_OBSERVATION_AGE_MS) { + throw new Error( + `GitHub App upgrade observation age must be between ${MIN_MAX_OBSERVATION_AGE_MS} and ${MAX_MAX_OBSERVATION_AGE_MS} milliseconds.`, + ); + } + return resolved; +} + +/** + * Assess one rolling GitHub App release transition from trusted supervisor observations. + * + * This function never performs a deployment, migration, drain, credential operation, or rollback. + * It is a fail-closed gate for an external service manager. A replica is drained only when worker + * admission is closed and the shared durable backend reports zero active fenced leases. A local + * zero-run observation or a momentary zero-lease count while admission remains open is insufficient. + * Repository content, webhook input, and scanner output must never supply these observations. + */ +export function assessSynSecGitHubAppUpgrade( + input: SynSecGitHubAppUpgradeAssessmentInput, +): SynSecGitHubAppUpgradeAssessment { + const issues: SynSecGitHubAppUpgradeIssue[] = []; + const currentRelease = releaseId(input.currentReleaseId); + const targetRelease = releaseId(input.targetReleaseId); + if (!currentRelease || !targetRelease || currentRelease === targetRelease) { + issues.push({ code: "invalid-release-id" }); + } + + const currentSchema = schemaVersion(input.currentSchemaVersion); + const targetSchema = schemaVersion(input.targetSchemaVersion); + if (!currentSchema || !targetSchema) issues.push({ code: "invalid-schema-version" }); + + const assessedAt = canonicalTimestamp(input.assessedAt); + if (assessedAt === undefined) issues.push({ code: "invalid-observation-time" }); + const ageLimit = maxObservationAge(input.maxObservationAgeMs); + + if (!Array.isArray(input.expectedReplicaIds) || input.expectedReplicaIds.length < 1 || input.expectedReplicaIds.length > MAX_REPLICAS) { + issues.push({ code: "missing-replica" }); + } + const expected = new Set(); + for (const raw of input.expectedReplicaIds) { + const id = replicaId(raw); + if (!id || expected.has(id)) { + issues.push({ code: "duplicate-replica", ...(id ? { replicaId: id } : {}) }); + continue; + } + expected.add(id); + } + + const seen = new Set(); + let targetReplicaCount = 0; + let previousReplicaCount = 0; + let allReady = true; + let allDrained = true; + let allTargetSchema = true; + + for (const observation of input.replicas) { + const id = replicaId(observation.replicaId); + if (!id || seen.has(id)) { + issues.push({ code: "duplicate-replica", ...(id ? { replicaId: id } : {}) }); + continue; + } + seen.add(id); + if (!expected.has(id)) issues.push({ code: "unexpected-replica", replicaId: id }); + + const observedAt = canonicalTimestamp(observation.observedAt); + if (observedAt === undefined || assessedAt === undefined) { + issues.push({ code: "invalid-observation-time", replicaId: id }); + } else { + if (observedAt > assessedAt + 30_000) issues.push({ code: "future-observation", replicaId: id }); + if (assessedAt - observedAt > ageLimit) issues.push({ code: "stale-observation", replicaId: id }); + } + + if (!observation.ready) { + allReady = false; + issues.push({ code: "replica-not-ready", replicaId: id }); + } + if (observation.acceptingWorkerRuns !== false) { + allDrained = false; + issues.push({ code: "worker-admission-open", replicaId: id }); + } + if (!Number.isSafeInteger(observation.activeLeases) || observation.activeLeases < 0 || observation.activeLeases > 1_000_000) { + allDrained = false; + issues.push({ code: "active-work-remains", replicaId: id }); + } else if (observation.activeLeases > 0) { + allDrained = false; + issues.push({ code: "active-work-remains", replicaId: id }); + } + + const observedSchema = schemaVersion(observation.schemaVersion); + if (!observedSchema) { + allTargetSchema = false; + issues.push({ code: "invalid-schema-version", replicaId: id }); + } else if (targetSchema && observedSchema !== targetSchema) { + allTargetSchema = false; + issues.push({ code: observation.releaseId === targetRelease ? "target-schema-mismatch" : "mixed-schema", replicaId: id }); + } + + if (observation.releaseId === targetRelease) targetReplicaCount += 1; + else if (observation.releaseId === currentRelease) previousReplicaCount += 1; + else issues.push({ code: "invalid-release-id", replicaId: id }); + } + + for (const id of expected) { + if (!seen.has(id)) issues.push({ code: "missing-replica", replicaId: id }); + } + + const structuralErrors = issues.some((issue) => [ + "invalid-release-id", + "invalid-schema-version", + "invalid-observation-time", + "duplicate-replica", + "missing-replica", + "unexpected-replica", + "stale-observation", + "future-observation", + ].includes(issue.code)); + + const schemaChanged = currentSchema !== undefined && targetSchema !== undefined && currentSchema !== targetSchema; + const rollbackAllowed = !structuralErrors + && input.previousReleaseAvailable + && (!schemaChanged || input.rollbackSchemaCompatible); + if (!input.previousReleaseAvailable) issues.push({ code: "previous-release-unavailable" }); + if (schemaChanged && !input.rollbackSchemaCompatible) issues.push({ code: "rollback-schema-incompatible" }); + + const exactReplicaCoverage = seen.size === expected.size && [...seen].every((id) => expected.has(id)); + const readyToBeginRollout = !structuralErrors + && exactReplicaCoverage + && rollbackAllowed + && allReady + && allDrained + && previousReplicaCount === expected.size + && targetReplicaCount === 0; + + const readyToFinalizeRollout = !structuralErrors + && exactReplicaCoverage + && allReady + && allDrained + && allTargetSchema + && targetReplicaCount === expected.size + && previousReplicaCount === 0; + + return { + readyToBeginRollout, + readyToFinalizeRollout, + rollbackAllowed, + targetReplicaCount, + previousReplicaCount, + issues, + }; +} diff --git a/packages/github/src/app-worker-drain.ts b/packages/github/src/app-worker-drain.ts new file mode 100644 index 00000000..54289ed7 --- /dev/null +++ b/packages/github/src/app-worker-drain.ts @@ -0,0 +1,109 @@ +const DEFAULT_DRAIN_TIMEOUT_MS = 30_000; +const MIN_DRAIN_TIMEOUT_MS = 1_000; +const MAX_DRAIN_TIMEOUT_MS = 120_000; + +export interface SynSecGitHubAppWorkerDrainStatus { + acceptingWorkerRuns: boolean; + activeWorkerRuns: number; +} + +export type SynSecGitHubAppWorkerAdmissionResult = + | { admitted: true; value: T } + | { admitted: false }; + +export interface SynSecGitHubAppWorkerDrainController { + beginDrain(): SynSecGitHubAppWorkerDrainStatus; + resumeAdmission(): SynSecGitHubAppWorkerDrainStatus; + status(): SynSecGitHubAppWorkerDrainStatus; + run(operation: () => Promise): Promise>; + waitForDrained(timeoutMs?: number): Promise; +} + +function boundedTimeout(value: number | undefined): number { + const resolved = value ?? DEFAULT_DRAIN_TIMEOUT_MS; + if (!Number.isSafeInteger(resolved) || resolved < MIN_DRAIN_TIMEOUT_MS || resolved > MAX_DRAIN_TIMEOUT_MS) { + throw new Error( + `GitHub App worker drain timeout must be between ${MIN_DRAIN_TIMEOUT_MS} and ${MAX_DRAIN_TIMEOUT_MS} milliseconds.`, + ); + } + return resolved; +} + +/** + * Enforce one replica's background-worker admission boundary during maintenance or rollout. + * + * beginDrain() is synchronous: after it returns, subsequent run() calls are rejected before their + * operation executes, so a correctly integrated worker cannot reach queue.claimNext(). Operations + * admitted before the boundary closed are allowed to finish normally, including lease heartbeat and + * fenced terminal transitions owned by the worker. This controller never cancels or steals a lease. + * + * activeWorkerRuns counts only operations admitted through this in-process controller. It is not a + * durable queue lease count and must not be used as fleet-wide drain proof after crashes/restarts. + */ +export function createSynSecGitHubAppWorkerDrainController(): SynSecGitHubAppWorkerDrainController { + let acceptingWorkerRuns = true; + let activeWorkerRuns = 0; + const drainedWaiters = new Set<() => void>(); + + const currentStatus = (): SynSecGitHubAppWorkerDrainStatus => ({ + acceptingWorkerRuns, + activeWorkerRuns, + }); + + const notifyDrained = (): void => { + if (activeWorkerRuns !== 0) return; + for (const resolve of drainedWaiters) resolve(); + drainedWaiters.clear(); + }; + + return { + beginDrain() { + acceptingWorkerRuns = false; + return currentStatus(); + }, + resumeAdmission() { + acceptingWorkerRuns = true; + return currentStatus(); + }, + status: currentStatus, + async run(operation: () => Promise): Promise> { + if (typeof operation !== "function") throw new Error("GitHub App worker operation is required."); + if (!acceptingWorkerRuns) return { admitted: false }; + + activeWorkerRuns += 1; + try { + return { admitted: true, value: await operation() }; + } finally { + activeWorkerRuns -= 1; + notifyDrained(); + } + }, + async waitForDrained(timeoutMs?: number): Promise { + if (acceptingWorkerRuns) { + throw new Error("GitHub App worker admission must be draining before waiting for worker runs to drain."); + } + if (activeWorkerRuns === 0) return; + const timeout = boundedTimeout(timeoutMs); + await new Promise((resolve, reject) => { + let settled = false; + let timer: NodeJS.Timeout; + const onDrained = (): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + drainedWaiters.delete(onDrained); + resolve(); + }; + drainedWaiters.add(onDrained); + timer = setTimeout(() => { + if (settled) return; + settled = true; + drainedWaiters.delete(onDrained); + reject(new Error("GitHub App worker admission drain did not complete before the configured timeout.")); + }, timeout); + timer.unref?.(); + if (activeWorkerRuns === 0) onDrained(); + }); + }, + }; +} diff --git a/packages/github/src/app-worker-host.ts b/packages/github/src/app-worker-host.ts new file mode 100644 index 00000000..cebfa57e --- /dev/null +++ b/packages/github/src/app-worker-host.ts @@ -0,0 +1,178 @@ +import type { SynSecConfig } from "@synsec/config"; +import { runScanEngine } from "@synsec/engine"; +import { + createOciIsolatedScanners, + withBuiltInScannerFactory, +} from "@synsec/scanners"; +import { parseGitHubAppHostProfile, type NormalizedGitHubAppHostProfile } from "./app-host-profile.js"; +import { createSynSecGitHubAppWorkerDrainController, type SynSecGitHubAppWorkerDrainController } from "./app-worker-drain.js"; +import { + runConfiguredGitHubAppWorkerOnce, + type ConfiguredGitHubAppWorkerResult, +} from "./app-worker-runner.js"; +import { createGitHubAppInstallationTokenProvider } from "./app-token-provider.js"; +import { createGitHubAppRuntimeCredentialSource, type GitHubAppRuntimeCredentialSnapshot, type GitHubAppRuntimeCredentialStatus } from "./runtime-credentials.js"; +import { loadMountedGitHubAppRuntimeCredentialSnapshot } from "./mounted-runtime-credentials.js"; +import { + buildSynSecGitHubPostgresBackendContract, + createSynSecGitHubPostgresSharedStores, + migrateSynSecGitHubPostgresBackend, +} from "./postgres-shared-backend.js"; +import type { PostgresGitHubSharedStateOptions, PostgresPoolLike } from "./postgres-shared-state.js"; +import { assessGitHubAppSharedStateConformanceEvidence } from "./shared-state-evidence.js"; + +const OCI_WORKER_SCANNERS = new Set(["checkov", "grype", "syft"]); + +export interface SynSecGitHubAppWorkerHostOptions { + /** Exact-keyed non-secret deployment profile shared with the intake role. */ + profile: unknown; + /** Caller-owned PostgreSQL pool. Connection material never enters returned status. */ + pool: PostgresPoolLike; + /** Canonical real-backend conformance report bound to the exact built-in PostgreSQL adapter. */ + conformanceReport: unknown; + /** Trusted worker scan configuration. Hosted OCI workers currently accept Checkov, Grype, and Syft. */ + config: SynSecConfig; + sharedStateOptions?: PostgresGitHubSharedStateOptions; + publishSarif?: boolean; + toolVersion?: string; + apiVersion?: string; + userAgent?: string; + fetch?: typeof globalThis.fetch; + /** Test/hosting seam. Defaults to the fixed-filename mounted credential loader. */ + loadCredentials?: () => Promise; +} + +export interface SynSecGitHubAppWorkerHost { + readonly profile: NormalizedGitHubAppHostProfile; + readonly drain: SynSecGitHubAppWorkerDrainController; + readonly interpretation: "executable-fenced-worker-with-enforced-oci-subset-not-fleet-readiness-or-complete-coverage"; + credentialStatus(): GitHubAppRuntimeCredentialStatus; + reloadCredentials(): Promise; + runOnce(): Promise; + beginDrain(): void; + resumeAdmission(): void; + close(timeoutMs?: number): Promise; +} + +/** + * Validate the scanner set that the executable hosted worker can truthfully isolate today. + * + * The current enforced OCI integration supports Checkov, Grype, and Syft because all three adapters + * accept the sandbox process runner for availability checks and scan execution. Rejecting every other + * selected scanner is intentional: the worker must never silently fall back to host execution merely + * to gain scanner breadth. AI review is likewise disabled in this role because it is a separate + * outbound trust boundary and is not part of the scanner sandbox contract. + */ +export function assertGitHubAppOciWorkerConfig(config: SynSecConfig): void { + if (!config || config.schemaVersion !== 1 || !Array.isArray(config.scanners)) { + throw new Error("GitHub App OCI worker requires a valid SynSec configuration."); + } + if (config.scanners.length === 0) throw new Error("GitHub App OCI worker requires at least one isolated scanner."); + const selected = new Set(); + for (const scanner of config.scanners) { + if (typeof scanner !== "string" || !OCI_WORKER_SCANNERS.has(scanner)) { + throw new Error("GitHub App OCI worker configuration contains a scanner without enforced hosted isolation support."); + } + if (selected.has(scanner)) throw new Error("GitHub App OCI worker configuration contains duplicate scanner ids."); + selected.add(scanner); + } + if (config.ai?.enabled) { + throw new Error("GitHub App OCI worker does not enable AI review inside the scanner-isolation role."); + } +} + +/** + * Compose one production worker replica around the durable PostgreSQL queue and enforced OCI subset. + * + * Activation order is fail-closed: validate the secret-free profile and worker scanner policy, verify + * exact canonical PostgreSQL conformance evidence, then load credentials, then migrate/construct the + * durable stores. Each run enters the worker-drain admission boundary before queue.claimNext(). The + * normal worker then owns the durable lease heartbeat/fence, rechecks installation authorization, + * acquires exact commits with a short-lived installation token, and obtains a fresh publication token. + * + * Scanner availability and scan execution run under an AsyncLocalStorage-scoped factory containing + * only digest-pinned OCI adapters rooted at the acquired repository. The sandbox itself enforces + * network=none, read-only repository/root filesystems, separate tmpfs scratch, non-root execution, + * dropped capabilities, no-new-privileges, and bounded CPU/memory/PIDs. GitHub credentials stay in + * the host acquisition/publication layers and are never supplied as scanner environment variables. + * + * This role intentionally rejects unsupported scanner ids rather than falling back to host execution. + * Therefore successful execution is evidence for the enforced Checkov/Grype/Syft worker path only, + * not proof of complete scanner coverage, fleet readiness, runtime exploitability, or absence of vulnerabilities. + */ +export async function createSynSecGitHubAppWorkerHost( + options: SynSecGitHubAppWorkerHostOptions, +): Promise { + if (!options || typeof options !== "object") throw new Error("GitHub App worker host options are required."); + const profile = parseGitHubAppHostProfile(options.profile); + assertGitHubAppOciWorkerConfig(options.config); + + const contract = buildSynSecGitHubPostgresBackendContract(); + const evidence = assessGitHubAppSharedStateConformanceEvidence(contract, options.conformanceReport); + if (!evidence.ready) { + throw new Error(`GitHub App worker host shared-state evidence is not ready: ${evidence.issues.map((issue) => issue.code).join(", ")}`); + } + + const loadCredentials = options.loadCredentials + ?? (() => loadMountedGitHubAppRuntimeCredentialSnapshot(profile.credentialDirectory)); + const credentialSource = createGitHubAppRuntimeCredentialSource(await loadCredentials()); + + await migrateSynSecGitHubPostgresBackend(options.pool); + const stores = createSynSecGitHubPostgresSharedStores(options.pool, options.sharedStateOptions); + const drain = createSynSecGitHubAppWorkerDrainController(); + const getInstallationToken = createGitHubAppInstallationTokenProvider({ + appId: profile.appId, + privateKey: () => credentialSource.getPrivateKey(), + ...(options.apiVersion ? { apiVersion: options.apiVersion } : {}), + ...(options.userAgent ? { userAgent: options.userAgent } : {}), + ...(options.fetch ? { fetch: options.fetch } : {}), + requiredPermissionsByPurpose: { + acquire: { contents: "read" }, + publish: { + checks: "write", + ...(options.publishSarif ? { security_events: "write" as const } : {}), + }, + }, + }); + + const scan = async (input: Parameters[0]) => withBuiltInScannerFactory( + () => createOciIsolatedScanners({ + runtimeCommand: profile.scannerRuntimeCommand, + image: profile.scannerImage, + repositoryRoot: input.rootPath, + }), + () => runScanEngine(input), + ); + + return { + profile, + drain, + interpretation: "executable-fenced-worker-with-enforced-oci-subset-not-fleet-readiness-or-complete-coverage", + credentialStatus: () => credentialSource.getStatus(), + reloadCredentials: () => credentialSource.reload(loadCredentials), + runOnce: () => runConfiguredGitHubAppWorkerOnce({ + queue: stores.queue, + installationStore: stores.installationStore, + config: options.config, + getInstallationToken: (installationId, purpose) => getInstallationToken(installationId, purpose), + workerDrain: drain, + scan, + publishSarif: options.publishSarif, + toolVersion: options.toolVersion, + acquisitionOptions: { workspaceRoot: profile.workspaceDirectory }, + ...(options.apiVersion ? { apiVersion: options.apiVersion } : {}), + ...(options.userAgent ? { userAgent: options.userAgent } : {}), + ...(options.fetch ? { fetch: options.fetch } : {}), + }), + beginDrain() { + drain.beginDrain(); + }, + resumeAdmission() { + drain.resumeAdmission(); + }, + async close(timeoutMs?: number): Promise { + drain.beginDrain(); + await drain.waitForDrained(timeoutMs); + }, + }; +} diff --git a/packages/github/src/app-worker-runner.ts b/packages/github/src/app-worker-runner.ts new file mode 100644 index 00000000..28729aa5 --- /dev/null +++ b/packages/github/src/app-worker-runner.ts @@ -0,0 +1,155 @@ +import type { SynSecConfig } from "@synsec/config"; +import { runScanEngine, type ScanEngineOutcome } from "@synsec/engine"; +import { + runNextGitHubAppScanJob, + type GitHubAppWorkerAuthorizer, + type GitHubAppWorkerQueue, + type GitHubAppWorkerResult, + type GitHubInstallationTokenPurpose, +} from "./app-worker.js"; +import type { SynSecGitHubAppWorkerDrainController } from "./app-worker-drain.js"; +import { + acquireGitHubRepositoryScanTarget, + type GitHubRepositoryAcquisitionOptions, +} from "./repository-acquisition.js"; +import { deriveExactChangedFiles, type ExactTreeDiffPlan } from "./exact-tree-diff.js"; +import { buildGitHubCheck, type GitHubCheckThreshold, type GitHubPullRequestContext } from "./index.js"; +import { publishGitHubCheck, type GitHubPublisherOptions } from "./publisher.js"; +import { publishGitHubSarif } from "./sarif-publisher.js"; + +export interface ConfiguredGitHubAppWorkerOptions extends GitHubPublisherOptions { + queue: GitHubAppWorkerQueue; + installationStore: GitHubAppWorkerAuthorizer; + config: SynSecConfig; + getInstallationToken(installationId: number, purpose: GitHubInstallationTokenPurpose): Promise; + /** Optional enforced local admission boundary for safe maintenance/rolling replacement. */ + workerDrain?: SynSecGitHubAppWorkerDrainController; + threshold?: GitHubCheckThreshold; + publishSarif?: boolean; + toolVersion?: string; + scan?: typeof runScanEngine; + acquire?: typeof acquireGitHubRepositoryScanTarget; + deriveChangedFiles?: typeof deriveExactChangedFiles; + acquisitionOptions?: GitHubRepositoryAcquisitionOptions; +} + +export type ConfiguredGitHubAppWorkerResult = GitHubAppWorkerResult | { status: "draining" }; + +function contextForJob(job: { + repository: string; + headSha: string; + event: "push" | "pull_request"; + baseSha?: string; + pullRequestNumber?: number; +}): GitHubPullRequestContext { + return { + repository: job.repository, + sha: job.headSha, + ...(job.event === "pull_request" && job.baseSha ? { baseSha: job.baseSha } : {}), + ...(job.event === "pull_request" && job.pullRequestNumber + ? { pullRequestNumber: job.pullRequestNumber } + : {}), + }; +} + +function requireCommit(reportCommitSha: string | undefined, expectedSha: string, label: string): void { + const actual = reportCommitSha?.trim().toLowerCase(); + if (!actual || actual !== expectedSha.toLowerCase()) { + throw new Error(`${label} report commit does not match the queued GitHub commit SHA.`); + } +} + +function useTargetedHeadScan(plan: ExactTreeDiffPlan | undefined, publishSarif: boolean | undefined): boolean { + // A partial SARIF analysis can make untouched alerts appear absent to code scanning, so hosted + // workers keep SARIF-enabled jobs repository-wide until a merge-safe partial publication contract + // is implemented. Empty diffs likewise stay full to avoid adapter-specific empty-scope semantics. + return Boolean( + plan + && plan.mode === "changed-files" + && plan.changedFiles.length > 0 + && !publishSarif, + ); +} + +async function runConfiguredWorkerOperation( + options: ConfiguredGitHubAppWorkerOptions, +): Promise { + const scan = options.scan ?? runScanEngine; + const acquire = options.acquire ?? acquireGitHubRepositoryScanTarget; + const deriveChangedFiles = options.deriveChangedFiles ?? deriveExactChangedFiles; + + return runNextGitHubAppScanJob({ + queue: options.queue, + installationStore: options.installationStore, + getInstallationToken: options.getInstallationToken, + acquire, + acquisitionOptions: options.acquisitionOptions, + scan: async (job, workspace, baseWorkspace) => { + let baseline: ScanEngineOutcome["report"] | undefined; + let exactDiff: ExactTreeDiffPlan | undefined; + if (job.event === "pull_request") { + if (!job.baseSha || !baseWorkspace) { + throw new Error("Hosted pull-request scan requires the exact acquired base workspace."); + } + const baseOutcome: ScanEngineOutcome = await scan({ + rootPath: baseWorkspace, + config: options.config, + toolVersion: options.toolVersion, + changedOnly: false, + }); + requireCommit(baseOutcome.report.target.commitSha, job.baseSha, "GitHub App baseline"); + baseline = baseOutcome.report; + exactDiff = await deriveChangedFiles(baseWorkspace, workspace); + } + + const targeted = useTargetedHeadScan(exactDiff, options.publishSarif); + const outcome: ScanEngineOutcome = await scan({ + rootPath: workspace, + config: options.config, + toolVersion: options.toolVersion, + ...(baseline ? { baseline } : {}), + changedOnly: targeted, + ...(targeted && job.baseSha && exactDiff + ? { changedBase: job.baseSha, changedFiles: exactDiff.changedFiles } + : {}), + }); + requireCommit(outcome.report.target.commitSha, job.headSha, "GitHub App head"); + return outcome.report; + }, + publish: async (job, report, installationToken) => { + const context = contextForJob(job); + const check = buildGitHubCheck(report, context, { + threshold: options.threshold, + onlyNewAnnotations: Boolean(report.baseline), + }); + await publishGitHubCheck(check, context, installationToken, { + apiVersion: options.apiVersion, + userAgent: options.userAgent, + fetch: options.fetch, + }); + if (options.publishSarif) { + await publishGitHubSarif(report, context, installationToken, { + apiVersion: options.apiVersion, + userAgent: options.userAgent, + fetch: options.fetch, + }); + } + }, + }); +} + +/** + * Execute at most one configured hosted-App job through SynSec's repository scan engine. + * + * When workerDrain is supplied, admission is checked synchronously before claimNext() can run. + * beginDrain() therefore closes new local queue claims while work admitted before the boundary keeps + * its existing fenced lease/heartbeat until normal completion. A draining result means no queue claim + * was attempted by this invocation; it is not evidence that other replicas or durable leases drained. + */ +export async function runConfiguredGitHubAppWorkerOnce( + options: ConfiguredGitHubAppWorkerOptions, +): Promise { + if (!options.workerDrain) return runConfiguredWorkerOperation(options); + const admitted = await options.workerDrain.run(() => runConfiguredWorkerOperation(options)); + return admitted.admitted ? admitted.value : { status: "draining" }; +} diff --git a/packages/github/src/app-worker.ts b/packages/github/src/app-worker.ts new file mode 100644 index 00000000..fc226d4a --- /dev/null +++ b/packages/github/src/app-worker.ts @@ -0,0 +1,180 @@ +import type { SynSecReport } from "@synsec/report"; +import { sanitizeOperationalText } from "@synsec/scanner-sdk"; +import { + acquireGitHubRepositoryScanTarget, + type AcquiredGitHubScanTarget, + type GitHubRepositoryAcquisitionOptions, +} from "./repository-acquisition.js"; +import type { GitHubScanJob } from "./scan-queue.js"; + +export interface GitHubAppWorkerQueue { + leaseMs?: number; + claimNext(): Promise; + assertLease(jobId: string, expectedLeaseId: string): Promise; + renew?(jobId: string, expectedLeaseId: string): Promise; + release(jobId: string, expectedLeaseId: string): Promise; + fail(jobId: string, expectedLeaseId: string): Promise; + complete(jobId: string, expectedLeaseId: string): Promise; +} + +export interface GitHubAppWorkerAuthorizer { + isRepositoryAllowed(installationId: number, repository: string): Promise; +} + +export type GitHubInstallationTokenPurpose = "acquire" | "publish"; + +export interface GitHubAppWorkerOptions { + queue: GitHubAppWorkerQueue; + installationStore: GitHubAppWorkerAuthorizer; + getInstallationToken(installationId: number, purpose: GitHubInstallationTokenPurpose): Promise; + scan(job: GitHubScanJob, workspace: string, baseWorkspace?: string): Promise; + publish(job: GitHubScanJob, report: SynSecReport, installationToken: string): Promise; + acquire?: ( + input: { repository: string; commitSha: string; baseCommitSha?: string; installationToken: string }, + options?: GitHubRepositoryAcquisitionOptions, + ) => Promise; + acquisitionOptions?: GitHubRepositoryAcquisitionOptions; +} + +export type GitHubAppWorkerResult = + | { status: "idle" } + | { status: "completed"; job: GitHubScanJob; reportId: string } + | { status: "revoked"; job: GitHubScanJob } + | { status: "retry_scheduled"; job: GitHubScanJob; error: string }; + +function safeError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return sanitizeOperationalText(message, 1000) || "GitHub App worker failed."; +} + +interface LeaseHeartbeat { + stop(): Promise; + assertHealthy(): void; +} + +function startLeaseHeartbeat(queue: GitHubAppWorkerQueue, job: GitHubScanJob): LeaseHeartbeat { + if (!queue.renew || !queue.leaseMs || !job.leaseId) { + return { stop: async () => {}, assertHealthy: () => {} }; + } + + const intervalMs = Math.max(1_000, Math.floor(queue.leaseMs / 3)); + let stopped = false; + let failure: unknown; + let timer: ReturnType | undefined; + let inFlight: Promise | undefined; + + const schedule = (): void => { + if (stopped || failure) return; + timer = setTimeout(() => { + inFlight = (async () => { + try { + await queue.renew?.(job.jobId, job.leaseId as string); + } catch (error) { + failure = error; + } finally { + inFlight = undefined; + schedule(); + } + })(); + }, intervalMs); + timer.unref?.(); + }; + schedule(); + + return { + stop: async () => { + stopped = true; + if (timer) clearTimeout(timer); + if (inFlight) await inFlight; + }, + assertHealthy: () => { + if (failure) throw new Error(`GitHub scan job lease renewal failed: ${safeError(failure)}`); + }, + }; +} + +/** + * Consume at most one durable GitHub App scan job. + * + * Authorization is checked again after lease acquisition so a repository removed or suspended + * after webhook queueing is never scanned from stale authorization. Installation credentials are + * obtained only in the transport layer: one short-lived token for exact-commit acquisition and a + * fresh token for publication. For PR jobs, acquisition can also materialize the exact queued base + * commit in a second isolated workspace without exposing credentials to the scanner. New leases use + * a random durable lease id as a fencing token. The local queue renews that exact lease while work is + * active, revalidates it immediately before publication, and requires the same id for retry/terminal + * mutations so an expired or concurrently superseded worker cannot publish or mutate newer work. + * Operational errors are sanitized before they enter retry/status results so transport or tool + * failures cannot echo bearer tokens, URL credentials, or other common secret forms. + */ +export async function runNextGitHubAppScanJob(options: GitHubAppWorkerOptions): Promise { + const job = await options.queue.claimNext(); + if (!job) return { status: "idle" }; + const leaseId = job.leaseId?.trim(); + if (!leaseId) throw new Error("Claimed GitHub scan job is missing its lease fencing identity."); + + let acquired: AcquiredGitHubScanTarget | undefined; + const heartbeat = startLeaseHeartbeat(options.queue, job); + try { + const allowed = await options.installationStore.isRepositoryAllowed(job.installationId, job.repository); + if (!allowed) { + await heartbeat.stop(); + heartbeat.assertHealthy(); + await options.queue.fail(job.jobId, leaseId); + return { status: "revoked", job }; + } + + const acquisitionToken = await options.getInstallationToken(job.installationId, "acquire"); + const acquire = options.acquire ?? acquireGitHubRepositoryScanTarget; + acquired = await acquire({ + repository: job.repository, + commitSha: job.headSha, + ...(job.event === "pull_request" && job.baseSha ? { baseCommitSha: job.baseSha } : {}), + installationToken: acquisitionToken, + }, options.acquisitionOptions); + + if (acquired.repository !== job.repository || acquired.commitSha.toLowerCase() !== job.headSha.toLowerCase()) { + throw new Error("Acquired GitHub repository does not match the leased scan job provenance."); + } + if (job.event === "pull_request") { + const acquiredBaseSha = acquired.base?.commitSha.toLowerCase(); + if (!job.baseSha || !acquiredBaseSha || acquiredBaseSha !== job.baseSha.toLowerCase()) { + throw new Error("Acquired GitHub base repository does not match the leased pull-request base SHA."); + } + } + + const report = await options.scan(job, acquired.workspace, acquired.base?.workspace); + const reportSha = report.target.commitSha?.trim().toLowerCase(); + if (!reportSha || reportSha !== job.headSha.toLowerCase()) { + throw new Error("GitHub App worker report commit does not match the leased scan job head SHA."); + } + + heartbeat.assertHealthy(); + await options.queue.assertLease(job.jobId, leaseId); + const publicationToken = await options.getInstallationToken(job.installationId, "publish"); + await options.publish(job, report, publicationToken); + await heartbeat.stop(); + heartbeat.assertHealthy(); + if (!await options.queue.complete(job.jobId, leaseId)) { + throw new Error("Completed GitHub App scan job disappeared before queue acknowledgement."); + } + return { status: "completed", job, reportId: report.reportId }; + } catch (error) { + await heartbeat.stop(); + let effectiveError: unknown = error; + try { + heartbeat.assertHealthy(); + } catch (heartbeatError) { + effectiveError = new Error(`${safeError(error)} ${safeError(heartbeatError)}`); + } + try { + await options.queue.release(job.jobId, leaseId); + } catch (releaseError) { + throw new Error(`${safeError(effectiveError)} Queue release also failed: ${safeError(releaseError)}`); + } + return { status: "retry_scheduled", job, error: safeError(effectiveError) }; + } finally { + await heartbeat.stop(); + if (acquired) await acquired.cleanup(); + } +} diff --git a/packages/github/src/app.ts b/packages/github/src/app.ts new file mode 100644 index 00000000..e4fcddfb --- /dev/null +++ b/packages/github/src/app.ts @@ -0,0 +1,305 @@ +import { createHmac, sign as cryptoSign, timingSafeEqual } from "node:crypto"; + +const MAX_WEBHOOK_BYTES = 10 * 1024 * 1024; +const APP_JWT_LIFETIME_SECONDS = 9 * 60; +const MAX_WEBHOOK_SECRETS = 2; +const MAX_WEBHOOK_SECRET_BYTES = 4096; +const SCANNABLE_PULL_REQUEST_ACTIONS = new Set(["opened", "reopened", "synchronize", "ready_for_review"]); + +export type GitHubWebhookSecret = string | readonly string[]; + +export interface GitHubAppTokenOptions { + apiVersion?: string; + userAgent?: string; + fetch?: typeof globalThis.fetch; +} + +export type GitHubInstallationPermissionLevel = "read" | "write"; +export type GitHubInstallationPermissions = Record; + +export interface GitHubInstallationToken { + token: string; + expiresAt: string; + permissions?: GitHubInstallationPermissions; + repositorySelection?: "all" | "selected"; +} + +export interface GitHubAppWebhook { + event: "pull_request" | "push" | "installation" | "installation_repositories"; + action?: string; + deliveryId?: string; + installationId?: number; + repository?: string; + headSha?: string; + baseSha?: string; + pullRequestNumber?: number; +} + +function nonEmpty(value: string, label: string): string { + const normalized = value.trim(); + if (!normalized) throw new Error(`${label} is required.`); + return normalized; +} + +function positiveInteger(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive integer.`); + } + return value; +} + +function base64url(value: string | Uint8Array): string { + return Buffer.from(value).toString("base64url"); +} + +function rawBytes(body: string | Uint8Array): Buffer { + const bytes = typeof body === "string" ? Buffer.from(body, "utf8") : Buffer.from(body); + if (bytes.byteLength > MAX_WEBHOOK_BYTES) { + throw new Error(`GitHub webhook body exceeds the ${MAX_WEBHOOK_BYTES}-byte limit.`); + } + return bytes; +} + +function webhookSecrets(value: GitHubWebhookSecret): string[] { + const values = typeof value === "string" ? [value] : [...value]; + if (values.length < 1 || values.length > MAX_WEBHOOK_SECRETS) { + throw new Error(`GitHub webhook secret set must contain between 1 and ${MAX_WEBHOOK_SECRETS} secrets.`); + } + const result = values.map((entry) => { + const secret = nonEmpty(entry, "GitHub webhook secret"); + if (Buffer.byteLength(secret, "utf8") > MAX_WEBHOOK_SECRET_BYTES) { + throw new Error(`GitHub webhook secret exceeds ${MAX_WEBHOOK_SECRET_BYTES} bytes.`); + } + return secret; + }); + if (new Set(result).size !== result.length) { + throw new Error("GitHub webhook secret set contains duplicates."); + } + return result; +} + +function objectValue(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function integerValue(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; +} + +function repositoryName(payload: Record): string | undefined { + const repository = objectValue(payload.repository); + const fullName = stringValue(repository?.full_name); + return fullName && /^[^/\s]+\/[^/\s]+$/.test(fullName) ? fullName : undefined; +} + +function installationPermissions(value: unknown): GitHubInstallationPermissions | undefined { + if (value === undefined) return undefined; + const record = objectValue(value); + if (!record) throw new Error("GitHub installation-token API returned invalid permission metadata."); + const permissions: GitHubInstallationPermissions = {}; + for (const [name, level] of Object.entries(record)) { + const normalizedName = name.trim(); + if (!normalizedName || normalizedName.length > 128 || !/^[a-z0-9_]+$/i.test(normalizedName)) { + throw new Error("GitHub installation-token API returned invalid permission metadata."); + } + if (level !== "read" && level !== "write") { + throw new Error("GitHub installation-token API returned invalid permission metadata."); + } + permissions[normalizedName] = level; + } + return permissions; +} + +/** Verify GitHub's X-Hub-Signature-256 against one active secret or a bounded rotation pair. */ +export function verifyGitHubWebhookSignature( + body: string | Uint8Array, + signatureHeader: string | undefined, + webhookSecret: GitHubWebhookSecret, +): boolean { + const secrets = webhookSecrets(webhookSecret); + const signature = signatureHeader?.trim(); + if (!signature || !/^sha256=[a-f0-9]{64}$/i.test(signature)) return false; + + const bytes = rawBytes(body); + const supplied = Buffer.from(signature.slice("sha256=".length), "hex"); + let matched = false; + for (const secret of secrets) { + const expected = createHmac("sha256", secret).update(bytes).digest(); + const equal = supplied.byteLength === expected.byteLength && timingSafeEqual(supplied, expected); + matched = equal || matched; + } + return matched; +} + +/** + * Verify and normalize only GitHub App events SynSec currently understands. + * Scanner targets are never derived from arbitrary payload URLs. + */ +export function parseVerifiedGitHubAppWebhook(input: { + body: string | Uint8Array; + signatureHeader?: string; + webhookSecret: GitHubWebhookSecret; + eventName: string; + deliveryId?: string; +}): GitHubAppWebhook { + if (!verifyGitHubWebhookSignature(input.body, input.signatureHeader, input.webhookSecret)) { + throw new Error("GitHub webhook signature verification failed."); + } + + const eventName = nonEmpty(input.eventName, "GitHub event name"); + if (!["pull_request", "push", "installation", "installation_repositories"].includes(eventName)) { + throw new Error(`Unsupported GitHub App event: ${eventName}`); + } + + let payload: Record; + try { + const parsed = objectValue(JSON.parse(rawBytes(input.body).toString("utf8"))); + if (!parsed) throw new Error(); + payload = parsed; + } catch { + throw new Error("GitHub webhook body must be a JSON object."); + } + + const installationId = integerValue(objectValue(payload.installation)?.id); + const repository = repositoryName(payload); + const action = stringValue(payload.action); + const deliveryId = input.deliveryId?.trim() || undefined; + + if (eventName === "pull_request") { + const pullRequest = objectValue(payload.pull_request); + const headSha = stringValue(objectValue(pullRequest?.head)?.sha); + const baseSha = stringValue(objectValue(pullRequest?.base)?.sha); + const pullRequestNumber = integerValue(payload.number); + if (!repository || !installationId || !headSha || !baseSha || !pullRequestNumber) { + throw new Error("GitHub pull_request webhook is missing required repository, installation, PR, or commit identity."); + } + return { + event: "pull_request", + ...(action ? { action } : {}), + ...(deliveryId ? { deliveryId } : {}), + installationId, + repository, + headSha, + baseSha, + pullRequestNumber, + }; + } + + if (eventName === "push") { + const headSha = stringValue(payload.after); + if (!repository || !installationId || !headSha) { + throw new Error("GitHub push webhook is missing required repository, installation, or commit identity."); + } + return { + event: "push", + ...(deliveryId ? { deliveryId } : {}), + installationId, + repository, + headSha, + }; + } + + if (!installationId) throw new Error(`GitHub ${eventName} webhook is missing installation identity.`); + return { + event: eventName as "installation" | "installation_repositories", + ...(action ? { action } : {}), + ...(deliveryId ? { deliveryId } : {}), + installationId, + ...(repository ? { repository } : {}), + }; +} + +/** + * Decide whether a verified App event may enqueue a repository scan. + * Installation-management events are bookkeeping only and PR scans use an explicit action allowlist. + */ +export function shouldScanGitHubAppWebhook(event: GitHubAppWebhook): boolean { + if (event.event === "push") return Boolean(event.repository && event.headSha && event.installationId); + if (event.event !== "pull_request") return false; + return Boolean( + event.repository + && event.headSha + && event.baseSha + && event.pullRequestNumber + && event.installationId + && event.action + && SCANNABLE_PULL_REQUEST_ACTIONS.has(event.action), + ); +} + +/** Create a short-lived RS256 GitHub App JWT. */ +export function createGitHubAppJwt(appId: string | number, privateKey: string, now = Date.now()): string { + const issuer = String(appId).trim(); + if (!/^\d+$/.test(issuer) || issuer === "0") throw new Error("GitHub App id must be a positive integer."); + const key = nonEmpty(privateKey, "GitHub App private key"); + if (!Number.isFinite(now) || now <= 0) throw new Error("JWT clock must be a positive timestamp."); + + const issuedAt = Math.floor(now / 1000) - 30; + const expiresAt = issuedAt + APP_JWT_LIFETIME_SECONDS; + const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" })); + const payload = base64url(JSON.stringify({ iat: issuedAt, exp: expiresAt, iss: issuer })); + const signingInput = `${header}.${payload}`; + const signature = cryptoSign("RSA-SHA256", Buffer.from(signingInput), key); + return `${signingInput}.${base64url(signature)}`; +} + +/** Exchange an app JWT for one installation token using GitHub's fixed API host. */ +export async function createGitHubInstallationToken( + installationId: number, + appJwt: string, + options: GitHubAppTokenOptions = {}, +): Promise { + const id = positiveInteger(installationId, "GitHub installation id"); + const jwt = nonEmpty(appJwt, "GitHub App JWT"); + const fetchImpl = options.fetch ?? globalThis.fetch; + if (!fetchImpl) throw new Error("No fetch implementation is available for GitHub App authentication."); + + const response = await fetchImpl(`https://api.github.com/app/installations/${id}/access_tokens`, { + method: "POST", + redirect: "error", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/json", + "User-Agent": options.userAgent?.trim() || "synsec/0.2", + "X-GitHub-Api-Version": options.apiVersion?.trim() || "2022-11-28", + }, + body: "{}", + }); + + const text = await response.text(); + if (!response.ok) { + const detail = text.replace(/[\r\n]+/g, " ").slice(0, 500).trim(); + throw new Error(`GitHub installation-token API returned HTTP ${response.status}${detail ? `: ${detail}` : "."}`); + } + + let payload: Record; + try { + payload = objectValue(text ? JSON.parse(text) : {}) ?? {}; + } catch { + throw new Error("GitHub installation-token API returned invalid JSON."); + } + const token = stringValue(payload.token); + const expiresAt = stringValue(payload.expires_at); + if (!token || !expiresAt) throw new Error("GitHub installation-token API response is missing token metadata."); + if (!Number.isFinite(Date.parse(expiresAt))) { + throw new Error("GitHub installation-token API returned an invalid expiration timestamp."); + } + const permissions = installationPermissions(payload.permissions); + const selection = payload.repository_selection; + if (selection !== undefined && selection !== "all" && selection !== "selected") { + throw new Error("GitHub installation-token API returned invalid repository-selection metadata."); + } + return { + token, + expiresAt, + ...(permissions ? { permissions } : {}), + ...(selection ? { repositorySelection: selection } : {}), + }; +} diff --git a/packages/github/src/base-scan.ts b/packages/github/src/base-scan.ts new file mode 100644 index 00000000..7e5444e6 --- /dev/null +++ b/packages/github/src/base-scan.ts @@ -0,0 +1,83 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import type { SynSecConfig } from "@synsec/config"; +import { runScanEngine, type ScanEngineOutcome } from "@synsec/engine"; +import type { SynSecReport } from "@synsec/report"; +import { reportMatchesGitHubCommit } from "./orchestrator.js"; + +const execFileAsync = promisify(execFile); +const COMMIT_SHA = /^[0-9a-f]{7,40}$/i; + +export interface GitHubBaseScanOptions { + toolVersion?: string; + scan?: typeof runScanEngine; +} + +export interface GitHubBaseScanResult { + report: SynSecReport; + outcome: ScanEngineOutcome; +} + +async function git(rootPath: string, args: string[]): Promise { + await execFileAsync("git", ["-C", rootPath, ...args], { + encoding: "utf8", + maxBuffer: 1024 * 1024, + windowsHide: true, + }); +} + +/** + * Produce a baseline by scanning one exact commit already present in the local checkout. + * + * The commit is checked out into a temporary detached worktree and scanned with changed-file mode + * disabled. This helper never fetches from a remote, follows a repository-supplied URL, or changes + * the caller's working tree. The resulting report must identify the requested commit before it is + * accepted as baseline evidence. + */ +export async function scanGitHubBaseCommit( + config: SynSecConfig, + rootPath: string, + baseSha: string, + options: GitHubBaseScanOptions = {}, +): Promise { + const normalizedSha = baseSha.trim(); + if (!COMMIT_SHA.test(normalizedSha)) { + throw new Error("GitHub base scan requires a valid commit SHA."); + } + + const repositoryRoot = resolve(rootPath); + try { + await git(repositoryRoot, ["cat-file", "-e", `${normalizedSha}^{commit}`]); + } catch { + throw new Error( + "The pull-request base commit is not available in the local checkout. Configure actions/checkout with fetch-depth: 0 (or otherwise fetch the exact base commit) before running SynSec auto-baseline mode.", + ); + } + + const worktreePath = await mkdtemp(join(tmpdir(), "synsec-github-base-")); + let worktreeAdded = false; + try { + await git(repositoryRoot, ["worktree", "add", "--detach", worktreePath, normalizedSha]); + worktreeAdded = true; + const scan = options.scan ?? runScanEngine; + const outcome = await scan({ + rootPath: worktreePath, + config, + toolVersion: options.toolVersion, + changedOnly: false, + }); + const actual = outcome.report.target.commitSha?.trim(); + if (!actual || !reportMatchesGitHubCommit(actual, normalizedSha)) { + throw new Error("Automatic GitHub baseline scan did not produce a report bound to the requested base commit."); + } + return { report: outcome.report, outcome }; + } finally { + if (worktreeAdded) { + await git(repositoryRoot, ["worktree", "remove", "--force", worktreePath]).catch(() => undefined); + } + await rm(worktreePath, { recursive: true, force: true }).catch(() => undefined); + } +} diff --git a/packages/github/src/baseline.ts b/packages/github/src/baseline.ts new file mode 100644 index 00000000..31f6fad2 --- /dev/null +++ b/packages/github/src/baseline.ts @@ -0,0 +1,45 @@ +import { stat } from "node:fs/promises"; +import { resolve } from "node:path"; +import { readReport, type SynSecReport } from "@synsec/report"; +import type { GitHubPullRequestContext } from "./index.js"; +import { reportMatchesGitHubCommit } from "./orchestrator.js"; + +const MAX_GITHUB_BASELINE_BYTES = 20 * 1024 * 1024; + +export interface GitHubBaselineLoadOptions { + expectedCommitSha?: string; + requireCommitMatch?: boolean; +} + +/** + * Load a bounded local baseline report and, by default, bind it to the PR base commit. + * This function performs no network retrieval. In PR contexts the event payload's base SHA is + * required unless the caller supplies an explicit expected commit SHA. + */ +export async function loadValidatedGitHubBaseline( + path: string, + context: GitHubPullRequestContext, + options: GitHubBaselineLoadOptions = {}, +): Promise { + const baselinePath = resolve(path); + const info = await stat(baselinePath); + if (!info.isFile()) throw new Error(`GitHub baseline path is not a file: ${baselinePath}`); + if (info.size > MAX_GITHUB_BASELINE_BYTES) { + throw new Error(`GitHub baseline exceeds ${MAX_GITHUB_BASELINE_BYTES} bytes.`); + } + + const report = await readReport(baselinePath); + const requireCommitMatch = options.requireCommitMatch ?? true; + if (!requireCommitMatch) return report; + + const expected = options.expectedCommitSha?.trim() || context.baseSha?.trim(); + if (!expected) { + throw new Error("GitHub baseline commit validation requires the pull-request base SHA or an explicit expected commit SHA."); + } + const actual = report.target.commitSha?.trim(); + if (!actual) throw new Error("GitHub baseline report does not identify its commit SHA."); + if (!reportMatchesGitHubCommit(actual, expected)) { + throw new Error("GitHub baseline report commit does not match the expected base commit."); + } + return report; +} diff --git a/packages/github/src/credential-reload-freshness.ts b/packages/github/src/credential-reload-freshness.ts new file mode 100644 index 00000000..8c16622d --- /dev/null +++ b/packages/github/src/credential-reload-freshness.ts @@ -0,0 +1,158 @@ +import { + assessSynSecGitHubAppCredentialReload, + type SynSecGitHubAppCredentialReloadKind, + type SynSecGitHubAppCredentialReloadAssessment, + type SynSecGitHubAppRotationWithoutReload, +} from "./credential-reload.js"; +import { + buildSynSecGitHubAppCredentialRotationPlan, + type SynSecGitHubAppCredentialRotationPlan, +} from "./credential-rotation.js"; + +export interface SynSecGitHubAppFreshCredentialReloadReplica { + replicaId: string; + loadedGeneration: string; + ready: boolean; + observedAt: string; +} + +export interface SynSecGitHubAppFreshCredentialReloadInput { + kind: SynSecGitHubAppCredentialReloadKind; + targetGeneration: string; + expectedReplicaIds: readonly string[]; + replicas: readonly SynSecGitHubAppFreshCredentialReloadReplica[]; + assessedAt: string; + maxObservationAgeSeconds?: number; +} + +export interface SynSecGitHubAppFreshCredentialReloadAssessment { + version: 1; + reload: SynSecGitHubAppCredentialReloadAssessment; + assessedAt: string; + maxObservationAgeSeconds: number; + freshReplicaCount: number; + expiredObservationCount: number; + futureObservationCount: number; + complete: boolean; + interpretation: "fresh-deployment-observation-not-secret-management"; +} + +export interface SynSecGitHubAppCredentialRotationWithFreshReloadInput { + rotation: SynSecGitHubAppRotationWithoutReload; + reload: SynSecGitHubAppFreshCredentialReloadInput; +} + +export interface SynSecGitHubAppCredentialRotationWithFreshReloadAssessment { + reload: SynSecGitHubAppFreshCredentialReloadAssessment; + rotation: SynSecGitHubAppCredentialRotationPlan; +} + +const DEFAULT_MAX_OBSERVATION_AGE_SECONDS = 300; +const MIN_MAX_OBSERVATION_AGE_SECONDS = 10; +const MAX_MAX_OBSERVATION_AGE_SECONDS = 3600; +const MAX_FUTURE_CLOCK_SKEW_MS = 30_000; + +function parseTimestamp(value: unknown, name: string): { value: string; epochMs: number } { + if (typeof value !== "string" || value.length < 20 || value.length > 40) { + throw new Error(`${name} must be a bounded RFC 3339 timestamp.`); + } + const epochMs = Date.parse(value); + if (!Number.isFinite(epochMs)) throw new Error(`${name} must be a valid RFC 3339 timestamp.`); + const canonical = new Date(epochMs).toISOString(); + if (value !== canonical) { + throw new Error(`${name} must use canonical UTC RFC 3339 format.`); + } + return { value, epochMs }; +} + +function maxObservationAgeSeconds(value: number | undefined): number { + const normalized = value ?? DEFAULT_MAX_OBSERVATION_AGE_SECONDS; + if ( + !Number.isSafeInteger(normalized) + || normalized < MIN_MAX_OBSERVATION_AGE_SECONDS + || normalized > MAX_MAX_OBSERVATION_AGE_SECONDS + ) { + throw new Error( + `maxObservationAgeSeconds must be an integer between ${MIN_MAX_OBSERVATION_AGE_SECONDS} and ${MAX_MAX_OBSERVATION_AGE_SECONDS}.`, + ); + } + return normalized; +} + +/** + * Require fleet-wide credential reload observations to be both structurally complete and recent. + * + * The base reload assessor proves exact replica membership, generation agreement, and readiness. + * This wrapper additionally prevents old observations from being reused indefinitely as retirement + * evidence. It accepts deployment metadata only and never accepts or retrieves credential values. + */ +export function assessSynSecGitHubAppFreshCredentialReload( + input: SynSecGitHubAppFreshCredentialReloadInput, +): SynSecGitHubAppFreshCredentialReloadAssessment { + const assessedAt = parseTimestamp(input.assessedAt, "assessedAt"); + const maxAgeSeconds = maxObservationAgeSeconds(input.maxObservationAgeSeconds); + const maxAgeMs = maxAgeSeconds * 1000; + + if (!Array.isArray(input.replicas)) throw new Error("replicas must be an array."); + + let freshReplicaCount = 0; + let expiredObservationCount = 0; + let futureObservationCount = 0; + const baseReplicas = input.replicas.map((replica) => { + if (!replica || typeof replica !== "object") throw new Error("Every replica observation must be an object."); + const observedAt = parseTimestamp(replica.observedAt, "replica.observedAt"); + const ageMs = assessedAt.epochMs - observedAt.epochMs; + if (ageMs < -MAX_FUTURE_CLOCK_SKEW_MS) futureObservationCount += 1; + else if (ageMs > maxAgeMs) expiredObservationCount += 1; + else freshReplicaCount += 1; + + return { + replicaId: replica.replicaId, + loadedGeneration: replica.loadedGeneration, + ready: replica.ready, + }; + }); + + const reload = assessSynSecGitHubAppCredentialReload({ + kind: input.kind, + targetGeneration: input.targetGeneration, + expectedReplicaIds: input.expectedReplicaIds, + replicas: baseReplicas, + }); + const complete = reload.complete + && expiredObservationCount === 0 + && futureObservationCount === 0 + && freshReplicaCount === reload.expectedReplicaCount; + + return { + version: 1, + reload, + assessedAt: assessedAt.value, + maxObservationAgeSeconds: maxAgeSeconds, + freshReplicaCount, + expiredObservationCount, + futureObservationCount, + complete, + interpretation: "fresh-deployment-observation-not-secret-management", + }; +} + +/** + * Compose fresh fleet observations with credential rotation. `runtimeReloaded` is derived only from + * the fresh assessment, so an otherwise complete rotation cannot retire the previous credential on + * stale rollout evidence. + */ +export function buildSynSecGitHubAppCredentialRotationWithFreshReloadAssessment( + input: SynSecGitHubAppCredentialRotationWithFreshReloadInput, +): SynSecGitHubAppCredentialRotationWithFreshReloadAssessment { + if (input.rotation.kind !== input.reload.kind) { + throw new Error("Credential rotation and fresh reload kinds must match."); + } + + const reload = assessSynSecGitHubAppFreshCredentialReload(input.reload); + const rotation = buildSynSecGitHubAppCredentialRotationPlan({ + ...input.rotation, + runtimeReloaded: reload.complete, + }); + return { reload, rotation }; +} diff --git a/packages/github/src/credential-reload.ts b/packages/github/src/credential-reload.ts new file mode 100644 index 00000000..e691ef09 --- /dev/null +++ b/packages/github/src/credential-reload.ts @@ -0,0 +1,180 @@ +import { + buildSynSecGitHubAppCredentialRotationPlan, + type SynSecGitHubAppCredentialRotationInput, + type SynSecGitHubAppCredentialRotationPlan, +} from "./credential-rotation.js"; + +export type SynSecGitHubAppCredentialReloadKind = "webhook-secret" | "app-private-key"; + +export interface SynSecGitHubAppCredentialReloadReplica { + replicaId: string; + loadedGeneration: string; + ready: boolean; +} + +export interface SynSecGitHubAppCredentialReloadInput { + kind: SynSecGitHubAppCredentialReloadKind; + targetGeneration: string; + expectedReplicaIds: readonly string[]; + replicas: readonly SynSecGitHubAppCredentialReloadReplica[]; +} + +export interface SynSecGitHubAppCredentialReloadAssessment { + version: 1; + kind: SynSecGitHubAppCredentialReloadKind; + targetGeneration: string; + expectedReplicaCount: number; + observedReplicaCount: number; + matchedReplicaCount: number; + staleReplicaCount: number; + unreadyReplicaCount: number; + missingReplicaCount: number; + unexpectedReplicaCount: number; + complete: boolean; + interpretation: "deployment-observed-reload-state-not-secret-management"; +} + +export type SynSecGitHubAppRotationWithoutReload = Omit< + SynSecGitHubAppCredentialRotationInput, + "runtimeReloaded" +>; + +export interface SynSecGitHubAppCredentialRotationWithReloadInput { + rotation: SynSecGitHubAppRotationWithoutReload; + reload: SynSecGitHubAppCredentialReloadInput; +} + +export interface SynSecGitHubAppCredentialRotationWithReloadAssessment { + reload: SynSecGitHubAppCredentialReloadAssessment; + rotation: SynSecGitHubAppCredentialRotationPlan; +} + +const MAX_REPLICA_COUNT = 1000; +const MAX_IDENTIFIER_LENGTH = 128; +const SAFE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:@/-]*$/; + +function requireIdentifier(value: unknown, name: string): string { + if (typeof value !== "string") throw new Error(`${name} must be a string.`); + const trimmed = value.trim(); + if (!trimmed || trimmed.length > MAX_IDENTIFIER_LENGTH || !SAFE_IDENTIFIER.test(trimmed)) { + throw new Error(`${name} must be a bounded non-secret identifier.`); + } + return trimmed; +} + +function requireExpectedReplicaIds(value: unknown): readonly string[] { + if (!Array.isArray(value) || value.length < 1 || value.length > MAX_REPLICA_COUNT) { + throw new Error(`expectedReplicaIds must contain between 1 and ${MAX_REPLICA_COUNT} entries.`); + } + + const ids: string[] = []; + const seen = new Set(); + for (const raw of value) { + const replicaId = requireIdentifier(raw, "expectedReplicaId"); + if (seen.has(replicaId)) throw new Error("expectedReplicaIds must contain unique replica identifiers."); + seen.add(replicaId); + ids.push(replicaId); + } + return ids; +} + +/** + * Assess whether every specifically expected SynSec application replica has observed the same + * credential configuration generation after a rollout. + * + * Generation and replica identifiers are deployment metadata only; credential values are not + * accepted. Callers are responsible for obtaining these observations from their supervisor or + * orchestration platform. A complete assessment proves only that the exact declared replica set is + * ready and reports the target generation. It does not prove that GitHub accepted a webhook + * secret/private key, revoke an old credential, or authorize repository access. + */ +export function assessSynSecGitHubAppCredentialReload( + input: SynSecGitHubAppCredentialReloadInput, +): SynSecGitHubAppCredentialReloadAssessment { + if (input.kind !== "webhook-secret" && input.kind !== "app-private-key") { + throw new Error("GitHub App credential reload kind must be webhook-secret or app-private-key."); + } + + const targetGeneration = requireIdentifier(input.targetGeneration, "targetGeneration"); + const expectedReplicaIds = requireExpectedReplicaIds(input.expectedReplicaIds); + const expectedReplicaSet = new Set(expectedReplicaIds); + if (!Array.isArray(input.replicas)) throw new Error("replicas must be an array."); + if (input.replicas.length > MAX_REPLICA_COUNT) { + throw new Error(`replicas must contain no more than ${MAX_REPLICA_COUNT} entries.`); + } + + const observedReplicaIds = new Set(); + let matchedReplicaCount = 0; + let staleReplicaCount = 0; + let unreadyReplicaCount = 0; + let unexpectedReplicaCount = 0; + + for (const replica of input.replicas) { + if (!replica || typeof replica !== "object") throw new Error("Every replica observation must be an object."); + const replicaId = requireIdentifier(replica.replicaId, "replicaId"); + if (observedReplicaIds.has(replicaId)) throw new Error("Replica observations must use unique replicaId values."); + observedReplicaIds.add(replicaId); + + const loadedGeneration = requireIdentifier(replica.loadedGeneration, "loadedGeneration"); + if (typeof replica.ready !== "boolean") throw new Error("replica.ready must be boolean."); + + if (!expectedReplicaSet.has(replicaId)) { + unexpectedReplicaCount += 1; + continue; + } + + if (loadedGeneration === targetGeneration) matchedReplicaCount += 1; + else staleReplicaCount += 1; + if (!replica.ready) unreadyReplicaCount += 1; + } + + const expectedReplicaCount = expectedReplicaIds.length; + const observedReplicaCount = input.replicas.length; + let missingReplicaCount = 0; + for (const expectedReplicaId of expectedReplicaIds) { + if (!observedReplicaIds.has(expectedReplicaId)) missingReplicaCount += 1; + } + + const complete = missingReplicaCount === 0 + && unexpectedReplicaCount === 0 + && matchedReplicaCount === expectedReplicaCount + && staleReplicaCount === 0 + && unreadyReplicaCount === 0; + + return { + version: 1, + kind: input.kind, + targetGeneration, + expectedReplicaCount, + observedReplicaCount, + matchedReplicaCount, + staleReplicaCount, + unreadyReplicaCount, + missingReplicaCount, + unexpectedReplicaCount, + complete, + interpretation: "deployment-observed-reload-state-not-secret-management", + }; +} + +/** + * Compose deployment-wide reload observations with the existing credential-rotation state machine. + * The reload acknowledgement is derived internally from the raw observations so callers cannot + * substitute a hand-authored `complete: true` assessment. This remains an observation/evaluation + * boundary: it does not retrieve secrets, reload processes, contact GitHub, or revoke credentials. + */ +export function buildSynSecGitHubAppCredentialRotationWithReloadAssessment( + input: SynSecGitHubAppCredentialRotationWithReloadInput, +): SynSecGitHubAppCredentialRotationWithReloadAssessment { + if (input.rotation.kind !== input.reload.kind) { + throw new Error("Credential rotation and reload kinds must match."); + } + + const reload = assessSynSecGitHubAppCredentialReload(input.reload); + const rotation = buildSynSecGitHubAppCredentialRotationPlan({ + ...input.rotation, + runtimeReloaded: reload.complete, + }); + + return { reload, rotation }; +} diff --git a/packages/github/src/credential-rotation.ts b/packages/github/src/credential-rotation.ts new file mode 100644 index 00000000..f305b4b6 --- /dev/null +++ b/packages/github/src/credential-rotation.ts @@ -0,0 +1,113 @@ +export type SynSecGitHubAppCredentialRotationKind = "webhook-secret" | "app-private-key"; + +export interface SynSecGitHubAppCredentialRotationInput { + kind: SynSecGitHubAppCredentialRotationKind; + replacementActivated?: boolean; + runtimeReloaded?: boolean; + externalConfigurationUpdated?: boolean; + verificationSucceeded?: boolean; +} + +export interface SynSecGitHubAppCredentialRotationPlan { + version: 1; + kind: SynSecGitHubAppCredentialRotationKind; + readyToRetirePrevious: boolean; + completedSteps: string[]; + requiredActions: string[]; + interpretation: "operator-acknowledged-rotation-state-not-secret-management"; +} + +const STEP_LIMIT = 8; + +function ensureBoolean(value: boolean | undefined, name: string): boolean { + if (value === undefined) return false; + if (typeof value !== "boolean") throw new Error(`${name} must be boolean when supplied.`); + return value; +} + +function appendBounded(target: string[], value: string): void { + if (target.length >= STEP_LIMIT) throw new Error("GitHub App credential rotation plan exceeds its bounded step count."); + target.push(value); +} + +/** + * Build a deterministic, secret-free rollout plan for GitHub App credential rotation. + * + * This helper accepts only operator acknowledgements. It never accepts credential values, contacts + * GitHub, reloads a process, revokes a key, or changes webhook configuration. The previous credential + * is considered safe to retire only after every required rollout and verification acknowledgement is + * present. Callers must derive acknowledgements from their own deployment and GitHub observations. + */ +export function buildSynSecGitHubAppCredentialRotationPlan( + input: SynSecGitHubAppCredentialRotationInput, +): SynSecGitHubAppCredentialRotationPlan { + if (input.kind !== "webhook-secret" && input.kind !== "app-private-key") { + throw new Error("GitHub App credential rotation kind must be webhook-secret or app-private-key."); + } + + const replacementActivated = ensureBoolean(input.replacementActivated, "replacementActivated"); + const runtimeReloaded = ensureBoolean(input.runtimeReloaded, "runtimeReloaded"); + const externalConfigurationUpdated = ensureBoolean( + input.externalConfigurationUpdated, + "externalConfigurationUpdated", + ); + const verificationSucceeded = ensureBoolean(input.verificationSucceeded, "verificationSucceeded"); + const completedSteps: string[] = []; + const requiredActions: string[] = []; + + if (replacementActivated) { + appendBounded(completedSteps, input.kind === "webhook-secret" + ? "Replacement webhook secret is staged in the runtime overlap set." + : "Replacement GitHub App private key is active in GitHub."); + } else { + appendBounded(requiredActions, input.kind === "webhook-secret" + ? "Stage the replacement webhook secret alongside the previous secret in the bounded two-secret overlap set." + : "Activate the replacement GitHub App private key in GitHub before changing the SynSec runtime."); + } + + if (runtimeReloaded) { + appendBounded(completedSteps, "SynSec runtime has reloaded the replacement credential configuration."); + } else { + appendBounded(requiredActions, "Reload or roll the SynSec runtime with the replacement credential configuration."); + } + + if (input.kind === "webhook-secret") { + if (externalConfigurationUpdated) { + appendBounded(completedSteps, "GitHub webhook configuration has been updated to use the replacement secret."); + } else { + appendBounded(requiredActions, "Update the GitHub webhook secret only after the replacement is staged in SynSec."); + } + } else if (externalConfigurationUpdated) { + appendBounded(completedSteps, "GitHub-side replacement private-key activation has been operator-confirmed."); + } + + if (verificationSucceeded) { + appendBounded(completedSteps, input.kind === "webhook-secret" + ? "An authenticated GitHub webhook delivery succeeded after the GitHub-side update." + : "A fresh installation-token exchange succeeded after the SynSec runtime reload."); + } else { + appendBounded(requiredActions, input.kind === "webhook-secret" + ? "Confirm at least one authenticated GitHub webhook delivery after the GitHub-side secret update." + : "Verify a fresh installation-token exchange using the replacement private key."); + } + + const readyToRetirePrevious = replacementActivated + && runtimeReloaded + && verificationSucceeded + && (input.kind === "app-private-key" || externalConfigurationUpdated); + + if (!readyToRetirePrevious) { + appendBounded(requiredActions, input.kind === "webhook-secret" + ? "Keep the previous webhook secret in the overlap set until every required acknowledgement is complete." + : "Keep the previous GitHub App private key active until every required acknowledgement is complete."); + } + + return { + version: 1, + kind: input.kind, + readyToRetirePrevious, + completedSteps, + requiredActions, + interpretation: "operator-acknowledged-rotation-state-not-secret-management", + }; +} diff --git a/packages/github/src/exact-tree-diff.ts b/packages/github/src/exact-tree-diff.ts new file mode 100644 index 00000000..a04efa61 --- /dev/null +++ b/packages/github/src/exact-tree-diff.ts @@ -0,0 +1,163 @@ +import { resolve } from "node:path"; +import { runProcess, type ProcessOutput } from "@synsec/scanner-sdk"; + +const DEFAULT_MAX_TREE_BYTES = 8 * 1024 * 1024; +const DEFAULT_MAX_TREE_ENTRIES = 100_000; +const DEFAULT_MAX_CHANGED_FILES = 5_000; + +export type ExactTreeDiffReason = + | "exact-tree-diff" + | "tree-read-failed" + | "invalid-tree-output" + | "unsupported-tree-change" + | "deletion-requires-full-scan" + | "too-many-tree-entries" + | "too-many-changed-files"; + +export interface ExactTreeDiffPlan { + mode: "changed-files" | "full-repository"; + reason: ExactTreeDiffReason; + changedFiles: string[]; + /** Deleted paths are recorded for diagnostics only; targeted scanners never receive absent paths. */ + deletedFiles: string[]; + interpretation: "exact-commit-tree-comparison-with-conservative-full-scan-fallback"; +} + +export interface ExactTreeDiffOptions { + timeoutMs?: number; + maxTreeBytes?: number; + maxTreeEntries?: number; + maxChangedFiles?: number; + run?: typeof runProcess; +} + +interface TreeEntry { + mode: string; + type: string; + object: string; + path: string; +} + +function boundedInteger(value: number | undefined, fallback: number, min: number, max: number, label: string): number { + const normalized = value ?? fallback; + if (!Number.isSafeInteger(normalized) || normalized < min || normalized > max) { + throw new Error(`${label} must be an integer between ${min} and ${max}.`); + } + return normalized; +} + +function safeRepositoryPath(value: string): string | undefined { + if (!value || value.includes("\0") || value.includes("\uFFFD")) return undefined; + const path = value.replaceAll("\\", "/"); + if (path.startsWith("/") || /^[A-Za-z]:\//.test(path)) return undefined; + const segments = path.split("/"); + if (segments.some((segment) => !segment || segment === "." || segment === "..")) return undefined; + return path; +} + +function parseTree(output: string, maxEntries: number): Map | undefined { + const entries = new Map(); + const records = output.split("\0"); + if (records.at(-1) === "") records.pop(); + if (records.length > maxEntries) return undefined; + + for (const record of records) { + const separator = record.indexOf("\t"); + if (separator <= 0) return undefined; + const metadata = record.slice(0, separator).split(" "); + if (metadata.length !== 3) return undefined; + const [mode, type, object] = metadata; + const path = safeRepositoryPath(record.slice(separator + 1)); + if (!mode || !/^[0-7]{6}$/.test(mode) || !type || !object || !/^[a-f0-9]{40,64}$/i.test(object) || !path) { + return undefined; + } + const key = path.toLowerCase(); + if (entries.has(key)) return undefined; + entries.set(key, { mode, type, object: object.toLowerCase(), path }); + } + return entries; +} + +async function readTree( + workspace: string, + options: { timeoutMs: number; maxTreeBytes: number; maxTreeEntries: number; run: typeof runProcess }, +): Promise | undefined> { + let result: ProcessOutput; + try { + result = await options.run( + "git", + ["-C", resolve(workspace), "ls-tree", "-r", "-z", "--full-tree", "HEAD"], + { timeoutMs: options.timeoutMs, maxOutputBytes: options.maxTreeBytes }, + ); + } catch { + return undefined; + } + if (result.exitCode !== 0 || result.stderr.trim()) return undefined; + return parseTree(result.stdout, options.maxTreeEntries); +} + +function fullPlan(reason: ExactTreeDiffReason, deletedFiles: string[] = []): ExactTreeDiffPlan { + return { + mode: "full-repository", + reason, + changedFiles: [], + deletedFiles, + interpretation: "exact-commit-tree-comparison-with-conservative-full-scan-fallback", + }; +} + +/** + * Compare the exact already-acquired base/head commit trees without fetching history or using branch + * names. Only changed blob paths that exist in the head may become a targeted scan. Deletions, + * submodule/tree-type changes, malformed/oversized tree output, or excessive change counts fall back + * to a full repository scan instead of guessing an incomplete target set. + */ +export async function deriveExactChangedFiles( + baseWorkspace: string, + headWorkspace: string, + options: ExactTreeDiffOptions = {}, +): Promise { + const timeoutMs = boundedInteger(options.timeoutMs, 10_000, 1_000, 60_000, "timeoutMs"); + const maxTreeBytes = boundedInteger(options.maxTreeBytes, DEFAULT_MAX_TREE_BYTES, 1_024, 64 * 1024 * 1024, "maxTreeBytes"); + const maxTreeEntries = boundedInteger(options.maxTreeEntries, DEFAULT_MAX_TREE_ENTRIES, 1, 500_000, "maxTreeEntries"); + const maxChangedFiles = boundedInteger(options.maxChangedFiles, DEFAULT_MAX_CHANGED_FILES, 1, 50_000, "maxChangedFiles"); + const run = options.run ?? runProcess; + + const readOptions = { timeoutMs, maxTreeBytes, maxTreeEntries, run }; + const [base, head] = await Promise.all([ + readTree(baseWorkspace, readOptions), + readTree(headWorkspace, readOptions), + ]); + if (!base || !head) return fullPlan("tree-read-failed"); + if (base.size > maxTreeEntries || head.size > maxTreeEntries) return fullPlan("too-many-tree-entries"); + + const changedFiles: string[] = []; + const deletedFiles: string[] = []; + const allKeys = new Set([...base.keys(), ...head.keys()]); + for (const key of allKeys) { + const before = base.get(key); + const after = head.get(key); + if (before && after && before.mode === after.mode && before.type === after.type && before.object === after.object) continue; + + if (!after) { + if (before) deletedFiles.push(before.path); + continue; + } + if (after.type !== "blob" || (before && before.type !== "blob")) { + return fullPlan("unsupported-tree-change", deletedFiles.sort()); + } + changedFiles.push(after.path); + if (changedFiles.length > maxChangedFiles) return fullPlan("too-many-changed-files", deletedFiles.sort()); + } + + deletedFiles.sort(); + if (deletedFiles.length > 0) return fullPlan("deletion-requires-full-scan", deletedFiles); + changedFiles.sort(); + return { + mode: "changed-files", + reason: "exact-tree-diff", + changedFiles, + deletedFiles: [], + interpretation: "exact-commit-tree-comparison-with-conservative-full-scan-fallback", + }; +} diff --git a/packages/github/src/hosted-installation-ownership.ts b/packages/github/src/hosted-installation-ownership.ts new file mode 100644 index 00000000..52f3bb40 --- /dev/null +++ b/packages/github/src/hosted-installation-ownership.ts @@ -0,0 +1,190 @@ +const MAX_IDENTIFIER_LENGTH = 128; +const MAX_LOGIN_LENGTH = 255; + +export interface SynSecHostedGitHubPrincipal { + /** Stable authenticated application subject. Never derived from repository-controlled input. */ + subject: string; + /** Stable hosted tenant identifier chosen by the trusted application identity layer. */ + tenantId: string; + /** GitHub user id bound to the authenticated session by the caller-owned identity layer. */ + githubUserId: number; +} + +export interface SynSecAccessibleGitHubInstallation { + id: number; + account: { + id: number; + login: string; + type: "User" | "Organization"; + }; + repositorySelection: "all" | "selected"; + suspendedAt?: string; +} + +export interface SynSecGitHubUserInstallationTransport { + /** Must execute with the same user-scoped GitHub credential used for getAccessibleInstallation(). */ + getAuthenticatedUser(): Promise<{ id: number; login: string }>; + /** Return undefined when this authenticated GitHub user cannot access the requested installation. */ + getAccessibleInstallation(installationId: number): Promise; +} + +export interface SynSecHostedInstallationOwnershipClaim { + tenantId: string; + installationId: number; + githubUserId: number; + accountId: number; + accountLogin: string; + accountType: "User" | "Organization"; +} + +export type SynSecHostedInstallationClaimResult = "claimed" | "already-owned-by-tenant" | "conflict"; + +export interface SynSecHostedInstallationOwnershipStore { + /** Atomic compare-and-claim. Implementations must never overwrite a different tenant owner. */ + claim(input: SynSecHostedInstallationOwnershipClaim): Promise; +} + +export interface SynSecHostedInstallationOwnershipEvidence { + status: "verified"; + tenantId: string; + installationId: number; + githubUserId: number; + accountId: number; + accountLogin: string; + accountType: "User" | "Organization"; + repositorySelection: "all" | "selected"; + ownership: "claimed" | "already-owned-by-tenant"; + interpretation: "authenticated-user-access-and-atomic-tenant-claim-only"; +} + +function boundedIdentifier(value: unknown, label: string): string { + if (typeof value !== "string") throw new Error(`${label} must be a string.`); + const normalized = value.trim(); + if (!normalized || normalized.length > MAX_IDENTIFIER_LENGTH || !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(normalized)) { + throw new Error(`${label} must be a bounded non-secret identifier.`); + } + return normalized; +} + +function positiveInteger(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive integer.`); + } + return value; +} + +function login(value: unknown): string { + if (typeof value !== "string") throw new Error("GitHub account login must be a string."); + const normalized = value.trim(); + if (!normalized || normalized.length > MAX_LOGIN_LENGTH || /[\u0000-\u001f\u007f]/.test(normalized)) { + throw new Error("GitHub account login is invalid."); + } + return normalized; +} + +function installation(value: SynSecAccessibleGitHubInstallation | undefined, expectedId: number): SynSecAccessibleGitHubInstallation { + if (!value || typeof value !== "object") throw new Error("GitHub installation is not accessible to the authenticated user."); + if (positiveInteger(value.id, "GitHub installation id") !== expectedId) { + throw new Error("GitHub installation lookup returned an unexpected installation."); + } + if (!value.account || typeof value.account !== "object") throw new Error("GitHub installation account is invalid."); + positiveInteger(value.account.id, "GitHub installation account id"); + login(value.account.login); + if (value.account.type !== "User" && value.account.type !== "Organization") { + throw new Error("GitHub installation account type is invalid."); + } + if (value.repositorySelection !== "all" && value.repositorySelection !== "selected") { + throw new Error("GitHub installation repository selection is invalid."); + } + if (value.suspendedAt !== undefined) { + if (typeof value.suspendedAt !== "string" || !Number.isFinite(Date.parse(value.suspendedAt))) { + throw new Error("GitHub installation suspension state is invalid."); + } + throw new Error("Suspended GitHub installations cannot be claimed for hosted use."); + } + return value; +} + +/** + * Verify hosted installation ownership through a caller-owned user-scoped GitHub transport and then + * atomically claim the installation for one hosted tenant. + * + * This function deliberately accepts no GitHub token. The transport owns user credentials and must + * use the same authenticated GitHub identity for both calls. A successful result proves only that + * the authenticated user id matched the caller-bound session, GitHub exposed the installation to + * that user at verification time, and the ownership store accepted the tenant claim. It does not + * prove organization role, future access, repository authorization, runtime route protection, or + * that GitHub will continue to expose the installation. + */ +export async function verifyAndClaimSynSecHostedGitHubInstallation(options: { + principal: SynSecHostedGitHubPrincipal; + installationId: number; + transport: SynSecGitHubUserInstallationTransport; + store: SynSecHostedInstallationOwnershipStore; +}): Promise { + if (!options || typeof options !== "object") throw new Error("Hosted GitHub installation verification options are required."); + const subject = boundedIdentifier(options.principal?.subject, "Hosted principal subject"); + void subject; // validated as part of the authenticated-session boundary; intentionally not persisted here. + const tenantId = boundedIdentifier(options.principal?.tenantId, "Hosted tenant id"); + const githubUserId = positiveInteger(options.principal?.githubUserId, "Authenticated GitHub user id"); + const installationId = positiveInteger(options.installationId, "GitHub installation id"); + if (!options.transport || typeof options.transport.getAuthenticatedUser !== "function" || typeof options.transport.getAccessibleInstallation !== "function") { + throw new Error("User-scoped GitHub installation transport is required."); + } + if (!options.store || typeof options.store.claim !== "function") { + throw new Error("Hosted installation ownership store is required."); + } + + let authenticatedUser: { id: number; login: string }; + let accessible: SynSecAccessibleGitHubInstallation | undefined; + try { + authenticatedUser = await options.transport.getAuthenticatedUser(); + const returnedUserId = positiveInteger(authenticatedUser?.id, "GitHub authenticated user id"); + login(authenticatedUser?.login); + if (returnedUserId !== githubUserId) { + throw new Error("Authenticated GitHub identity does not match the hosted session."); + } + accessible = await options.transport.getAccessibleInstallation(installationId); + } catch (error) { + if (error instanceof Error && ( + error.message === "Authenticated GitHub identity does not match the hosted session." + || error.message.startsWith("GitHub authenticated user id") + || error.message.startsWith("GitHub account login") + )) throw error; + throw new Error("GitHub installation ownership verification failed."); + } + + const verified = installation(accessible, installationId); + const claim: SynSecHostedInstallationOwnershipClaim = { + tenantId, + installationId, + githubUserId, + accountId: positiveInteger(verified.account.id, "GitHub installation account id"), + accountLogin: login(verified.account.login), + accountType: verified.account.type, + }; + + let ownership: SynSecHostedInstallationClaimResult; + try { + ownership = await options.store.claim(claim); + } catch { + throw new Error("Hosted installation ownership persistence failed."); + } + if (ownership === "conflict") throw new Error("GitHub installation is already claimed by another hosted tenant."); + if (ownership !== "claimed" && ownership !== "already-owned-by-tenant") { + throw new Error("Hosted installation ownership store returned an invalid result."); + } + + return { + status: "verified", + tenantId, + installationId, + githubUserId, + accountId: claim.accountId, + accountLogin: claim.accountLogin, + accountType: claim.accountType, + repositorySelection: verified.repositorySelection, + ownership, + interpretation: "authenticated-user-access-and-atomic-tenant-claim-only", + }; +} diff --git a/packages/github/src/hosted-installation-reverification-sweep.ts b/packages/github/src/hosted-installation-reverification-sweep.ts new file mode 100644 index 00000000..2fae74cd --- /dev/null +++ b/packages/github/src/hosted-installation-reverification-sweep.ts @@ -0,0 +1,243 @@ +import type { + SynSecGitHubUserInstallationTransport, + SynSecHostedGitHubPrincipal, +} from "./hosted-installation-ownership.js"; +import { + reverifySynSecHostedGitHubInstallation, + type SynSecHostedInstallationReverificationStore, +} from "./hosted-installation-reverification.js"; + +const MAX_IDENTIFIER_LENGTH = 128; +const MAX_TARGETS = 10_000; +const DEFAULT_CONCURRENCY = 4; +const MIN_CONCURRENCY = 1; +const MAX_CONCURRENCY = 32; + +export interface SynSecHostedInstallationReverificationTarget { + principal: SynSecHostedGitHubPrincipal; + installationId: number; +} + +/** + * Hosting-owned target and credential boundary. + * + * listTargets() must derive targets from trusted hosted tenant state rather than repository input. + * createTransport() owns user-scoped GitHub credentials and should return a freshly usable bounded + * transport for only the target being processed. SynSec does not persist or serialize that transport. + */ +export interface SynSecHostedInstallationReverificationTargetProvider { + listTargets(): Promise; + createTransport( + target: SynSecHostedInstallationReverificationTarget, + ): Promise | SynSecGitHubUserInstallationTransport; +} + +export interface SynSecHostedInstallationReverificationSweepResult { + status: "completed"; + attempted: number; + verified: number; + revoked: number; + superseded: number; + failed: number; + interpretation: "scheduler-observation-only-not-authorization-evidence"; +} + +export interface SynSecHostedInstallationReverificationSweepOptions { + provider: SynSecHostedInstallationReverificationTargetProvider; + store: SynSecHostedInstallationReverificationStore; + concurrency?: number; +} + +export interface SynSecHostedInstallationReverificationSweepStatus { + active: boolean; + completedSweeps: number; + lastResult?: SynSecHostedInstallationReverificationSweepResult; + interpretation: "process-local-scheduler-status-only"; +} + +function boundedIdentifier(value: unknown, label: string): string { + if (typeof value !== "string") throw new Error(`${label} must be a string.`); + const normalized = value.trim(); + if (!normalized + || normalized.length > MAX_IDENTIFIER_LENGTH + || !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(normalized)) { + throw new Error(`${label} must be a bounded non-secret identifier.`); + } + return normalized; +} + +function positiveInteger(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive integer.`); + } + return value; +} + +function concurrency(value: number | undefined): number { + const resolved = value ?? DEFAULT_CONCURRENCY; + if (!Number.isSafeInteger(resolved) || resolved < MIN_CONCURRENCY || resolved > MAX_CONCURRENCY) { + throw new Error(`Hosted installation re-verification concurrency must be between ${MIN_CONCURRENCY} and ${MAX_CONCURRENCY}.`); + } + return resolved; +} + +function validatedTarget( + value: SynSecHostedInstallationReverificationTarget, +): SynSecHostedInstallationReverificationTarget { + if (!value || typeof value !== "object") throw new Error("Hosted installation re-verification target is invalid."); + if (!value.principal || typeof value.principal !== "object") { + throw new Error("Hosted installation re-verification target principal is invalid."); + } + return { + principal: { + subject: boundedIdentifier(value.principal.subject, "Hosted principal subject"), + tenantId: boundedIdentifier(value.principal.tenantId, "Hosted tenant id"), + githubUserId: positiveInteger(value.principal.githubUserId, "Authenticated GitHub user id"), + }, + installationId: positiveInteger(value.installationId, "GitHub installation id"), + }; +} + +function validateOptions(options: SynSecHostedInstallationReverificationSweepOptions): number { + if (!options || typeof options !== "object") { + throw new Error("Hosted installation re-verification sweep options are required."); + } + if (!options.provider + || typeof options.provider.listTargets !== "function" + || typeof options.provider.createTransport !== "function") { + throw new Error("Hosted installation re-verification target provider is required."); + } + if (!options.store + || typeof options.store.beginReverification !== "function" + || typeof options.store.finishVerified !== "function" + || typeof options.store.finishRevoked !== "function" + || typeof options.store.isFreshlyAuthorized !== "function") { + throw new Error("Hosted installation re-verification store is required."); + } + return concurrency(options.concurrency); +} + +async function loadTargets( + provider: SynSecHostedInstallationReverificationTargetProvider, +): Promise { + let values: readonly SynSecHostedInstallationReverificationTarget[]; + try { + values = await provider.listTargets(); + } catch { + throw new Error("Hosted installation re-verification target discovery failed."); + } + if (!Array.isArray(values)) throw new Error("Hosted installation re-verification target discovery returned an invalid result."); + if (values.length > MAX_TARGETS) { + throw new Error(`Hosted installation re-verification sweep exceeds the ${MAX_TARGETS}-target limit.`); + } + + const targets = values.map(validatedTarget); + const identities = new Set(); + for (const target of targets) { + const identity = `${target.principal.tenantId}\u0000${target.installationId}`; + if (identities.has(identity)) { + throw new Error("Hosted installation re-verification targets contain a duplicate tenant installation."); + } + identities.add(identity); + } + return targets; +} + +/** + * Execute one bounded periodic ownership re-verification sweep. + * + * This function deliberately returns aggregate observations only. A completed sweep, successful + * scheduler invocation, or zero failures is not authorization evidence. Hosted request paths must + * continue to call the durable freshness gate before granting installation-scoped access. + * + * The transport boundary owns HTTP timeouts/cancellation. This function does not simulate timeout by + * abandoning an in-flight remote call because that call could still complete a fenced durable write. + */ +export async function runSynSecHostedInstallationReverificationSweep( + options: SynSecHostedInstallationReverificationSweepOptions, +): Promise { + const limit = validateOptions(options); + const targets = await loadTargets(options.provider); + let cursor = 0; + const counters = { + verified: 0, + revoked: 0, + superseded: 0, + failed: 0, + }; + + async function worker(): Promise { + while (true) { + const index = cursor; + cursor += 1; + if (index >= targets.length) return; + const target = targets[index]; + if (!target) return; + try { + const transport = await options.provider.createTransport(target); + if (!transport + || typeof transport.getAuthenticatedUser !== "function" + || typeof transport.getAccessibleInstallation !== "function") { + throw new Error("invalid transport"); + } + const evidence = await reverifySynSecHostedGitHubInstallation({ + principal: target.principal, + installationId: target.installationId, + transport, + store: options.store, + }); + counters[evidence.status] += 1; + } catch { + counters.failed += 1; + } + } + } + + await Promise.all(Array.from({ length: Math.min(limit, Math.max(1, targets.length)) }, () => worker())); + return { + status: "completed", + attempted: targets.length, + ...counters, + interpretation: "scheduler-observation-only-not-authorization-evidence", + }; +} + +/** + * Process-local overlap coalescing for service-manager timers or embedded schedulers. + * + * Multi-replica safety still comes from the durable verification epoch, not this object. Different + * replicas may run simultaneously; this controller merely avoids duplicate sweeps inside one process. + */ +export class SynSecHostedInstallationReverificationSweepController { + private inFlight: Promise | undefined; + private completedSweeps = 0; + private lastResult: SynSecHostedInstallationReverificationSweepResult | undefined; + + constructor(private readonly options: SynSecHostedInstallationReverificationSweepOptions) { + validateOptions(options); + } + + runOnce(): Promise { + if (this.inFlight) return this.inFlight; + const operation = runSynSecHostedInstallationReverificationSweep(this.options) + .then((result) => { + this.completedSweeps += 1; + this.lastResult = result; + return result; + }) + .finally(() => { + if (this.inFlight === operation) this.inFlight = undefined; + }); + this.inFlight = operation; + return operation; + } + + status(): SynSecHostedInstallationReverificationSweepStatus { + return { + active: this.inFlight !== undefined, + completedSweeps: this.completedSweeps, + ...(this.lastResult ? { lastResult: { ...this.lastResult } } : {}), + interpretation: "process-local-scheduler-status-only", + }; + } +} diff --git a/packages/github/src/hosted-installation-reverification.ts b/packages/github/src/hosted-installation-reverification.ts new file mode 100644 index 00000000..e4f259ba --- /dev/null +++ b/packages/github/src/hosted-installation-reverification.ts @@ -0,0 +1,237 @@ +import type { + SynSecAccessibleGitHubInstallation, + SynSecGitHubUserInstallationTransport, + SynSecHostedGitHubPrincipal, +} from "./hosted-installation-ownership.js"; + +const MAX_IDENTIFIER_LENGTH = 128; +const MIN_FRESHNESS_MS = 60_000; +const MAX_FRESHNESS_MS = 30 * 24 * 60 * 60 * 1000; + +export type SynSecHostedInstallationRevocationReason = + | "inaccessible" + | "suspended" + | "account-identity-changed"; + +export interface SynSecHostedInstallationReverificationFence { + epoch: number; + tenantId: string; + installationId: number; + githubUserId: number; + accountId: number; + accountType: "User" | "Organization"; +} + +export type SynSecHostedInstallationReverificationFinishResult = "applied" | "stale" | "conflict"; + +export interface SynSecHostedInstallationReverificationStore { + /** + * Allocate a monotonically increasing durable epoch for the exact current tenant/user proof. + * Returns undefined when the tenant, installation, or proof user does not match durable ownership. + */ + beginReverification( + tenantId: string, + installationId: number, + githubUserId: number, + ): Promise; + /** Apply a successful GitHub observation only if this epoch is still current. */ + finishVerified(input: SynSecHostedInstallationReverificationFence & { + accountLogin: string; + }): Promise; + /** Apply a definitive negative GitHub observation only if this epoch is still current. */ + finishRevoked(input: SynSecHostedInstallationReverificationFence & { + reason: SynSecHostedInstallationRevocationReason; + }): Promise; + /** + * Durable authorization gate. Implementations must require active state and a successful + * verification no older than maxAgeMs using backend time rather than a caller clock. + */ + isFreshlyAuthorized(tenantId: string, installationId: number, maxAgeMs: number): Promise; +} + +export interface SynSecHostedInstallationReverificationEvidence { + status: "verified" | "revoked" | "superseded"; + tenantId: string; + installationId: number; + epoch: number; + reason?: SynSecHostedInstallationRevocationReason; + interpretation: "fresh-user-access-and-fenced-durable-reverification-only"; +} + +function boundedIdentifier(value: unknown, label: string): string { + if (typeof value !== "string") throw new Error(`${label} must be a string.`); + const normalized = value.trim(); + if (!normalized || normalized.length > MAX_IDENTIFIER_LENGTH || !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(normalized)) { + throw new Error(`${label} must be a bounded non-secret identifier.`); + } + return normalized; +} + +function positiveInteger(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive integer.`); + } + return value; +} + +function accountLogin(value: unknown): string { + if (typeof value !== "string") throw new Error("GitHub account login must be a string."); + const normalized = value.trim(); + if (!normalized || normalized.length > 255 || /[\u0000-\u001f\u007f]/.test(normalized)) { + throw new Error("GitHub account login is invalid."); + } + return normalized; +} + +function definitiveInstallation( + value: SynSecAccessibleGitHubInstallation | undefined, + expectedId: number, +): { status: "verified"; value: SynSecAccessibleGitHubInstallation } | { status: "revoked"; reason: SynSecHostedInstallationRevocationReason } { + if (value === undefined) return { status: "revoked", reason: "inaccessible" }; + if (!value || typeof value !== "object") throw new Error("GitHub installation response is invalid."); + if (positiveInteger(value.id, "GitHub installation id") !== expectedId) { + throw new Error("GitHub installation lookup returned an unexpected installation."); + } + if (!value.account || typeof value.account !== "object") throw new Error("GitHub installation account is invalid."); + positiveInteger(value.account.id, "GitHub installation account id"); + accountLogin(value.account.login); + if (value.account.type !== "User" && value.account.type !== "Organization") { + throw new Error("GitHub installation account type is invalid."); + } + if (value.repositorySelection !== "all" && value.repositorySelection !== "selected") { + throw new Error("GitHub installation repository selection is invalid."); + } + if (value.suspendedAt !== undefined) { + if (typeof value.suspendedAt !== "string" || !Number.isFinite(Date.parse(value.suspendedAt))) { + throw new Error("GitHub installation suspension state is invalid."); + } + return { status: "revoked", reason: "suspended" }; + } + return { status: "verified", value }; +} + +function finishEvidence( + result: SynSecHostedInstallationReverificationFinishResult, + fence: SynSecHostedInstallationReverificationFence, + reason?: SynSecHostedInstallationRevocationReason, +): SynSecHostedInstallationReverificationEvidence { + if (result === "conflict") throw new Error("Hosted installation ownership changed during re-verification."); + if (result === "stale") { + return { + status: "superseded", + tenantId: fence.tenantId, + installationId: fence.installationId, + epoch: fence.epoch, + interpretation: "fresh-user-access-and-fenced-durable-reverification-only", + }; + } + if (result !== "applied") throw new Error("Hosted installation re-verification store returned an invalid result."); + return { + status: reason ? "revoked" : "verified", + tenantId: fence.tenantId, + installationId: fence.installationId, + epoch: fence.epoch, + ...(reason ? { reason } : {}), + interpretation: "fresh-user-access-and-fenced-durable-reverification-only", + }; +} + +/** + * Re-check the user proof behind a durable hosted installation claim. + * + * beginReverification() allocates a durable epoch before the remote calls. A later replica can + * therefore supersede an earlier in-flight check, and the stale result cannot overwrite newer + * authorization state. Transport failures do not become revocation evidence; callers should rely on + * the durable freshness gate to fail closed when successful verification evidence ages out. + */ +export async function reverifySynSecHostedGitHubInstallation(options: { + principal: SynSecHostedGitHubPrincipal; + installationId: number; + transport: SynSecGitHubUserInstallationTransport; + store: SynSecHostedInstallationReverificationStore; +}): Promise { + if (!options || typeof options !== "object") throw new Error("Hosted GitHub installation re-verification options are required."); + boundedIdentifier(options.principal?.subject, "Hosted principal subject"); + const tenantId = boundedIdentifier(options.principal?.tenantId, "Hosted tenant id"); + const githubUserId = positiveInteger(options.principal?.githubUserId, "Authenticated GitHub user id"); + const installationId = positiveInteger(options.installationId, "GitHub installation id"); + if (!options.transport || typeof options.transport.getAuthenticatedUser !== "function" || typeof options.transport.getAccessibleInstallation !== "function") { + throw new Error("User-scoped GitHub installation transport is required."); + } + if (!options.store + || typeof options.store.beginReverification !== "function" + || typeof options.store.finishVerified !== "function" + || typeof options.store.finishRevoked !== "function") { + throw new Error("Hosted installation re-verification store is required."); + } + + let fence: SynSecHostedInstallationReverificationFence | undefined; + try { + fence = await options.store.beginReverification(tenantId, installationId, githubUserId); + } catch { + throw new Error("Hosted installation re-verification persistence failed."); + } + if (!fence) throw new Error("Hosted installation ownership proof does not match the authenticated tenant and GitHub user."); + + let authenticatedUser: { id: number; login: string }; + let accessible: SynSecAccessibleGitHubInstallation | undefined; + try { + authenticatedUser = await options.transport.getAuthenticatedUser(); + const returnedUserId = positiveInteger(authenticatedUser?.id, "GitHub authenticated user id"); + accountLogin(authenticatedUser?.login); + if (returnedUserId !== githubUserId) { + throw new Error("Authenticated GitHub identity does not match the hosted session."); + } + accessible = await options.transport.getAccessibleInstallation(installationId); + } catch (error) { + if (error instanceof Error && error.message === "Authenticated GitHub identity does not match the hosted session.") throw error; + throw new Error("GitHub installation re-verification failed."); + } + + const observation = definitiveInstallation(accessible, installationId); + try { + if (observation.status === "revoked") { + const result = await options.store.finishRevoked({ ...fence, reason: observation.reason }); + return finishEvidence(result, fence, observation.reason); + } + + const verified = observation.value; + if (verified.account.id !== fence.accountId || verified.account.type !== fence.accountType) { + const result = await options.store.finishRevoked({ ...fence, reason: "account-identity-changed" }); + return finishEvidence(result, fence, "account-identity-changed"); + } + const result = await options.store.finishVerified({ + ...fence, + accountLogin: accountLogin(verified.account.login), + }); + return finishEvidence(result, fence); + } catch (error) { + if (error instanceof Error && ( + error.message === "Hosted installation ownership changed during re-verification." + || error.message === "Hosted installation re-verification store returned an invalid result." + )) throw error; + throw new Error("Hosted installation re-verification persistence failed."); + } +} + +export async function isSynSecHostedInstallationFreshlyAuthorized(options: { + tenantId: string; + installationId: number; + maxAgeMs: number; + store: SynSecHostedInstallationReverificationStore; +}): Promise { + const tenantId = boundedIdentifier(options?.tenantId, "Hosted tenant id"); + const installationId = positiveInteger(options?.installationId, "GitHub installation id"); + const maxAgeMs = options?.maxAgeMs; + if (!Number.isSafeInteger(maxAgeMs) || maxAgeMs < MIN_FRESHNESS_MS || maxAgeMs > MAX_FRESHNESS_MS) { + throw new Error(`Hosted installation verification freshness must be between ${MIN_FRESHNESS_MS} and ${MAX_FRESHNESS_MS} milliseconds.`); + } + if (!options.store || typeof options.store.isFreshlyAuthorized !== "function") { + throw new Error("Hosted installation re-verification store is required."); + } + try { + return await options.store.isFreshlyAuthorized(tenantId, installationId, maxAgeMs); + } catch { + throw new Error("Hosted installation authorization freshness check failed."); + } +} diff --git a/packages/github/src/index.ts b/packages/github/src/index.ts new file mode 100644 index 00000000..26e5a58b --- /dev/null +++ b/packages/github/src/index.ts @@ -0,0 +1,258 @@ +import { readFile, stat } from "node:fs/promises"; +import type { CorrelatedFinding, Severity } from "@synsec/core"; +import type { SynSecReport } from "@synsec/report"; + +export type GitHubCheckConclusion = "success" | "failure" | "neutral"; +export type GitHubAnnotationLevel = "notice" | "warning" | "failure"; +export type GitHubCheckThreshold = Severity | "none"; + +export interface GitHubPullRequestContext { + repository: string; + sha: string; + ref?: string; + baseRef?: string; + baseSha?: string; + headRef?: string; + pullRequestNumber?: number; +} + +export interface GitHubCheckAnnotation { + path: string; + start_line: number; + end_line: number; + annotation_level: GitHubAnnotationLevel; + title: string; + message: string; + raw_details?: string; +} + +export interface GitHubCheckOutput { + title: string; + summary: string; + text: string; + annotations: GitHubCheckAnnotation[]; +} + +export interface GitHubCheckResult { + name: string; + headSha: string; + conclusion: GitHubCheckConclusion; + output: GitHubCheckOutput; +} + +interface GitHubEventPullRequest { + number?: unknown; + head?: { sha?: unknown; ref?: unknown }; + base?: { sha?: unknown; ref?: unknown }; +} + +interface GitHubEventPayload { + pull_request?: GitHubEventPullRequest; + repository?: { full_name?: unknown }; + after?: unknown; + ref?: unknown; +} + +const MAX_GITHUB_EVENT_BYTES = 2 * 1024 * 1024; + +const severityRank: Record = { + critical: 5, + high: 4, + medium: 3, + low: 2, + info: 1, + unknown: 0, +}; + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function parseRepository(value: unknown): string | undefined { + const trimmed = nonEmptyString(value); + return trimmed && /^[^/\s]+\/[^/\s]+$/.test(trimmed) ? trimmed : undefined; +} + +function positiveInteger(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) return undefined; + return value; +} + +function parsePullRequestNumber(ref: string | undefined): number | undefined { + if (!ref) return undefined; + const match = /^refs\/pull\/(\d+)\/(?:merge|head)$/.exec(ref); + if (!match) return undefined; + const parsed = Number(match[1]); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; +} + +function asGitHubEventPayload(value: unknown): GitHubEventPayload | undefined { + return typeof value === "object" && value !== null ? (value as GitHubEventPayload) : undefined; +} + +/** + * Resolve the current GitHub Actions repository/commit context without making a network request. + * + * For pull_request events, the event payload is authoritative for the head SHA. GitHub exposes + * GITHUB_SHA as the synthetic merge ref for many PR workflows, which is not the commit a check + * run should be attached to. + */ +export function detectGitHubContext( + env: NodeJS.ProcessEnv, + eventPayload?: unknown, +): GitHubPullRequestContext | undefined { + const event = asGitHubEventPayload(eventPayload); + const pullRequest = event?.pull_request; + const repository = parseRepository(event?.repository?.full_name) ?? parseRepository(env.GITHUB_REPOSITORY); + const ref = nonEmptyString(env.GITHUB_REF) ?? nonEmptyString(event?.ref); + const envSha = nonEmptyString(env.GITHUB_SHA); + const eventSha = nonEmptyString(event?.after); + const pullRequestHeadSha = nonEmptyString(pullRequest?.head?.sha); + const sha = pullRequestHeadSha ?? eventSha ?? envSha; + + if (!repository || !sha) return undefined; + + const baseRef = nonEmptyString(pullRequest?.base?.ref) ?? nonEmptyString(env.GITHUB_BASE_REF); + const baseSha = nonEmptyString(pullRequest?.base?.sha); + const headRef = nonEmptyString(pullRequest?.head?.ref) ?? nonEmptyString(env.GITHUB_HEAD_REF); + const pullRequestNumber = positiveInteger(pullRequest?.number) ?? parsePullRequestNumber(ref); + + return { + repository, + sha, + ...(ref ? { ref } : {}), + ...(baseRef ? { baseRef } : {}), + ...(baseSha ? { baseSha } : {}), + ...(headRef ? { headRef } : {}), + ...(pullRequestNumber ? { pullRequestNumber } : {}), + }; +} + +/** Load and bound the local GitHub Actions event payload, then resolve the effective context. */ +export async function loadGitHubContext( + env: NodeJS.ProcessEnv = process.env, +): Promise { + const eventPath = nonEmptyString(env.GITHUB_EVENT_PATH); + if (!eventPath) return detectGitHubContext(env); + + const eventStat = await stat(eventPath); + if (!eventStat.isFile()) throw new Error(`GITHUB_EVENT_PATH is not a file: ${eventPath}`); + if (eventStat.size > MAX_GITHUB_EVENT_BYTES) { + throw new Error(`GitHub event payload exceeds ${MAX_GITHUB_EVENT_BYTES} bytes.`); + } + + const raw = await readFile(eventPath, "utf8"); + let payload: unknown; + try { + payload = JSON.parse(raw) as unknown; + } catch { + throw new Error(`GITHUB_EVENT_PATH does not contain valid JSON: ${eventPath}`); + } + return detectGitHubContext(env, payload); +} + +function annotationLevel(severity: Severity): GitHubAnnotationLevel { + if (severity === "critical" || severity === "high") return "failure"; + if (severity === "medium" || severity === "low") return "warning"; + return "notice"; +} + +function singleLine(value: string, maxLength = 1024): string { + const normalized = value.replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim(); + if (normalized.length <= maxLength) return normalized; + return `${normalized.slice(0, Math.max(0, maxLength - 1))}…`; +} + +function findingAnnotation(finding: CorrelatedFinding): GitHubCheckAnnotation | undefined { + const primary = finding.primary; + const location = primary.location; + if (!location?.path || !location.startLine) return undefined; + + const sources = finding.sources.map((source) => source.name).join(", "); + const details = [ + primary.description, + primary.remediation ? `Remediation: ${primary.remediation}` : undefined, + sources ? `Sources: ${sources}` : undefined, + `SynSec fingerprint: ${finding.fingerprint}`, + ] + .filter((value): value is string => Boolean(value)) + .join("\n\n"); + + return { + path: location.path.replaceAll("\\", "/").replace(/^\.\//, ""), + start_line: Math.max(1, location.startLine), + end_line: Math.max(location.startLine, location.endLine ?? location.startLine), + annotation_level: annotationLevel(primary.severity), + title: singleLine(`[${primary.severity.toUpperCase()}] ${primary.title}`, 255), + message: singleLine(primary.description ?? primary.title), + ...(details ? { raw_details: details.slice(0, 65_535) } : {}), + }; +} + +export function buildGitHubAnnotations( + report: SynSecReport, + options: { maxAnnotations?: number; onlyNew?: boolean } = {}, +): GitHubCheckAnnotation[] { + const maxAnnotations = Math.max(0, Math.min(50, options.maxAnnotations ?? 50)); + const newFingerprints = options.onlyNew && report.baseline ? new Set(report.baseline.new) : undefined; + + return report.findings + .filter((finding) => !newFingerprints || newFingerprints.has(finding.fingerprint)) + .sort((a, b) => { + const severityDelta = severityRank[b.primary.severity] - severityRank[a.primary.severity]; + if (severityDelta !== 0) return severityDelta; + return b.primary.confidence - a.primary.confidence; + }) + .map(findingAnnotation) + .filter((annotation): annotation is GitHubCheckAnnotation => Boolean(annotation)) + .slice(0, maxAnnotations); +} + +export function reportFailsThreshold(report: SynSecReport, threshold: GitHubCheckThreshold): boolean { + if (threshold === "none") return false; + const required = severityRank[threshold]; + if (required <= 0) return false; + return report.findings.some((finding) => severityRank[finding.primary.severity] >= required); +} + +function markdownSummary(report: SynSecReport, threshold: GitHubCheckThreshold): string { + const delta = report.baseline; + const deltaLine = delta + ? `New: **${delta.new.length}** · Fixed: **${delta.fixed.length}** · Persisting: **${delta.persisting.length}**` + : "No baseline comparison was provided."; + + return [ + `Security score: **${report.securityScore}/100** · Findings: **${report.findingCount}**`, + `Critical: **${report.summary.critical}** · High: **${report.summary.high}** · Medium: **${report.summary.medium}** · Low: **${report.summary.low}**`, + deltaLine, + `CI threshold: **${threshold}**`, + ].join("\n\n"); +} + +export function buildGitHubCheck( + report: SynSecReport, + context: GitHubPullRequestContext, + options: { threshold?: GitHubCheckThreshold; onlyNewAnnotations?: boolean; maxAnnotations?: number } = {}, +): GitHubCheckResult { + const threshold = options.threshold ?? "high"; + const failed = reportFailsThreshold(report, threshold); + const annotations = buildGitHubAnnotations(report, { + maxAnnotations: options.maxAnnotations, + onlyNew: options.onlyNewAnnotations ?? Boolean(report.baseline), + }); + + const scope = report.scope?.mode === "changed-files" ? "changed files" : "repository"; + const conclusion: GitHubCheckConclusion = failed ? "failure" : report.findingCount > 0 ? "neutral" : "success"; + + return { + name: "SynSec repository security", + headSha: context.sha, + conclusion, + output: { + title: failed ? `SynSec found findings at or above ${threshold}` : `SynSec ${scope} scan complete`, + summary: markdownSummary(report, threshold), + text: `Report ${report.reportId} scanned ${scope} with ${report.scanners.length} scanner run(s).`, + annotations, + }, + }; +} diff --git a/packages/github/src/installation-store.ts b/packages/github/src/installation-store.ts new file mode 100644 index 00000000..1e93657e --- /dev/null +++ b/packages/github/src/installation-store.ts @@ -0,0 +1,212 @@ +import { lstat, open, readFile, readdir, rename, rm, stat } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { randomBytes } from "node:crypto"; +import { ensurePrivateDirectory } from "./private-directory.js"; + +const MAX_RECORD_BYTES = 16 * 1024; +const MAX_LIST_ENTRIES = 10_000; +const MAX_LOGIN_LENGTH = 255; +const MAX_REPOSITORY_COUNT = 10_000; +const MAX_REPOSITORY_LENGTH = 255; + +export type GitHubInstallationAccountType = "User" | "Organization"; +export type GitHubRepositorySelection = "all" | "selected"; + +export interface GitHubInstallationRecord { + version: 1; + installationId: number; + accountLogin: string; + accountType: GitHubInstallationAccountType; + repositorySelection: GitHubRepositorySelection; + repositories: string[]; + suspendedAt?: string; + updatedAt: string; +} + +export interface GitHubInstallationRecordInput { + installationId: number; + accountLogin: string; + accountType: GitHubInstallationAccountType; + repositorySelection: GitHubRepositorySelection; + repositories?: string[]; + suspendedAt?: string; + updatedAt?: string; +} + +function positiveInteger(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive integer.`); + } + return value; +} + +function boundedString(value: unknown, label: string, maxLength: number): string { + if (typeof value !== "string") throw new Error(`${label} must be a string.`); + const normalized = value.trim(); + if (!normalized) throw new Error(`${label} is required.`); + if (normalized.length > maxLength) throw new Error(`${label} exceeds ${maxLength} characters.`); + return normalized; +} + +function repositoryName(value: unknown): string { + const repository = boundedString(value, "GitHub repository", MAX_REPOSITORY_LENGTH); + if (!/^[^/\s]+\/[^/\s]+$/.test(repository)) throw new Error("GitHub repository must be in owner/name form."); + return repository; +} + +function timestamp(value: unknown, label: string): string { + const normalized = boundedString(value, label, 64); + if (!Number.isFinite(Date.parse(normalized))) throw new Error(`${label} must be an ISO timestamp.`); + return normalized; +} + +function normalize(input: GitHubInstallationRecordInput | GitHubInstallationRecord): GitHubInstallationRecord { + const installationId = positiveInteger(input.installationId, "GitHub installation id"); + const accountLogin = boundedString(input.accountLogin, "GitHub account login", MAX_LOGIN_LENGTH); + if (input.accountType !== "User" && input.accountType !== "Organization") { + throw new Error("GitHub installation account type must be User or Organization."); + } + if (input.repositorySelection !== "all" && input.repositorySelection !== "selected") { + throw new Error("GitHub repository selection must be all or selected."); + } + const sourceRepositories = input.repositories ?? []; + if (!Array.isArray(sourceRepositories) || sourceRepositories.length > MAX_REPOSITORY_COUNT) { + throw new Error(`GitHub installation repositories exceed the ${MAX_REPOSITORY_COUNT}-entry limit.`); + } + const repositories = [...new Set(sourceRepositories.map(repositoryName))].sort(); + if (input.repositorySelection === "all" && repositories.length > 0) { + throw new Error("GitHub installations with repositorySelection=all must not persist an enumerated repository list."); + } + const updatedAt = timestamp(input.updatedAt ?? new Date().toISOString(), "GitHub installation updatedAt"); + const suspendedAt = input.suspendedAt === undefined ? undefined : timestamp(input.suspendedAt, "GitHub installation suspendedAt"); + return { + version: 1, + installationId, + accountLogin, + accountType: input.accountType, + repositorySelection: input.repositorySelection, + repositories, + ...(suspendedAt ? { suspendedAt } : {}), + updatedAt, + }; +} + +function recordPath(directory: string, installationId: number): string { + return join(directory, `${installationId}.json`); +} + +async function readRecord(path: string): Promise { + const metadata = await lstat(path); + if (metadata.isSymbolicLink() || !metadata.isFile() || metadata.size > MAX_RECORD_BYTES) { + throw new Error("Stored GitHub installation record is invalid, symlinked, or oversized."); + } + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(path, "utf8")); + } catch { + throw new Error("Stored GitHub installation record is invalid JSON."); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Stored GitHub installation record has an invalid shape."); + } + const record = parsed as Partial; + if (record.version !== 1) throw new Error("Stored GitHub installation record has an unsupported version."); + return normalize(record as GitHubInstallationRecord); +} + +function isNotFound(error: unknown): boolean { + return error instanceof Error + && Object.prototype.hasOwnProperty.call(error, "code") + && (error as NodeJS.ErrnoException).code === "ENOENT"; +} + +/** + * Minimal durable GitHub App installation state. + * + * Tokens, private keys, webhook secrets, clone URLs, and repository credentials are + * intentionally not part of this schema. Selected-repository installations persist + * only validated owner/name identifiers required to decide whether SynSec may scan. + */ +export class FileGitHubInstallationStore { + readonly directory: string; + + constructor(directory: string) { + const normalized = directory.trim(); + if (!normalized) throw new Error("GitHub installation-store directory is required."); + this.directory = resolve(normalized); + } + + async put(input: GitHubInstallationRecordInput): Promise { + const record = normalize(input); + await ensurePrivateDirectory(this.directory); + const path = recordPath(this.directory, record.installationId); + const tempPath = join(this.directory, `.installation-${record.installationId}-${randomBytes(12).toString("hex")}.tmp`); + const handle = await open(tempPath, "wx", 0o600); + try { + await handle.writeFile(`${JSON.stringify(record)}\n`, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await rename(tempPath, path); + } finally { + await rm(tempPath, { force: true }); + } + return record; + } + + async get(installationIdValue: number): Promise { + const installationId = positiveInteger(installationIdValue, "GitHub installation id"); + await ensurePrivateDirectory(this.directory); + try { + const record = await readRecord(recordPath(this.directory, installationId)); + if (record.installationId !== installationId) throw new Error("Stored GitHub installation id does not match its filename."); + return record; + } catch (error) { + if (isNotFound(error)) return undefined; + throw error; + } + } + + async remove(installationIdValue: number): Promise { + const installationId = positiveInteger(installationIdValue, "GitHub installation id"); + await ensurePrivateDirectory(this.directory); + const path = recordPath(this.directory, installationId); + try { + const metadata = await lstat(path); + if (metadata.isSymbolicLink() || !metadata.isFile()) { + throw new Error("Stored GitHub installation record is invalid or symlinked."); + } + } catch (error) { + if (isNotFound(error)) return false; + throw error; + } + await rm(path); + return true; + } + + async list(): Promise { + await ensurePrivateDirectory(this.directory); + const entries = (await readdir(this.directory, { withFileTypes: true })) + .filter((entry) => entry.isFile() && /^\d+\.json$/.test(entry.name)); + if (entries.length > MAX_LIST_ENTRIES) throw new Error(`GitHub installation store exceeds the ${MAX_LIST_ENTRIES}-entry limit.`); + const records: GitHubInstallationRecord[] = []; + for (const entry of entries) { + const id = Number(entry.name.slice(0, -5)); + const record = await readRecord(join(this.directory, entry.name)); + if (!Number.isSafeInteger(id) || id <= 0 || record.installationId !== id) { + throw new Error("Stored GitHub installation id does not match its filename."); + } + records.push(record); + } + return records.sort((a, b) => a.installationId - b.installationId); + } + + async isRepositoryAllowed(installationIdValue: number, repositoryValue: string): Promise { + const record = await this.get(installationIdValue); + if (!record || record.suspendedAt) return false; + const repository = repositoryName(repositoryValue); + return record.repositorySelection === "all" || record.repositories.includes(repository); + } +} diff --git a/packages/github/src/installation-sync.ts b/packages/github/src/installation-sync.ts new file mode 100644 index 00000000..846c9365 --- /dev/null +++ b/packages/github/src/installation-sync.ts @@ -0,0 +1,327 @@ +import { verifyGitHubWebhookSignature, type GitHubWebhookSecret } from "./app.js"; +import { validateGitHubRepositoryIdentity } from "./repository-acquisition.js"; +import type { + GitHubInstallationRecord, + GitHubInstallationRecordInput, + GitHubRepositorySelection, +} from "./installation-store.js"; + +const MAX_REPOSITORIES = 10_000; +const MAX_LOGIN_LENGTH = 255; +const SUPPORTED_INSTALLATION_ACTIONS = new Set(["created", "deleted", "suspend", "unsuspend", "new_permissions_accepted"]); +const SUPPORTED_REPOSITORY_ACTIONS = new Set(["added", "removed"]); + +export interface GitHubInstallationStateStore { + get(installationId: number): Promise; + put(input: GitHubInstallationRecordInput): Promise; + remove(installationId: number): Promise; +} + +/** + * Optional shared-backend extension that binds one installation synchronization operation to a + * single durable transaction. The callback receives a transaction-scoped store; callers must not + * retain that scoped object after the callback resolves. + */ +export interface GitHubTransactionalInstallationStateStore extends GitHubInstallationStateStore { + withInstallationTransaction( + installationId: number, + operation: (store: GitHubInstallationStateStore) => Promise, + ): Promise; +} + +export interface GitHubInstallationStateEvent { + event: "installation" | "installation_repositories"; + action: string; + installationId: number; + accountLogin?: string; + accountType?: "User" | "Organization"; + repositorySelection?: GitHubRepositorySelection; + suspendedAt?: string; + repositories: string[]; + repositoriesAdded: string[]; + repositoriesRemoved: string[]; +} + +export type GitHubInstallationSyncResult = + | { status: "updated"; record: GitHubInstallationRecord } + | { status: "removed"; installationId: number; existed: boolean }; + +const installationSyncLocks = new WeakMap>>(); + +async function withInstallationSyncLock( + store: GitHubInstallationStateStore, + installationId: number, + operation: () => Promise, +): Promise { + let locks = installationSyncLocks.get(store); + if (!locks) { + locks = new Map>(); + installationSyncLocks.set(store, locks); + } + const previous = locks.get(installationId) ?? Promise.resolve(); + let release!: () => void; + const gate = new Promise((resolveGate) => { release = resolveGate; }); + const tail = previous.then(() => gate); + locks.set(installationId, tail); + await previous; + try { + return await operation(); + } finally { + release(); + if (locks.get(installationId) === tail) { + locks.delete(installationId); + if (locks.size === 0) installationSyncLocks.delete(store); + } + } +} + +function transactionalStore(value: GitHubInstallationStateStore): value is GitHubTransactionalInstallationStateStore { + return "withInstallationTransaction" in value + && typeof (value as Partial).withInstallationTransaction === "function"; +} + +function objectValue(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +function requiredPositiveInteger(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive integer.`); + } + return value; +} + +function requiredString(value: unknown, label: string, maxLength: number): string { + if (typeof value !== "string") throw new Error(`${label} must be a string.`); + const normalized = value.trim(); + if (!normalized) throw new Error(`${label} is required.`); + if (normalized.length > maxLength) throw new Error(`${label} exceeds ${maxLength} characters.`); + return normalized; +} + +function accountType(value: unknown): "User" | "Organization" { + if (value !== "User" && value !== "Organization") { + throw new Error("GitHub installation account type must be User or Organization."); + } + return value; +} + +function repositorySelection(value: unknown): GitHubRepositorySelection { + if (value !== "all" && value !== "selected") { + throw new Error("GitHub installation repository selection must be all or selected."); + } + return value; +} + +function optionalTimestamp(value: unknown): string | undefined { + if (value === null || value === undefined) return undefined; + const normalized = requiredString(value, "GitHub installation suspension timestamp", 64); + if (!Number.isFinite(Date.parse(normalized))) { + throw new Error("GitHub installation suspension timestamp must be an ISO timestamp."); + } + return normalized; +} + +function repositoryList(value: unknown, label: string): string[] { + if (value === undefined) return []; + if (!Array.isArray(value)) throw new Error(`${label} must be an array.`); + if (value.length > MAX_REPOSITORIES) throw new Error(`${label} exceeds ${MAX_REPOSITORIES} repositories.`); + const names = value.map((entry) => { + const fullName = objectValue(entry)?.full_name; + if (typeof fullName !== "string") throw new Error(`${label} contains a repository without full_name.`); + return validateGitHubRepositoryIdentity(fullName); + }); + return [...new Set(names)].sort(); +} + +/** + * Verify and normalize only GitHub installation-management state required for authorization. + * Clone/API URLs, permissions, tokens, and arbitrary payload fields are intentionally discarded. + */ +export function parseVerifiedGitHubInstallationStateEvent(input: { + body: string | Uint8Array; + signatureHeader?: string; + webhookSecret: GitHubWebhookSecret; + eventName: string; +}): GitHubInstallationStateEvent { + if (input.eventName !== "installation" && input.eventName !== "installation_repositories") { + throw new Error("GitHub installation state synchronization accepts only installation management events."); + } + if (!verifyGitHubWebhookSignature(input.body, input.signatureHeader, input.webhookSecret)) { + throw new Error("GitHub webhook signature verification failed."); + } + + let payload: Record; + try { + const parsed = JSON.parse(Buffer.from(input.body).toString("utf8")); + const object = objectValue(parsed); + if (!object) throw new Error(); + payload = object; + } catch { + throw new Error("GitHub installation webhook body must be a JSON object."); + } + + const action = requiredString(payload.action, "GitHub installation action", 64); + const installation = objectValue(payload.installation); + if (!installation) throw new Error("GitHub installation webhook is missing installation metadata."); + const installationId = requiredPositiveInteger(installation.id, "GitHub installation id"); + + if (input.eventName === "installation") { + if (!SUPPORTED_INSTALLATION_ACTIONS.has(action)) { + throw new Error(`Unsupported GitHub installation action: ${action}`); + } + if (action === "deleted") { + return { + event: "installation", + action, + installationId, + repositories: [], + repositoriesAdded: [], + repositoriesRemoved: [], + }; + } + } else if (!SUPPORTED_REPOSITORY_ACTIONS.has(action)) { + throw new Error(`Unsupported GitHub installation_repositories action: ${action}`); + } + + const account = objectValue(installation.account); + if (!account) throw new Error("GitHub installation webhook is missing account metadata."); + const selection = repositorySelection(installation.repository_selection); + const repositories = selection === "all" ? [] : repositoryList(payload.repositories, "GitHub installation repositories"); + const suspendedAt = optionalTimestamp(installation.suspended_at); + + return { + event: input.eventName, + action, + installationId, + accountLogin: requiredString(account.login, "GitHub installation account login", MAX_LOGIN_LENGTH), + accountType: accountType(account.type), + repositorySelection: selection, + ...(suspendedAt ? { suspendedAt } : {}), + repositories, + repositoriesAdded: repositoryList(payload.repositories_added, "GitHub added repositories"), + repositoriesRemoved: repositoryList(payload.repositories_removed, "GitHub removed repositories"), + }; +} + +function requireMetadata(event: GitHubInstallationStateEvent): { + accountLogin: string; + accountType: "User" | "Organization"; + repositorySelection: GitHubRepositorySelection; +} { + if (!event.accountLogin || !event.accountType || !event.repositorySelection) { + throw new Error("GitHub installation event is missing normalized authorization metadata."); + } + return { + accountLogin: event.accountLogin, + accountType: event.accountType, + repositorySelection: event.repositorySelection, + }; +} + +async function synchronizeGitHubInstallationStateUnlocked( + event: GitHubInstallationStateEvent, + store: GitHubInstallationStateStore, + now: number, +): Promise { + if (event.event === "installation" && event.action === "deleted") { + const existed = await store.remove(event.installationId); + return { status: "removed", installationId: event.installationId, existed }; + } + + const metadata = requireMetadata(event); + const existing = await store.get(event.installationId); + const updatedAt = new Date(now).toISOString(); + + if (event.event === "installation_repositories") { + if (!existing) throw new Error("GitHub installation repository selection changed before installation state was initialized."); + if (existing.repositorySelection !== "selected" || metadata.repositorySelection !== "selected") { + throw new Error("GitHub installation repository delta is inconsistent with repositorySelection=selected."); + } + if (existing.accountLogin !== metadata.accountLogin || existing.accountType !== metadata.accountType) { + throw new Error("GitHub installation repository delta account identity does not match stored authorization state."); + } + const repositories = new Set(existing.repositories); + for (const repository of event.repositoriesRemoved) repositories.delete(repository); + for (const repository of event.repositoriesAdded) repositories.add(repository); + const record = await store.put({ + installationId: event.installationId, + accountLogin: existing.accountLogin, + accountType: existing.accountType, + repositorySelection: "selected", + repositories: [...repositories], + ...(existing.suspendedAt ? { suspendedAt: existing.suspendedAt } : {}), + updatedAt, + }); + return { status: "updated", record }; + } + + let repositories: string[] | undefined; + if (metadata.repositorySelection === "selected") { + if (event.action === "created") { + repositories = event.repositories; + } else { + repositories = event.repositories.length > 0 + ? event.repositories + : existing?.repositorySelection === "selected" + ? existing.repositories + : []; + } + } + + const suspendedAt = event.action === "unsuspend" + ? undefined + : event.action === "suspend" + ? event.suspendedAt ?? updatedAt + : event.suspendedAt; + + const record = await store.put({ + installationId: event.installationId, + ...metadata, + ...(repositories ? { repositories } : {}), + ...(suspendedAt ? { suspendedAt } : {}), + updatedAt, + }); + return { status: "updated", record }; +} + +/** + * Apply one already verified GitHub installation-management event to durable authorization state. + * + * Transaction-aware shared stores execute the entire read-modify-write sequence under one backend + * transaction for the installation. Other stores retain the existing per-runtime lock, which is + * intentionally only a single-process guarantee. + */ +export async function synchronizeGitHubInstallationState( + event: GitHubInstallationStateEvent, + store: GitHubInstallationStateStore, + now = Date.now(), +): Promise { + if (!Number.isFinite(now) || now <= 0) throw new Error("GitHub installation synchronization clock must be a positive timestamp."); + if (transactionalStore(store)) { + return store.withInstallationTransaction( + event.installationId, + (transactionStore) => synchronizeGitHubInstallationStateUnlocked(event, transactionStore, now), + ); + } + return withInstallationSyncLock( + store, + event.installationId, + () => synchronizeGitHubInstallationStateUnlocked(event, store, now), + ); +} + +/** Verify, normalize, and synchronize one installation-management delivery. */ +export async function synchronizeVerifiedGitHubInstallationWebhook(input: { + body: string | Uint8Array; + signatureHeader?: string; + webhookSecret: GitHubWebhookSecret; + eventName: string; + store: GitHubInstallationStateStore; + now?: number; +}): Promise { + const event = parseVerifiedGitHubInstallationStateEvent(input); + return synchronizeGitHubInstallationState(event, input.store, input.now ?? Date.now()); +} diff --git a/packages/github/src/mounted-runtime-credentials.ts b/packages/github/src/mounted-runtime-credentials.ts new file mode 100644 index 00000000..dafce78a --- /dev/null +++ b/packages/github/src/mounted-runtime-credentials.ts @@ -0,0 +1,102 @@ +import { lstat, readFile } from "node:fs/promises"; +import { isAbsolute, join, resolve } from "node:path"; +import type { GitHubAppRuntimeCredentialSnapshot } from "./runtime-credentials.js"; + +const GENERATION_FILE = "generation"; +const PRIVATE_KEY_FILE = "private-key.pem"; +const WEBHOOK_SECRET_FILE = "webhook-secret"; +const PREVIOUS_WEBHOOK_SECRET_FILE = "webhook-secret-previous"; +const MAX_GENERATION_BYTES = 512; +const MAX_PRIVATE_KEY_BYTES = 64 * 1024; +const MAX_WEBHOOK_SECRET_BYTES = 4096; + +function sanitizedError(message: string): Error { + return new Error(message); +} + +function stripOneTerminalNewline(value: string): string { + if (value.endsWith("\r\n")) return value.slice(0, -2); + if (value.endsWith("\n")) return value.slice(0, -1); + return value; +} + +async function readBoundedRegularFile( + root: string, + filename: string, + maximumBytes: number, + optional = false, +): Promise { + const path = join(root, filename); + let info; + try { + info = await lstat(path); + } catch (error) { + const code = error && typeof error === "object" && "code" in error + ? String((error as { code?: unknown }).code ?? "") + : ""; + if (optional && code === "ENOENT") return undefined; + throw sanitizedError("GitHub App mounted credential file is unavailable."); + } + if (!info.isFile() || info.isSymbolicLink()) { + throw sanitizedError("GitHub App mounted credential files must be regular non-symlink files."); + } + if (info.size < 1 || info.size > maximumBytes) { + throw sanitizedError("GitHub App mounted credential file violates its byte bound."); + } + try { + return await readFile(path, "utf8"); + } catch { + throw sanitizedError("GitHub App mounted credential file could not be read."); + } +} + +/** + * Read one operator-managed GitHub App credential generation from fixed filenames. + * + * The directory is an integration boundary for mounted secrets supplied by a supervisor, container + * orchestrator, CSI driver, tmpfs handoff, or other operator-controlled mechanism. SynSec never + * writes into this directory. The directory itself and every credential file must be regular and + * non-symlink shaped; this deliberately prefers a narrow portable contract over following secret + * manager-specific indirection. Fixed filenames prevent repository or CLI metadata from selecting + * arbitrary host files, and byte bounds are enforced before reads. + * + * Returned credential values are intended to flow immediately into createGitHubAppRuntimeCredentialSource() + * or its reload() loader. Errors are categorical and never reflect paths or file contents. + */ +export async function loadMountedGitHubAppRuntimeCredentialSnapshot( + directoryValue: string, +): Promise { + if (typeof directoryValue !== "string" || !isAbsolute(directoryValue) || directoryValue.includes("\0")) { + throw sanitizedError("GitHub App mounted credential directory must be an absolute path."); + } + const directory = resolve(directoryValue); + let directoryInfo; + try { + directoryInfo = await lstat(directory); + } catch { + throw sanitizedError("GitHub App mounted credential directory is unavailable."); + } + if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink()) { + throw sanitizedError("GitHub App mounted credential directory must be a non-symlink directory."); + } + + const [generationRaw, privateKey, activeSecretRaw, previousSecretRaw] = await Promise.all([ + readBoundedRegularFile(directory, GENERATION_FILE, MAX_GENERATION_BYTES), + readBoundedRegularFile(directory, PRIVATE_KEY_FILE, MAX_PRIVATE_KEY_BYTES), + readBoundedRegularFile(directory, WEBHOOK_SECRET_FILE, MAX_WEBHOOK_SECRET_BYTES), + readBoundedRegularFile(directory, PREVIOUS_WEBHOOK_SECRET_FILE, MAX_WEBHOOK_SECRET_BYTES, true), + ]); + + const generation = stripOneTerminalNewline(generationRaw ?? "").trim(); + const activeSecret = stripOneTerminalNewline(activeSecretRaw ?? ""); + const previousSecret = previousSecretRaw === undefined ? undefined : stripOneTerminalNewline(previousSecretRaw); + if (!generation || !privateKey || !activeSecret) { + throw sanitizedError("GitHub App mounted credential snapshot is incomplete."); + } + + return { + generation, + privateKey, + webhookSecret: previousSecret === undefined ? activeSecret : [activeSecret, previousSecret] as const, + }; +} diff --git a/packages/github/src/orchestrator.ts b/packages/github/src/orchestrator.ts new file mode 100644 index 00000000..44d67bd4 --- /dev/null +++ b/packages/github/src/orchestrator.ts @@ -0,0 +1,72 @@ +import type { SynSecReport } from "@synsec/report"; +import { + buildGitHubCheck, + loadGitHubContext, + type GitHubCheckResult, + type GitHubCheckThreshold, + type GitHubPullRequestContext, +} from "./index.js"; +import { + publishGitHubCheck, + type GitHubCheckPublication, + type GitHubPublisherOptions, +} from "./publisher.js"; + +export interface GitHubReportPublicationOptions extends GitHubPublisherOptions { + env?: NodeJS.ProcessEnv; + threshold?: GitHubCheckThreshold; + onlyNewAnnotations?: boolean; + maxAnnotations?: number; +} + +export interface GitHubReportPublicationResult { + context: GitHubPullRequestContext; + check: GitHubCheckResult; + publication: GitHubCheckPublication; +} + +export function reportMatchesGitHubCommit(reportSha: string, contextSha: string): boolean { + const report = reportSha.trim().toLowerCase(); + const context = contextSha.trim().toLowerCase(); + if (!report || !context) return false; + if (report === context) return true; + + const hexSha = /^[0-9a-f]+$/; + if (!hexSha.test(report) || !hexSha.test(context) || Math.min(report.length, context.length) < 7) return false; + return report.startsWith(context) || context.startsWith(report); +} + +/** + * Convert a completed SynSec report into a GitHub check and publish it to the commit represented + * by the bounded local Actions context. This function never performs scanning, target discovery, + * repository mutation, or external assessment; it only transports an already-produced report. + */ +export async function publishSynSecReportToGitHub( + report: SynSecReport, + token: string, + options: GitHubReportPublicationOptions = {}, +): Promise { + const context = await loadGitHubContext(options.env ?? process.env); + if (!context) { + throw new Error("Unable to resolve a valid GitHub repository and commit context for check publication."); + } + + const reportCommitSha = report.target.commitSha?.trim(); + if (reportCommitSha && !reportMatchesGitHubCommit(reportCommitSha, context.sha)) { + throw new Error("SynSec report commit does not match the GitHub commit selected for publication."); + } + + const check = buildGitHubCheck(report, context, { + threshold: options.threshold, + onlyNewAnnotations: options.onlyNewAnnotations, + maxAnnotations: options.maxAnnotations, + }); + + const publication = await publishGitHubCheck(check, context, token, { + apiVersion: options.apiVersion, + userAgent: options.userAgent, + fetch: options.fetch, + }); + + return { context, check, publication }; +} diff --git a/packages/github/src/postgres-hosted-installation-ownership.ts b/packages/github/src/postgres-hosted-installation-ownership.ts new file mode 100644 index 00000000..39d1178f --- /dev/null +++ b/packages/github/src/postgres-hosted-installation-ownership.ts @@ -0,0 +1,338 @@ +import type { + SynSecHostedInstallationOwnershipClaim, + SynSecHostedInstallationOwnershipStore, + SynSecHostedInstallationClaimResult, +} from "./hosted-installation-ownership.js"; +import type { + SynSecHostedInstallationReverificationFence, + SynSecHostedInstallationReverificationFinishResult, + SynSecHostedInstallationReverificationStore, + SynSecHostedInstallationRevocationReason, +} from "./hosted-installation-reverification.js"; +import type { PostgresPoolLike, PostgresTransactionClient } from "./postgres-shared-state.js"; + +const MAX_TENANT_ID_LENGTH = 128; +const MAX_LOGIN_LENGTH = 255; +const MIN_FRESHNESS_MS = 60_000; +const MAX_FRESHNESS_MS = 30 * 24 * 60 * 60 * 1000; + +export const SYNSEC_GITHUB_POSTGRES_HOSTED_OWNERSHIP_MIGRATIONS = [ + `CREATE TABLE IF NOT EXISTS synsec_github_hosted_installation_ownership ( + installation_id bigint PRIMARY KEY CHECK (installation_id > 0), + tenant_id varchar(128) NOT NULL, + github_user_id bigint NOT NULL CHECK (github_user_id > 0), + account_id bigint NOT NULL CHECK (account_id > 0), + account_login varchar(255) NOT NULL, + account_type varchar(16) NOT NULL CHECK (account_type IN ('User', 'Organization')), + claimed_at timestamptz(3) NOT NULL DEFAULT date_trunc('milliseconds', clock_timestamp()), + CHECK (tenant_id ~ '^[A-Za-z0-9][A-Za-z0-9._:-]*$') + )`, + `ALTER TABLE synsec_github_hosted_installation_ownership + ADD COLUMN IF NOT EXISTS verification_epoch bigint NOT NULL DEFAULT 0`, + `ALTER TABLE synsec_github_hosted_installation_ownership + ADD COLUMN IF NOT EXISTS access_status varchar(16) NOT NULL DEFAULT 'active'`, + `ALTER TABLE synsec_github_hosted_installation_ownership + ADD COLUMN IF NOT EXISTS verified_at timestamptz(3)`, + `UPDATE synsec_github_hosted_installation_ownership + SET verified_at = claimed_at WHERE verified_at IS NULL`, + `ALTER TABLE synsec_github_hosted_installation_ownership + ALTER COLUMN verified_at SET NOT NULL`, + `ALTER TABLE synsec_github_hosted_installation_ownership + ADD COLUMN IF NOT EXISTS revoked_at timestamptz(3)`, + `ALTER TABLE synsec_github_hosted_installation_ownership + ADD COLUMN IF NOT EXISTS revocation_reason varchar(32)`, + `DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'synsec_github_hosted_ownership_access_status_check' + ) THEN + ALTER TABLE synsec_github_hosted_installation_ownership + ADD CONSTRAINT synsec_github_hosted_ownership_access_status_check + CHECK (access_status IN ('active', 'revoked')); + END IF; + END $$`, + `DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'synsec_github_hosted_ownership_revocation_reason_check' + ) THEN + ALTER TABLE synsec_github_hosted_installation_ownership + ADD CONSTRAINT synsec_github_hosted_ownership_revocation_reason_check + CHECK ( + revocation_reason IS NULL OR revocation_reason IN ('inaccessible', 'suspended', 'account-identity-changed') + ); + END IF; + END $$`, + `CREATE INDEX IF NOT EXISTS synsec_github_hosted_installation_ownership_tenant_idx + ON synsec_github_hosted_installation_ownership (tenant_id, installation_id)`, +] as const; + +function positiveInteger(value: unknown, label: string): number { + const normalized = typeof value === "number" ? value : Number(value); + if (!Number.isSafeInteger(normalized) || normalized <= 0) { + throw new Error(`${label} must be a positive integer.`); + } + return normalized; +} + +function nonnegativeInteger(value: unknown, label: string): number { + const normalized = typeof value === "number" ? value : Number(value); + if (!Number.isSafeInteger(normalized) || normalized < 0) throw new Error(`${label} must be a non-negative integer.`); + return normalized; +} + +function tenantId(value: unknown): string { + if (typeof value !== "string") throw new Error("Hosted tenant id must be a string."); + const normalized = value.trim(); + if (!normalized || normalized.length > MAX_TENANT_ID_LENGTH || !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(normalized)) { + throw new Error("Hosted tenant id must be a bounded non-secret identifier."); + } + return normalized; +} + +function accountLogin(value: unknown): string { + if (typeof value !== "string") throw new Error("GitHub account login must be a string."); + const normalized = value.trim(); + if (!normalized || normalized.length > MAX_LOGIN_LENGTH || /[\u0000-\u001f\u007f]/.test(normalized)) { + throw new Error("GitHub account login is invalid."); + } + return normalized; +} + +function accountType(value: unknown): "User" | "Organization" { + if (value !== "User" && value !== "Organization") throw new Error("GitHub installation account type is invalid."); + return value; +} + +function validateClaim(input: SynSecHostedInstallationOwnershipClaim): SynSecHostedInstallationOwnershipClaim { + return { + tenantId: tenantId(input?.tenantId), + installationId: positiveInteger(input?.installationId, "GitHub installation id"), + githubUserId: positiveInteger(input?.githubUserId, "Authenticated GitHub user id"), + accountId: positiveInteger(input?.accountId, "GitHub installation account id"), + accountLogin: accountLogin(input?.accountLogin), + accountType: accountType(input?.accountType), + }; +} + +function validateFence(input: SynSecHostedInstallationReverificationFence): SynSecHostedInstallationReverificationFence { + return { + epoch: positiveInteger(input?.epoch, "Hosted installation verification epoch"), + tenantId: tenantId(input?.tenantId), + installationId: positiveInteger(input?.installationId, "GitHub installation id"), + githubUserId: positiveInteger(input?.githubUserId, "Authenticated GitHub user id"), + accountId: positiveInteger(input?.accountId, "GitHub installation account id"), + accountType: accountType(input?.accountType), + }; +} + +function revocationReason(value: unknown): SynSecHostedInstallationRevocationReason { + if (value !== "inaccessible" && value !== "suspended" && value !== "account-identity-changed") { + throw new Error("Hosted installation revocation reason is invalid."); + } + return value; +} + +async function transaction(pool: PostgresPoolLike, operation: (client: PostgresTransactionClient) => Promise): Promise { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const result = await operation(client); + await client.query("COMMIT"); + return result; + } catch (error) { + try { + await client.query("ROLLBACK"); + } catch { + // Preserve the original categorical database failure. + } + throw error; + } finally { + client.release(); + } +} + +export async function migrateSynSecGitHubPostgresHostedInstallationOwnership(pool: PostgresPoolLike): Promise { + await transaction(pool, async (client) => { + await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", ["synsec-hosted-installation-ownership-v1"]); + for (const statement of SYNSEC_GITHUB_POSTGRES_HOSTED_OWNERSHIP_MIGRATIONS) await client.query(statement); + }); +} + +/** + * Transactional tenant ownership store for hosted GitHub App setup and periodic re-verification. + * + * The installation id is the global tenant fence: revocation never deletes or transfers the claim. + * Each remote re-verification first increments verification_epoch. Completion uses compare-and-set + * against that epoch, so a slow result from one replica cannot overwrite a newer observation from + * another replica. Authorization requires active state plus backend-time freshness. + */ +export class PostgresSynSecHostedInstallationOwnershipStore +implements SynSecHostedInstallationOwnershipStore, SynSecHostedInstallationReverificationStore { + constructor(private readonly pool: PostgresPoolLike) {} + + async claim(inputValue: SynSecHostedInstallationOwnershipClaim): Promise { + const input = validateClaim(inputValue); + return transaction(this.pool, async (client) => { + await client.query( + "SELECT pg_advisory_xact_lock(hashtextextended('synsec-hosted-installation:' || $1::text, 0))", + [input.installationId], + ); + const inserted = await client.query( + `INSERT INTO synsec_github_hosted_installation_ownership( + installation_id, tenant_id, github_user_id, account_id, account_login, account_type, + verification_epoch, access_status, verified_at, revoked_at, revocation_reason + ) VALUES ($1,$2,$3,$4,$5,$6,1,'active',date_trunc('milliseconds', clock_timestamp()),NULL,NULL) + ON CONFLICT (installation_id) DO NOTHING + RETURNING installation_id`, + [input.installationId, input.tenantId, input.githubUserId, input.accountId, input.accountLogin, input.accountType], + ); + if (inserted.rows.length === 1) return "claimed"; + + const current = await client.query( + `SELECT tenant_id, account_id, account_type + FROM synsec_github_hosted_installation_ownership + WHERE installation_id = $1`, + [input.installationId], + ); + const row = current.rows[0]; + if (!row || current.rows.length !== 1) throw new Error("Hosted installation ownership state is inconsistent."); + const storedTenant = typeof row.tenant_id === "string" ? row.tenant_id : ""; + const storedAccountId = positiveInteger(row.account_id, "Stored GitHub installation account id"); + const storedAccountType = accountType(row.account_type); + if (storedTenant !== input.tenantId) return "conflict"; + if (storedAccountId !== input.accountId || storedAccountType !== input.accountType) return "conflict"; + + await client.query( + `UPDATE synsec_github_hosted_installation_ownership + SET github_user_id = $2, + account_login = $3, + verification_epoch = verification_epoch + 1, + access_status = 'active', + verified_at = date_trunc('milliseconds', clock_timestamp()), + revoked_at = NULL, + revocation_reason = NULL + WHERE installation_id = $1 AND tenant_id = $4`, + [input.installationId, input.githubUserId, input.accountLogin, input.tenantId], + ); + return "already-owned-by-tenant"; + }); + } + + async beginReverification( + tenantIdValue: string, + installationIdValue: number, + githubUserIdValue: number, + ): Promise { + const tenant = tenantId(tenantIdValue); + const installationId = positiveInteger(installationIdValue, "GitHub installation id"); + const githubUserId = positiveInteger(githubUserIdValue, "Authenticated GitHub user id"); + const result = await this.pool.query( + `UPDATE synsec_github_hosted_installation_ownership + SET verification_epoch = verification_epoch + 1 + WHERE installation_id = $1 AND tenant_id = $2 AND github_user_id = $3 + RETURNING verification_epoch, account_id, account_type`, + [installationId, tenant, githubUserId], + ); + if (result.rows.length === 0) return undefined; + if (result.rows.length !== 1) throw new Error("Hosted installation ownership state is inconsistent."); + const row = result.rows[0]; + if (!row) throw new Error("Hosted installation ownership state is inconsistent."); + return { + epoch: positiveInteger(row.verification_epoch, "Hosted installation verification epoch"), + tenantId: tenant, + installationId, + githubUserId, + accountId: positiveInteger(row.account_id, "GitHub installation account id"), + accountType: accountType(row.account_type), + }; + } + + async finishVerified( + inputValue: SynSecHostedInstallationReverificationFence & { accountLogin: string }, + ): Promise { + const input = validateFence(inputValue); + const login = accountLogin(inputValue.accountLogin); + const result = await this.pool.query( + `UPDATE synsec_github_hosted_installation_ownership + SET account_login = $7, + access_status = 'active', + verified_at = date_trunc('milliseconds', clock_timestamp()), + revoked_at = NULL, + revocation_reason = NULL + WHERE installation_id = $1 AND tenant_id = $2 AND github_user_id = $3 + AND verification_epoch = $4 AND account_id = $5 AND account_type = $6 + RETURNING installation_id`, + [input.installationId, input.tenantId, input.githubUserId, input.epoch, input.accountId, input.accountType, login], + ); + if (result.rows.length === 1) return "applied"; + return this.classifyMiss(input); + } + + async finishRevoked( + inputValue: SynSecHostedInstallationReverificationFence & { reason: SynSecHostedInstallationRevocationReason }, + ): Promise { + const input = validateFence(inputValue); + const reason = revocationReason(inputValue.reason); + const result = await this.pool.query( + `UPDATE synsec_github_hosted_installation_ownership + SET access_status = 'revoked', + revoked_at = date_trunc('milliseconds', clock_timestamp()), + revocation_reason = $7 + WHERE installation_id = $1 AND tenant_id = $2 AND github_user_id = $3 + AND verification_epoch = $4 AND account_id = $5 AND account_type = $6 + RETURNING installation_id`, + [input.installationId, input.tenantId, input.githubUserId, input.epoch, input.accountId, input.accountType, reason], + ); + if (result.rows.length === 1) return "applied"; + return this.classifyMiss(input); + } + + async isFreshlyAuthorized(tenantIdValue: string, installationIdValue: number, maxAgeMsValue: number): Promise { + const tenant = tenantId(tenantIdValue); + const installationId = positiveInteger(installationIdValue, "GitHub installation id"); + if (!Number.isSafeInteger(maxAgeMsValue) || maxAgeMsValue < MIN_FRESHNESS_MS || maxAgeMsValue > MAX_FRESHNESS_MS) { + throw new Error(`Hosted installation verification freshness must be between ${MIN_FRESHNESS_MS} and ${MAX_FRESHNESS_MS} milliseconds.`); + } + const result = await this.pool.query( + `SELECT EXISTS( + SELECT 1 FROM synsec_github_hosted_installation_ownership + WHERE installation_id = $1 AND tenant_id = $2 AND access_status = 'active' + AND verified_at > clock_timestamp() - ($3::bigint * interval '1 millisecond') + ) AS allowed`, + [installationId, tenant, maxAgeMsValue], + ); + return result.rows[0]?.allowed === true; + } + + private async classifyMiss( + input: SynSecHostedInstallationReverificationFence, + ): Promise { + const current = await this.pool.query( + `SELECT tenant_id, github_user_id, account_id, account_type, verification_epoch + FROM synsec_github_hosted_installation_ownership WHERE installation_id = $1`, + [input.installationId], + ); + if (current.rows.length !== 1) return "conflict"; + const row = current.rows[0]; + if (!row) return "conflict"; + if (row.tenant_id !== input.tenantId + || positiveInteger(row.github_user_id, "Stored GitHub user id") !== input.githubUserId + || positiveInteger(row.account_id, "Stored GitHub installation account id") !== input.accountId + || accountType(row.account_type) !== input.accountType) return "conflict"; + const epoch = nonnegativeInteger(row.verification_epoch, "Hosted installation verification epoch"); + return epoch !== input.epoch ? "stale" : "conflict"; + } + + async release(tenantIdValue: string, installationIdValue: number): Promise { + const tenant = tenantId(tenantIdValue); + const installationId = positiveInteger(installationIdValue, "GitHub installation id"); + const result = await this.pool.query( + `DELETE FROM synsec_github_hosted_installation_ownership + WHERE installation_id = $1 AND tenant_id = $2 + RETURNING installation_id`, + [installationId, tenant], + ); + return result.rows.length === 1; + } +} diff --git a/packages/github/src/postgres-installation-store.ts b/packages/github/src/postgres-installation-store.ts new file mode 100644 index 00000000..8cf2d1e4 --- /dev/null +++ b/packages/github/src/postgres-installation-store.ts @@ -0,0 +1,239 @@ +import type { + GitHubInstallationRecord, + GitHubInstallationRecordInput, +} from "./installation-store.js"; +import type { + GitHubInstallationStateStore, + GitHubTransactionalInstallationStateStore, +} from "./installation-sync.js"; +import type { + PostgresPoolLike, + PostgresQueryResult, + PostgresQueryable, + PostgresTransactionClient, +} from "./postgres-shared-state.js"; +import { validateGitHubRepositoryIdentity } from "./repository-acquisition.js"; + +const MAX_REPOSITORY_COUNT = 10_000; +const MAX_LOGIN_LENGTH = 255; + +export const SYNSEC_GITHUB_POSTGRES_INSTALLATION_MIGRATIONS = [ + `CREATE TABLE IF NOT EXISTS synsec_github_installations ( + installation_id bigint PRIMARY KEY CHECK (installation_id > 0), + account_login varchar(255) NOT NULL, + account_type varchar(16) NOT NULL CHECK (account_type IN ('User', 'Organization')), + repository_selection varchar(16) NOT NULL CHECK (repository_selection IN ('all', 'selected')), + repositories text[] NOT NULL DEFAULT ARRAY[]::text[], + suspended_at timestamptz(3), + updated_at timestamptz(3) NOT NULL, + CHECK (repository_selection = 'selected' OR cardinality(repositories) = 0), + CHECK (cardinality(repositories) <= 10000) + )`, +] as const; + +function positiveInteger(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive integer.`); + } + return value; +} + +function boundedString(value: unknown, label: string, maximum: number): string { + if (typeof value !== "string") throw new Error(`${label} must be a string.`); + const normalized = value.trim(); + if (!normalized) throw new Error(`${label} is required.`); + if (normalized.length > maximum) throw new Error(`${label} exceeds ${maximum} characters.`); + if (/[\u0000-\u001f\u007f]/.test(normalized)) throw new Error(`${label} contains unsupported control characters.`); + return normalized; +} + +function timestamp(value: unknown, label: string): string { + if (value instanceof Date) return value.toISOString(); + const normalized = boundedString(value, label, 64); + if (!Number.isFinite(Date.parse(normalized))) throw new Error(`${label} must be an ISO timestamp.`); + return new Date(normalized).toISOString(); +} + +function normalize(input: GitHubInstallationRecordInput | GitHubInstallationRecord): GitHubInstallationRecord { + const installationId = positiveInteger(input.installationId, "GitHub installation id"); + const accountLogin = boundedString(input.accountLogin, "GitHub account login", MAX_LOGIN_LENGTH); + if (input.accountType !== "User" && input.accountType !== "Organization") { + throw new Error("GitHub installation account type must be User or Organization."); + } + if (input.repositorySelection !== "all" && input.repositorySelection !== "selected") { + throw new Error("GitHub repository selection must be all or selected."); + } + const sourceRepositories = input.repositories ?? []; + if (!Array.isArray(sourceRepositories) || sourceRepositories.length > MAX_REPOSITORY_COUNT) { + throw new Error(`GitHub installation repositories exceed the ${MAX_REPOSITORY_COUNT}-entry limit.`); + } + const repositories = [...new Set(sourceRepositories.map((value) => validateGitHubRepositoryIdentity( + boundedString(value, "GitHub repository", 255), + )))].sort(); + if (input.repositorySelection === "all" && repositories.length > 0) { + throw new Error("GitHub installations with repositorySelection=all must not persist an enumerated repository list."); + } + const updatedAt = timestamp(input.updatedAt ?? new Date().toISOString(), "GitHub installation updatedAt"); + const suspendedAt = input.suspendedAt === undefined ? undefined : timestamp(input.suspendedAt, "GitHub installation suspendedAt"); + return { + version: 1, + installationId, + accountLogin, + accountType: input.accountType, + repositorySelection: input.repositorySelection, + repositories, + ...(suspendedAt ? { suspendedAt } : {}), + updatedAt, + }; +} + +function recordFromRow(row: Record): GitHubInstallationRecord { + const repositories = row.repositories; + if (!Array.isArray(repositories) || repositories.some((value) => typeof value !== "string")) { + throw new Error("PostgreSQL installation state contains an invalid repository list."); + } + return normalize({ + version: 1, + installationId: positiveInteger( + typeof row.installation_id === "number" ? row.installation_id : Number(row.installation_id), + "GitHub installation id", + ), + accountLogin: boundedString(row.account_login, "GitHub account login", MAX_LOGIN_LENGTH), + accountType: row.account_type as GitHubInstallationRecord["accountType"], + repositorySelection: row.repository_selection as GitHubInstallationRecord["repositorySelection"], + repositories, + ...(row.suspended_at === null || row.suspended_at === undefined + ? {} + : { suspendedAt: timestamp(row.suspended_at, "GitHub installation suspendedAt") }), + updatedAt: timestamp(row.updated_at, "GitHub installation updatedAt"), + }); +} + +async function transaction( + pool: PostgresPoolLike, + operation: (client: PostgresTransactionClient) => Promise, +): Promise { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const result = await operation(client); + await client.query("COMMIT"); + return result; + } catch (error) { + try { + await client.query("ROLLBACK"); + } catch { + // Preserve the original backend failure; rollback diagnostics may contain connection details. + } + throw error; + } finally { + client.release(); + } +} + +export async function migrateSynSecGitHubPostgresInstallationState(pool: PostgresPoolLike): Promise { + await transaction(pool, async (client) => { + for (const statement of SYNSEC_GITHUB_POSTGRES_INSTALLATION_MIGRATIONS) await client.query(statement); + }); +} + +/** + * Shared PostgreSQL installation authorization state. + * + * The root store owns no credentials; its caller owns the database pool. Installation webhook + * read-modify-write operations use withInstallationTransaction(), which acquires one transaction- + * scoped advisory lock derived from the installation id and executes all reads/writes on the same + * database connection. Authorization checks always query shared durable state afresh. + */ +export class PostgresGitHubInstallationStore implements GitHubTransactionalInstallationStateStore { + private readonly queryable: PostgresQueryable; + + constructor( + private readonly pool: PostgresPoolLike, + queryable?: PostgresQueryable, + ) { + this.queryable = queryable ?? pool; + } + + async withInstallationTransaction( + installationIdValue: number, + operation: (store: GitHubInstallationStateStore) => Promise, + ): Promise { + const installationId = positiveInteger(installationIdValue, "GitHub installation id"); + return transaction(this.pool, async (client) => { + await client.query( + "SELECT pg_advisory_xact_lock(hashtextextended('synsec-installation:' || $1::text, 0))", + [installationId], + ); + return operation(new PostgresGitHubInstallationStore(this.pool, client)); + }); + } + + async put(input: GitHubInstallationRecordInput): Promise { + const record = normalize(input); + const result = await this.queryable.query( + `INSERT INTO synsec_github_installations( + installation_id, account_login, account_type, repository_selection, + repositories, suspended_at, updated_at + ) VALUES ($1,$2,$3,$4,$5::text[],$6::timestamptz,$7::timestamptz) + ON CONFLICT (installation_id) DO UPDATE SET + account_login = EXCLUDED.account_login, + account_type = EXCLUDED.account_type, + repository_selection = EXCLUDED.repository_selection, + repositories = EXCLUDED.repositories, + suspended_at = EXCLUDED.suspended_at, + updated_at = EXCLUDED.updated_at + RETURNING *`, + [ + record.installationId, + record.accountLogin, + record.accountType, + record.repositorySelection, + record.repositories, + record.suspendedAt ?? null, + record.updatedAt, + ], + ); + const row = result.rows[0]; + if (!row) throw new Error("PostgreSQL installation update did not return durable state."); + return recordFromRow(row); + } + + async get(installationIdValue: number): Promise { + const installationId = positiveInteger(installationIdValue, "GitHub installation id"); + const result = await this.queryable.query( + "SELECT * FROM synsec_github_installations WHERE installation_id = $1", + [installationId], + ); + if (result.rows.length > 1) throw new Error("PostgreSQL installation state contains duplicate installation ids."); + const row = result.rows[0]; + return row ? recordFromRow(row) : undefined; + } + + async remove(installationIdValue: number): Promise { + const installationId = positiveInteger(installationIdValue, "GitHub installation id"); + const result = await this.queryable.query( + "DELETE FROM synsec_github_installations WHERE installation_id = $1 RETURNING installation_id", + [installationId], + ); + return result.rows.length === 1; + } + + async isRepositoryAllowed(installationIdValue: number, repositoryValue: string): Promise { + const installationId = positiveInteger(installationIdValue, "GitHub installation id"); + const repository = validateGitHubRepositoryIdentity(boundedString(repositoryValue, "GitHub repository", 255)); + const result: PostgresQueryResult = await this.queryable.query( + `SELECT repository_selection, repositories, suspended_at + FROM synsec_github_installations + WHERE installation_id = $1`, + [installationId], + ); + const row = result.rows[0]; + if (!row || row.suspended_at !== null && row.suspended_at !== undefined) return false; + if (row.repository_selection === "all") return true; + if (row.repository_selection !== "selected" || !Array.isArray(row.repositories)) { + throw new Error("PostgreSQL installation authorization state has an invalid shape."); + } + return row.repositories.some((value) => value === repository); + } +} diff --git a/packages/github/src/postgres-lease-observer.ts b/packages/github/src/postgres-lease-observer.ts new file mode 100644 index 00000000..7faa2191 --- /dev/null +++ b/packages/github/src/postgres-lease-observer.ts @@ -0,0 +1,30 @@ +import type { PostgresPoolLike } from "./postgres-shared-state.js"; + +const MAX_ACTIVE_LEASES = 1_000_000; + +/** + * Count currently valid fenced scan-job leases from the transactional PostgreSQL backend. + * + * This is intended for trusted service-manager and rolling-upgrade orchestration. It queries durable + * shared state directly and does not derive fleet drainage from in-process worker counters. Expired + * leases are excluded because they are reclaimable and no longer establish current ownership. + */ +export async function countSynSecGitHubPostgresActiveLeases(pool: PostgresPoolLike): Promise { + if (!pool || typeof pool.query !== "function") { + throw new Error("PostgreSQL shared-state pool is required for active-lease observation."); + } + const result = await pool.query( + `SELECT count(*)::integer AS count + FROM synsec_github_scan_jobs + WHERE status = 'leased' AND lease_until > clock_timestamp()`, + ); + if (result.rows.length !== 1) { + throw new Error("PostgreSQL active-lease observation returned an invalid result shape."); + } + const raw = result.rows[0]?.count; + const count = typeof raw === "number" ? raw : Number(raw); + if (!Number.isSafeInteger(count) || count < 0 || count > MAX_ACTIVE_LEASES) { + throw new Error("PostgreSQL active-lease observation returned an invalid count."); + } + return count; +} diff --git a/packages/github/src/postgres-shared-backend.ts b/packages/github/src/postgres-shared-backend.ts new file mode 100644 index 00000000..96dee9d1 --- /dev/null +++ b/packages/github/src/postgres-shared-backend.ts @@ -0,0 +1,179 @@ +import type { GitHubAppSharedStateBackendContract } from "./shared-state-contract.js"; +import { + createGitHubAppSharedRuntime, + type GitHubAppSharedRuntime, + type GitHubAppSharedRuntimeOptions, +} from "./shared-runtime.js"; +import { + PostgresGitHubScanQueue, + PostgresGitHubWebhookReplayStore, + SYNSEC_GITHUB_POSTGRES_MIGRATIONS, + SYNSEC_GITHUB_POSTGRES_SCHEMA_VERSION, + type PostgresGitHubSharedStateOptions, + type PostgresPoolLike, +} from "./postgres-shared-state.js"; +import { + PostgresGitHubInstallationStore, + SYNSEC_GITHUB_POSTGRES_INSTALLATION_MIGRATIONS, +} from "./postgres-installation-store.js"; + +export const SYNSEC_GITHUB_POSTGRES_BACKEND_ID = "postgres-v1" as const; +export const SYNSEC_GITHUB_POSTGRES_IMPLEMENTATION_VERSION = "0.2.0-postgres-v1" as const; +const POSTGRES_MIGRATION_LOCK = "synsec-github-postgres-shared-state-v1"; + +/** + * Secret-free declaration for the concrete built-in PostgreSQL adapter implementation. + * + * This declaration is not sufficient for production activation by itself. The shared runtime still + * requires a complete canonical conformance report bound to this exact backend id/version. + */ +export function buildSynSecGitHubPostgresBackendContract(): GitHubAppSharedStateBackendContract { + return { + contractVersion: 1, + backendId: SYNSEC_GITHUB_POSTGRES_BACKEND_ID, + implementationVersion: SYNSEC_GITHUB_POSTGRES_IMPLEMENTATION_VERSION, + capabilities: { + atomicReplayClaim: true, + atomicQueueInsertion: true, + atomicQueueClaimWithFence: true, + compareAndSetLeaseRenewal: true, + fencedQueueTransitions: true, + transactionalInstallationState: true, + sharedAuthorizationState: true, + }, + evidence: [ + { + capability: "atomicReplayClaim", + mechanism: "database-constraint", + reference: "postgres-shared-state.replay-concurrent-claim", + }, + { + capability: "atomicQueueInsertion", + mechanism: "database-constraint", + reference: "postgres-shared-state.queue-unique-delivery", + }, + { + capability: "atomicQueueClaimWithFence", + mechanism: "fencing-token", + reference: "postgres-shared-state.queue-skip-locked-fence", + }, + { + capability: "compareAndSetLeaseRenewal", + mechanism: "compare-and-set", + reference: "postgres-shared-state.queue-lease-renewal-cas", + }, + { + capability: "fencedQueueTransitions", + mechanism: "fencing-token", + reference: "postgres-shared-state.queue-terminal-fences", + }, + { + capability: "transactionalInstallationState", + mechanism: "serializable-transaction", + reference: "postgres-installation-state.transaction-lock", + }, + { + capability: "sharedAuthorizationState", + mechanism: "shared-durable-store", + reference: "postgres-installation-state.shared-authorization", + }, + ], + }; +} + +/** + * Apply the complete PostgreSQL shared-state schema under one transaction-scoped advisory lock. + * Concurrent replicas invoking this helper therefore cannot race PostgreSQL DDL creation. + * + * The timestamp ALTER is intentionally idempotent and repairs databases created by an earlier + * pre-release adapter build whose replay claim column retained sub-millisecond precision. Keeping + * this repair explicit preserves exact claim-token compare-and-set semantics across upgrades. + */ +export async function migrateSynSecGitHubPostgresBackend(pool: PostgresPoolLike): Promise { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [POSTGRES_MIGRATION_LOCK]); + for (const statement of SYNSEC_GITHUB_POSTGRES_MIGRATIONS) await client.query(statement); + await client.query( + `ALTER TABLE synsec_github_replay + ALTER COLUMN received_at TYPE timestamptz(3) + USING date_trunc('milliseconds', received_at)`, + ); + for (const statement of SYNSEC_GITHUB_POSTGRES_INSTALLATION_MIGRATIONS) await client.query(statement); + + const current = await client.query( + "SELECT version FROM synsec_github_schema WHERE component = $1 FOR UPDATE", + ["shared-state"], + ); + if (current.rows.length > 1) throw new Error("SynSec PostgreSQL schema metadata is inconsistent."); + if (current.rows.length === 1) { + const version = Number(current.rows[0]?.version); + if (version !== SYNSEC_GITHUB_POSTGRES_SCHEMA_VERSION) { + throw new Error("SynSec PostgreSQL shared-state schema version is unsupported."); + } + } else { + await client.query( + "INSERT INTO synsec_github_schema(component, version) VALUES ($1, $2)", + ["shared-state", SYNSEC_GITHUB_POSTGRES_SCHEMA_VERSION], + ); + } + await client.query("COMMIT"); + } catch (error) { + try { + await client.query("ROLLBACK"); + } catch { + // Preserve the migration failure; rollback diagnostics may contain backend connection details. + } + throw error; + } finally { + client.release(); + } +} + +export interface SynSecGitHubPostgresSharedStores { + replayStore: PostgresGitHubWebhookReplayStore; + installationStore: PostgresGitHubInstallationStore; + queue: PostgresGitHubScanQueue; +} + +/** + * Construct the three shared stores from a caller-owned pool after migrations have been applied. + * Database credentials remain entirely in hosting code; this factory accepts only an established + * query/connect capability and never serializes backend connection details. + */ +export function createSynSecGitHubPostgresSharedStores( + pool: PostgresPoolLike, + options: PostgresGitHubSharedStateOptions = {}, +): SynSecGitHubPostgresSharedStores { + return { + replayStore: new PostgresGitHubWebhookReplayStore(pool, options), + installationStore: new PostgresGitHubInstallationStore(pool), + queue: new PostgresGitHubScanQueue(pool, options), + }; +} + +export type GitHubAppPostgresSharedRuntimeOptions = Omit< + GitHubAppSharedRuntimeOptions, + "backendContract" | "replayStore" | "installationStore" | "queue" +> & { + pool: PostgresPoolLike; + sharedStateOptions?: PostgresGitHubSharedStateOptions; +}; + +/** + * Compose the built-in PostgreSQL stores into the hosted runtime only through SynSec's existing + * conformance-evidence gate. The caller must migrate first and provide the portable report produced + * for this exact backend id/version; capability declarations alone cannot activate multi-host use. + */ +export function createGitHubAppPostgresSharedRuntime( + options: GitHubAppPostgresSharedRuntimeOptions, +): GitHubAppSharedRuntime { + const { pool, sharedStateOptions, ...runtime } = options; + const stores = createSynSecGitHubPostgresSharedStores(pool, sharedStateOptions); + return createGitHubAppSharedRuntime({ + ...runtime, + backendContract: buildSynSecGitHubPostgresBackendContract(), + ...stores, + }); +} diff --git a/packages/github/src/postgres-shared-state.ts b/packages/github/src/postgres-shared-state.ts new file mode 100644 index 00000000..47f9c268 --- /dev/null +++ b/packages/github/src/postgres-shared-state.ts @@ -0,0 +1,507 @@ +import { randomBytes } from "node:crypto"; +import type { GitHubWebhookDeliveryClaim } from "./replay-store.js"; +import type { GitHubScanJob, GitHubScanJobInput, GitHubScanJobStatus } from "./scan-queue.js"; + +const MAX_QUEUE_ENTRIES = 10_000; +const MAX_ATTEMPTS = 5; +const DEFAULT_LEASE_MS = 5 * 60 * 1000; +const MIN_LEASE_MS = 10_000; +const MAX_LEASE_MS = 60 * 60 * 1000; +const DEFAULT_REPLAY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; +const MIN_REPLAY_RETENTION_MS = 60 * 60 * 1000; +const MAX_REPLAY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; +const MAX_PRUNE_BATCH = 10_000; +const ENQUEUE_ADVISORY_LOCK = 1_938_211_067; + +export interface PostgresQueryResult { + rows: Array>; + rowCount?: number | null; +} + +export interface PostgresQueryable { + query(text: string, values?: unknown[]): Promise; +} + +export interface PostgresTransactionClient extends PostgresQueryable { + release(): void; +} + +export interface PostgresPoolLike extends PostgresQueryable { + connect(): Promise; +} + +export interface PostgresGitHubSharedStateOptions { + replayRetentionMs?: number; + leaseMs?: number; +} + +export const SYNSEC_GITHUB_POSTGRES_SCHEMA_VERSION = 1 as const; + +export const SYNSEC_GITHUB_POSTGRES_MIGRATIONS = [ + `CREATE TABLE IF NOT EXISTS synsec_github_schema ( + component text PRIMARY KEY, + version integer NOT NULL CHECK (version > 0), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp() + )`, + `CREATE TABLE IF NOT EXISTS synsec_github_replay ( + delivery_id varchar(128) PRIMARY KEY, + received_at timestamptz(3) NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS synsec_github_scan_jobs ( + job_id char(32) PRIMARY KEY, + delivery_id varchar(128) NOT NULL UNIQUE, + installation_id bigint NOT NULL CHECK (installation_id > 0), + repository varchar(255) NOT NULL, + head_sha varchar(64) NOT NULL, + event varchar(32) NOT NULL CHECK (event IN ('push', 'pull_request')), + base_sha varchar(64), + pull_request_number integer CHECK (pull_request_number > 0), + created_at timestamptz NOT NULL, + attempts smallint NOT NULL DEFAULT 0 CHECK (attempts >= 0 AND attempts <= 5), + status varchar(16) NOT NULL CHECK (status IN ('pending', 'leased', 'failed')), + lease_until timestamptz, + lease_id char(32), + CHECK ( + (event = 'pull_request' AND base_sha IS NOT NULL AND pull_request_number IS NOT NULL) + OR (event = 'push' AND base_sha IS NULL AND pull_request_number IS NULL) + ), + CHECK ( + (status = 'leased' AND lease_until IS NOT NULL AND lease_id IS NOT NULL) + OR (status <> 'leased' AND lease_until IS NULL AND lease_id IS NULL) + ) + )`, + `CREATE INDEX IF NOT EXISTS synsec_github_scan_jobs_claim_idx + ON synsec_github_scan_jobs (status, lease_until, created_at, job_id)`, +] as const; + +function integerInRange(value: number | undefined, fallback: number, minimum: number, maximum: number, label: string): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved < minimum || resolved > maximum) { + throw new Error(`${label} must be an integer between ${minimum} and ${maximum} milliseconds.`); + } + return resolved; +} + +function positiveInteger(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive integer.`); + } + return value; +} + +function boundedString(value: unknown, label: string, maximum: number): string { + if (typeof value !== "string") throw new Error(`${label} must be a string.`); + const normalized = value.trim(); + if (!normalized) throw new Error(`${label} is required.`); + if (normalized.length > maximum) throw new Error(`${label} exceeds ${maximum} characters.`); + if (/[\u0000-\u001f\u007f]/.test(normalized)) throw new Error(`${label} contains unsupported control characters.`); + return normalized; +} + +function deliveryId(value: unknown): string { + const normalized = boundedString(value, "GitHub delivery id", 128); + if (!/^[A-Za-z0-9._:-]+$/.test(normalized)) throw new Error("GitHub delivery id contains unsupported characters."); + return normalized; +} + +function repository(value: unknown): string { + const normalized = boundedString(value, "GitHub repository", 255); + if (!/^[^/\s]+\/[^/\s]+$/.test(normalized)) throw new Error("GitHub repository must be in owner/name form."); + return normalized; +} + +function commitSha(value: unknown, label: string): string { + const normalized = boundedString(value, label, 64).toLowerCase(); + if (!/^[a-f0-9]{40,64}$/.test(normalized)) throw new Error(`${label} must be a hexadecimal commit SHA.`); + return normalized; +} + +function jobId(value: unknown): string { + const normalized = boundedString(value, "GitHub scan job id", 32).toLowerCase(); + if (!/^[a-f0-9]{32}$/.test(normalized)) throw new Error("GitHub scan job id is invalid."); + return normalized; +} + +function leaseId(value: unknown): string { + const normalized = boundedString(value, "GitHub scan job lease id", 32).toLowerCase(); + if (!/^[a-f0-9]{32}$/.test(normalized)) throw new Error("GitHub scan job lease id is invalid."); + return normalized; +} + +function timestamp(value: unknown, label: string): string { + if (value instanceof Date) return value.toISOString(); + const normalized = boundedString(value, label, 64); + if (!Number.isFinite(Date.parse(normalized))) throw new Error(`${label} must be an ISO timestamp.`); + return new Date(normalized).toISOString(); +} + +function rowString(row: Record, key: string): unknown { + return row[key]; +} + +function jobFromRow(row: Record): GitHubScanJob { + const event = rowString(row, "event"); + if (event !== "push" && event !== "pull_request") throw new Error("PostgreSQL scan job has an invalid event type."); + const status = rowString(row, "status"); + if (status !== "pending" && status !== "leased" && status !== "failed") throw new Error("PostgreSQL scan job has an invalid status."); + const attemptsValue = rowString(row, "attempts"); + const attempts = typeof attemptsValue === "number" ? attemptsValue : Number(attemptsValue); + if (!Number.isSafeInteger(attempts) || attempts < 0 || attempts > MAX_ATTEMPTS) throw new Error("PostgreSQL scan job has an invalid attempt count."); + const installationValue = rowString(row, "installation_id"); + const installationId = typeof installationValue === "number" ? installationValue : Number(installationValue); + const pullRequestValue = rowString(row, "pull_request_number"); + const pullRequestNumber = pullRequestValue === null || pullRequestValue === undefined + ? undefined + : positiveInteger(typeof pullRequestValue === "number" ? pullRequestValue : Number(pullRequestValue), "GitHub pull request number"); + const baseValue = rowString(row, "base_sha"); + const baseSha = baseValue === null || baseValue === undefined ? undefined : commitSha(baseValue, "GitHub base SHA"); + const leaseUntilValue = rowString(row, "lease_until"); + const leaseIdentityValue = rowString(row, "lease_id"); + const leaseUntil = leaseUntilValue === null || leaseUntilValue === undefined ? undefined : timestamp(leaseUntilValue, "GitHub scan job leaseUntil"); + const currentLeaseId = leaseIdentityValue === null || leaseIdentityValue === undefined ? undefined : leaseId(leaseIdentityValue); + if (status === "leased" && (!leaseUntil || !currentLeaseId)) throw new Error("PostgreSQL leased scan job is missing lease metadata."); + if (status !== "leased" && (leaseUntil || currentLeaseId)) throw new Error("PostgreSQL non-leased scan job contains lease metadata."); + return { + version: 1, + jobId: jobId(rowString(row, "job_id")), + deliveryId: deliveryId(rowString(row, "delivery_id")), + installationId: positiveInteger(installationId, "GitHub installation id"), + repository: repository(rowString(row, "repository")), + headSha: commitSha(rowString(row, "head_sha"), "GitHub head SHA"), + event, + ...(baseSha ? { baseSha } : {}), + ...(pullRequestNumber ? { pullRequestNumber } : {}), + createdAt: timestamp(rowString(row, "created_at"), "GitHub scan job createdAt"), + attempts, + status, + ...(leaseUntil ? { leaseUntil } : {}), + ...(currentLeaseId ? { leaseId: currentLeaseId } : {}), + }; +} + +function matchesLogicalJob( + existing: GitHubScanJob, + input: { + installationId: number; + repository: string; + headSha: string; + event: GitHubScanJob["event"]; + baseSha?: string; + pullRequestNumber?: number; + }, +): boolean { + return existing.installationId === input.installationId + && existing.repository === input.repository + && existing.headSha === input.headSha + && existing.event === input.event + && existing.baseSha === input.baseSha + && existing.pullRequestNumber === input.pullRequestNumber; +} + +async function transaction(pool: PostgresPoolLike, operation: (client: PostgresTransactionClient) => Promise): Promise { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const result = await operation(client); + await client.query("COMMIT"); + return result; + } catch (error) { + try { + await client.query("ROLLBACK"); + } catch { + // Preserve the original database failure; rollback diagnostics may contain connection details. + } + throw error; + } finally { + client.release(); + } +} + +export async function migrateSynSecGitHubPostgresState(pool: PostgresPoolLike): Promise { + await transaction(pool, async (client) => { + for (const statement of SYNSEC_GITHUB_POSTGRES_MIGRATIONS) await client.query(statement); + const current = await client.query("SELECT version FROM synsec_github_schema WHERE component = $1 FOR UPDATE", ["shared-state"]); + if (current.rows.length > 1) throw new Error("SynSec PostgreSQL schema metadata is inconsistent."); + if (current.rows.length === 1) { + const version = Number(current.rows[0]?.version); + if (version !== SYNSEC_GITHUB_POSTGRES_SCHEMA_VERSION) { + throw new Error("SynSec PostgreSQL shared-state schema version is unsupported."); + } + } else { + await client.query( + "INSERT INTO synsec_github_schema(component, version) VALUES ($1, $2)", + ["shared-state", SYNSEC_GITHUB_POSTGRES_SCHEMA_VERSION], + ); + } + }); +} + +export class PostgresGitHubWebhookReplayStore { + readonly retentionMs: number; + constructor(private readonly pool: PostgresPoolLike, options: PostgresGitHubSharedStateOptions = {}) { + this.retentionMs = integerInRange( + options.replayRetentionMs, + DEFAULT_REPLAY_RETENTION_MS, + MIN_REPLAY_RETENTION_MS, + MAX_REPLAY_RETENTION_MS, + "Webhook replay retention", + ); + } + + async claim(deliveryIdValue: string): Promise { + const id = deliveryId(deliveryIdValue); + return transaction(this.pool, async (client) => { + await client.query( + "SELECT pg_advisory_xact_lock(hashtextextended('synsec-replay:' || $1::text, 0))", + [id], + ); + const existing = await client.query( + "SELECT received_at FROM synsec_github_replay WHERE delivery_id = $1 FOR UPDATE", + [id], + ); + const row = existing.rows[0]; + if (row) { + const receivedAt = timestamp(row.received_at, "GitHub webhook replay receivedAt"); + const fresh = await client.query( + `SELECT $1::timestamptz > clock_timestamp() - ($2::bigint * interval '1 millisecond') AS fresh`, + [receivedAt, this.retentionMs], + ); + if (fresh.rows[0]?.fresh === true) { + return { accepted: false, deliveryId: id, receivedAt }; + } + const reclaimed = await client.query( + `UPDATE synsec_github_replay + SET received_at = date_trunc('milliseconds', clock_timestamp()) + WHERE delivery_id = $1 + RETURNING received_at`, + [id], + ); + const reclaimedRow = reclaimed.rows[0]; + if (!reclaimedRow) throw new Error("PostgreSQL replay reclaim did not return durable state."); + return { + accepted: true, + deliveryId: id, + receivedAt: timestamp(reclaimedRow.received_at, "GitHub webhook replay receivedAt"), + }; + } + + const inserted = await client.query( + `INSERT INTO synsec_github_replay(delivery_id, received_at) + VALUES ($1, date_trunc('milliseconds', clock_timestamp())) + RETURNING received_at`, + [id], + ); + const insertedRow = inserted.rows[0]; + if (!insertedRow) throw new Error("PostgreSQL replay claim did not return durable state."); + return { + accepted: true, + deliveryId: id, + receivedAt: timestamp(insertedRow.received_at, "GitHub webhook replay receivedAt"), + }; + }); + } + + async release(deliveryIdValue: string, receivedAtValue: string): Promise { + const id = deliveryId(deliveryIdValue); + const receivedAt = timestamp(receivedAtValue, "GitHub webhook replay receivedAt"); + const result = await this.pool.query( + `DELETE FROM synsec_github_replay + WHERE delivery_id = $1 + AND received_at = $2::timestamptz + AND received_at > clock_timestamp() - ($3::bigint * interval '1 millisecond') + RETURNING delivery_id`, + [id, receivedAt, this.retentionMs], + ); + return result.rows.length === 1; + } + + async pruneExpired(limit = MAX_PRUNE_BATCH): Promise { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_PRUNE_BATCH) { + throw new Error(`PostgreSQL replay prune limit must be between 1 and ${MAX_PRUNE_BATCH}.`); + } + const result = await this.pool.query( + `WITH expired AS ( + SELECT delivery_id FROM synsec_github_replay + WHERE received_at <= clock_timestamp() - ($1::bigint * interval '1 millisecond') + ORDER BY received_at, delivery_id + FOR UPDATE SKIP LOCKED + LIMIT $2 + ) + DELETE FROM synsec_github_replay replay + USING expired + WHERE replay.delivery_id = expired.delivery_id + RETURNING replay.delivery_id`, + [this.retentionMs, limit], + ); + return result.rows.length; + } +} + +export class PostgresGitHubScanQueue { + readonly leaseMs: number; + constructor(private readonly pool: PostgresPoolLike, options: PostgresGitHubSharedStateOptions = {}) { + this.leaseMs = integerInRange(options.leaseMs, DEFAULT_LEASE_MS, MIN_LEASE_MS, MAX_LEASE_MS, "GitHub scan job lease"); + } + + async enqueue(input: GitHubScanJobInput): Promise { + const event = input.event; + if (event !== "push" && event !== "pull_request") throw new Error("GitHub scan job event must be push or pull_request."); + const id = randomBytes(16).toString("hex"); + const delivery = deliveryId(input.deliveryId); + const installationId = positiveInteger(input.installationId, "GitHub installation id"); + const repo = repository(input.repository); + const headSha = commitSha(input.headSha, "GitHub head SHA"); + const baseSha = input.baseSha === undefined ? undefined : commitSha(input.baseSha, "GitHub base SHA"); + const pullRequestNumber = input.pullRequestNumber === undefined ? undefined : positiveInteger(input.pullRequestNumber, "GitHub pull request number"); + if (event === "pull_request" && (!baseSha || !pullRequestNumber)) throw new Error("Pull request scan jobs require base SHA and pull request number."); + if (event === "push" && (baseSha || pullRequestNumber)) throw new Error("Push scan jobs must not contain pull request metadata."); + const createdAt = input.createdAt === undefined ? undefined : timestamp(input.createdAt, "GitHub scan job createdAt"); + + return transaction(this.pool, async (client) => { + await client.query("SELECT pg_advisory_xact_lock($1)", [ENQUEUE_ADVISORY_LOCK]); + const existingResult = await client.query( + "SELECT * FROM synsec_github_scan_jobs WHERE delivery_id = $1", + [delivery], + ); + if (existingResult.rows.length > 1) throw new Error("PostgreSQL scan queue contains duplicate delivery ids."); + const existingRow = existingResult.rows[0]; + if (existingRow) { + const existing = jobFromRow(existingRow); + if (!matchesLogicalJob(existing, { + installationId, + repository: repo, + headSha, + event, + ...(baseSha ? { baseSha } : {}), + ...(pullRequestNumber ? { pullRequestNumber } : {}), + })) { + throw new Error("GitHub delivery id is already queued with different scan provenance."); + } + return existing; + } + + const countResult = await client.query("SELECT count(*)::integer AS count FROM synsec_github_scan_jobs"); + const count = Number(countResult.rows[0]?.count); + if (!Number.isSafeInteger(count) || count < 0) throw new Error("PostgreSQL scan queue returned an invalid job count."); + if (count >= MAX_QUEUE_ENTRIES) throw new Error(`GitHub scan queue reached the ${MAX_QUEUE_ENTRIES}-job limit.`); + const result = await client.query( + `INSERT INTO synsec_github_scan_jobs( + job_id, delivery_id, installation_id, repository, head_sha, event, base_sha, + pull_request_number, created_at, attempts, status + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,COALESCE($9::timestamptz, clock_timestamp()),0,'pending') + RETURNING *`, + [id, delivery, installationId, repo, headSha, event, baseSha ?? null, pullRequestNumber ?? null, createdAt ?? null], + ); + const row = result.rows[0]; + if (!row) throw new Error("PostgreSQL scan queue insertion did not return durable state."); + return jobFromRow(row); + }); + } + + async claimNext(): Promise { + for (let examined = 0; examined <= MAX_QUEUE_ENTRIES; examined += 1) { + const nextLeaseId = randomBytes(16).toString("hex"); + const result = await this.pool.query( + `WITH candidate AS ( + SELECT job_id FROM synsec_github_scan_jobs + WHERE status = 'pending' OR (status = 'leased' AND lease_until <= clock_timestamp()) + ORDER BY created_at, job_id + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + UPDATE synsec_github_scan_jobs jobs + SET attempts = CASE WHEN jobs.attempts >= $2 THEN jobs.attempts ELSE jobs.attempts + 1 END, + status = CASE WHEN jobs.attempts >= $2 THEN 'failed' ELSE 'leased' END, + lease_until = CASE WHEN jobs.attempts >= $2 THEN NULL ELSE clock_timestamp() + ($3::bigint * interval '1 millisecond') END, + lease_id = CASE WHEN jobs.attempts >= $2 THEN NULL ELSE $1 END + FROM candidate + WHERE jobs.job_id = candidate.job_id + RETURNING jobs.*`, + [nextLeaseId, MAX_ATTEMPTS, this.leaseMs], + ); + const row = result.rows[0]; + if (!row) return undefined; + const job = jobFromRow(row); + if (job.status === "failed") continue; + return job; + } + throw new Error("PostgreSQL scan queue exceeded its bounded claim search."); + } + + async assertLease(jobIdValue: string, expectedLeaseId: string): Promise { + const result = await this.pool.query( + `SELECT * FROM synsec_github_scan_jobs + WHERE job_id = $1 AND status = 'leased' AND lease_id = $2 AND lease_until > clock_timestamp()`, + [jobId(jobIdValue), leaseId(expectedLeaseId)], + ); + const row = result.rows[0]; + if (!row) throw new Error("GitHub scan job lease is stale, expired, or no longer owned by this worker."); + return jobFromRow(row); + } + + async renew(jobIdValue: string, expectedLeaseId: string): Promise { + const result = await this.pool.query( + `UPDATE synsec_github_scan_jobs + SET lease_until = clock_timestamp() + ($3::bigint * interval '1 millisecond') + WHERE job_id = $1 AND status = 'leased' AND lease_id = $2 AND lease_until > clock_timestamp() + RETURNING *`, + [jobId(jobIdValue), leaseId(expectedLeaseId), this.leaseMs], + ); + const row = result.rows[0]; + if (!row) throw new Error("GitHub scan job lease is stale, expired, or no longer owned by this worker."); + return jobFromRow(row); + } + + async release(jobIdValue: string, expectedLeaseId: string): Promise { + return this.transition(jobIdValue, expectedLeaseId, "pending"); + } + + async fail(jobIdValue: string, expectedLeaseId: string): Promise { + return this.transition(jobIdValue, expectedLeaseId, "failed"); + } + + private async transition(jobIdValue: string, expectedLeaseId: string, status: Exclude): Promise { + const result = await this.pool.query( + `UPDATE synsec_github_scan_jobs + SET status = $3, lease_until = NULL, lease_id = NULL + WHERE job_id = $1 AND status = 'leased' AND lease_id = $2 AND lease_until > clock_timestamp() + RETURNING *`, + [jobId(jobIdValue), leaseId(expectedLeaseId), status], + ); + const row = result.rows[0]; + if (!row) throw new Error("GitHub scan job lease is stale, expired, or no longer owned by this worker."); + return jobFromRow(row); + } + + async complete(jobIdValue: string, expectedLeaseId: string): Promise { + const id = jobId(jobIdValue); + const expected = leaseId(expectedLeaseId); + const result = await this.pool.query( + `DELETE FROM synsec_github_scan_jobs + WHERE job_id = $1 AND status = 'leased' AND lease_id = $2 AND lease_until > clock_timestamp() + RETURNING job_id`, + [id, expected], + ); + if (result.rows.length === 1) return true; + const exists = await this.pool.query("SELECT job_id FROM synsec_github_scan_jobs WHERE job_id = $1", [id]); + if (exists.rows.length > 0) throw new Error("GitHub scan job lease is stale, expired, or no longer owned by this worker."); + return false; + } + + async list(): Promise { + const result = await this.pool.query( + `SELECT * FROM synsec_github_scan_jobs ORDER BY created_at, job_id LIMIT $1`, + [MAX_QUEUE_ENTRIES + 1], + ); + if (result.rows.length > MAX_QUEUE_ENTRIES) throw new Error(`GitHub scan queue exceeds the ${MAX_QUEUE_ENTRIES}-job limit.`); + return result.rows.map(jobFromRow); + } + + async deleteFailed(jobIdValue: string): Promise { + const result = await this.pool.query( + `DELETE FROM synsec_github_scan_jobs WHERE job_id = $1 AND status = 'failed' RETURNING job_id`, + [jobId(jobIdValue)], + ); + return result.rows.length === 1; + } +} diff --git a/packages/github/src/private-directory.ts b/packages/github/src/private-directory.ts new file mode 100644 index 00000000..e02eb1f1 --- /dev/null +++ b/packages/github/src/private-directory.ts @@ -0,0 +1,19 @@ +import { chmod, lstat, mkdir } from "node:fs/promises"; + +/** + * Ensure one durable local state directory exists with restrictive permissions where supported. + * + * `mkdir({ mode })` only controls newly-created directories; an operator-created or restored + * directory may already be more permissive. Repair that mode after creation so GitHub App durable + * metadata does not remain directory-listable merely because the path pre-existed SynSec. The + * final path itself must be a real directory rather than a symlink so permission repair does not + * intentionally follow an alternate filesystem object. + */ +export async function ensurePrivateDirectory(path: string): Promise { + await mkdir(path, { recursive: true, mode: 0o700 }); + const metadata = await lstat(path); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error("GitHub App durable state path must be a real directory, not a symlink or other filesystem object."); + } + if (process.platform !== "win32") await chmod(path, 0o700); +} diff --git a/packages/github/src/production-readiness.ts b/packages/github/src/production-readiness.ts new file mode 100644 index 00000000..f936205c --- /dev/null +++ b/packages/github/src/production-readiness.ts @@ -0,0 +1,69 @@ +import { + validateGitHubAppDeployment, + type GitHubAppDeploymentConfig, + type GitHubAppDeploymentReadiness, +} from "./app-deployment.js"; +import { + assessGitHubAppSharedStateConformanceEvidence, + type GitHubAppSharedStateEvidenceAssessment, +} from "./shared-state-evidence.js"; + +export interface GitHubAppProductionReadiness { + ready: boolean; + deployment: GitHubAppDeploymentReadiness; + requiresSharedStateEvidence: boolean; + sharedStateEvidence?: GitHubAppSharedStateEvidenceAssessment; +} + +/** + * Compose deployment configuration checks with portable shared-state conformance evidence. + * + * A single-replica deployment retains the existing deployment preflight behavior. A valid + * multi-replica declaration is not sufficient on its own: the exact shared backend adapter build + * must also have complete, identity-bound conformance evidence. This function does not connect to + * GitHub or a database and never includes credential values in its result. + */ +export function assessGitHubAppProductionReadiness( + config: GitHubAppDeploymentConfig, + backendContract?: unknown, + conformanceReport?: unknown, +): GitHubAppProductionReadiness { + const deployment = validateGitHubAppDeployment(config); + const replicaCount = config.replicaCount ?? 1; + const requiresSharedStateEvidence = Number.isSafeInteger(replicaCount) && replicaCount > 1; + + if (!requiresSharedStateEvidence) { + return { + ready: deployment.ready, + deployment, + requiresSharedStateEvidence: false, + }; + } + + const sharedStateEvidence = assessGitHubAppSharedStateConformanceEvidence( + backendContract, + conformanceReport, + ); + return { + ready: deployment.ready && sharedStateEvidence.ready, + deployment, + requiresSharedStateEvidence: true, + sharedStateEvidence, + }; +} + +export function assertGitHubAppProductionReady( + config: GitHubAppDeploymentConfig, + backendContract?: unknown, + conformanceReport?: unknown, +): void { + const readiness = assessGitHubAppProductionReadiness(config, backendContract, conformanceReport); + if (readiness.ready) return; + + const deploymentCodes = readiness.deployment.issues + .filter((issue) => issue.level === "error") + .map((issue) => issue.code); + const evidenceCodes = readiness.sharedStateEvidence?.issues.map((issue) => issue.code) ?? []; + const codes = [...deploymentCodes, ...evidenceCodes]; + throw new Error(`GitHub App production readiness failed: ${codes.join(", ") || "shared-state-evidence-required"}`); +} diff --git a/packages/github/src/publisher.ts b/packages/github/src/publisher.ts new file mode 100644 index 00000000..f99e91ea --- /dev/null +++ b/packages/github/src/publisher.ts @@ -0,0 +1,109 @@ +import type { GitHubCheckResult, GitHubPullRequestContext } from "./index.js"; + +export interface GitHubCheckPublication { + id: number; + htmlUrl?: string; + status?: string; + conclusion?: string; +} + +export interface GitHubPublisherOptions { + apiVersion?: string; + userAgent?: string; + fetch?: typeof globalThis.fetch; +} + +interface GitHubCheckRunResponse { + id?: unknown; + html_url?: unknown; + status?: unknown; + conclusion?: unknown; +} + +function repositoryParts(repository: string): { owner: string; name: string } { + const match = /^([^/\s]+)\/([^/\s]+)$/.exec(repository.trim()); + if (!match?.[1] || !match[2]) throw new Error(`Invalid GitHub repository: ${repository}`); + return { owner: match[1], name: match[2] }; +} + +function nonEmptyToken(token: string): string { + const normalized = token.trim(); + if (!normalized) throw new Error("A GitHub token is required to publish a check run."); + return normalized; +} + +function responseString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} + +function responseNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; +} + +export function toGitHubCheckRunRequest(check: GitHubCheckResult): Record { + return { + name: check.name, + head_sha: check.headSha, + status: "completed", + conclusion: check.conclusion, + output: check.output, + }; +} + +/** + * Publish a completed SynSec check to GitHub's Checks API. + * + * The destination host is fixed to api.github.com and the repository comes from validated + * GitHub context. Scanner output never controls a request URL. The bearer token is used only + * in the Authorization header and is never included in returned errors. + */ +export async function publishGitHubCheck( + check: GitHubCheckResult, + context: GitHubPullRequestContext, + token: string, + options: GitHubPublisherOptions = {}, +): Promise { + const authToken = nonEmptyToken(token); + const { owner, name } = repositoryParts(context.repository); + const fetchImpl = options.fetch ?? globalThis.fetch; + if (!fetchImpl) throw new Error("No fetch implementation is available for GitHub check publication."); + + const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/check-runs`; + const response = await fetchImpl(url, { + method: "POST", + redirect: "error", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + "User-Agent": options.userAgent?.trim() || "synsec/0.2", + "X-GitHub-Api-Version": options.apiVersion?.trim() || "2022-11-28", + }, + body: JSON.stringify(toGitHubCheckRunRequest(check)), + }); + + const text = await response.text(); + if (!response.ok) { + const detail = text.replace(/[\r\n]+/g, " ").slice(0, 500).trim(); + throw new Error(`GitHub Checks API returned HTTP ${response.status}${detail ? `: ${detail}` : "."}`); + } + + let payload: GitHubCheckRunResponse; + try { + payload = text ? (JSON.parse(text) as GitHubCheckRunResponse) : {}; + } catch { + throw new Error("GitHub Checks API returned invalid JSON."); + } + + const id = responseNumber(payload.id); + if (!id) throw new Error("GitHub Checks API response did not include a valid check-run id."); + const htmlUrl = responseString(payload.html_url); + const status = responseString(payload.status); + const conclusion = responseString(payload.conclusion); + return { + id, + ...(htmlUrl ? { htmlUrl } : {}), + ...(status ? { status } : {}), + ...(conclusion ? { conclusion } : {}), + }; +} diff --git a/packages/github/src/remediation-writer.ts b/packages/github/src/remediation-writer.ts new file mode 100644 index 00000000..1972fbfe --- /dev/null +++ b/packages/github/src/remediation-writer.ts @@ -0,0 +1,386 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { + authorizeRemediationExecution, + type ApprovedRemediationExecution, + type RemediationChange, +} from "@synsec/workflows/remediation"; +import { validateGitHubCommitSha, validateGitHubRepositoryIdentity } from "./repository-acquisition.js"; + +const MAX_OUTPUT_BYTES = 1024 * 1024; +const MAX_TOKEN_LENGTH = 4096; +const MAX_BRANCH_LENGTH = 200; +const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; +const MIN_TIMEOUT_MS = 10_000; +const MAX_TIMEOUT_MS = 30 * 60 * 1000; + +export interface RemediationGitResult { + exitCode: number; + stdout: string; + stderr: string; +} + +export interface RemediationGitOptions { + cwd: string; + env: NodeJS.ProcessEnv; + timeoutMs: number; +} + +export type RemediationGitRunner = ( + args: readonly string[], + options: RemediationGitOptions, +) => Promise; + +export interface GitHubRemediationWriterOptions { + fetch?: typeof fetch; + gitRunner?: RemediationGitRunner; + timeoutMs?: number; + apiVersion?: string; + userAgent?: string; + tempRoot?: string; +} + +export interface GitHubRemediationWriteInput { + repository: string; + baseBranch: string; + workspace: string; + installationToken: string; + execution: ApprovedRemediationExecution; +} + +export interface GitHubRemediationPullRequestResult { + repository: string; + proposalId: string; + branch: string; + commitSha: string; + pullRequestNumber: number; + pullRequestUrl: string; +} + +function boundedTimeout(value: number | undefined): number { + const timeoutMs = value ?? DEFAULT_TIMEOUT_MS; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < MIN_TIMEOUT_MS || timeoutMs > MAX_TIMEOUT_MS) { + throw new Error(`GitHub remediation timeout must be between ${MIN_TIMEOUT_MS} and ${MAX_TIMEOUT_MS} milliseconds.`); + } + return timeoutMs; +} + +function installationToken(value: string): string { + const token = value.trim(); + if (!token) throw new Error("GitHub installation token is required for remediation publication."); + if (token.length > MAX_TOKEN_LENGTH) throw new Error(`GitHub installation token exceeds ${MAX_TOKEN_LENGTH} characters.`); + if (/[\r\n\0]/.test(token)) throw new Error("GitHub installation token contains unsupported characters."); + return token; +} + +function branchName(value: string): string { + const branch = value.trim(); + if ( + !branch || + branch.length > MAX_BRANCH_LENGTH || + !/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(branch) || + branch.includes("..") || + branch.includes("//") || + branch.includes("@{") || + branch.endsWith("/") || + branch.endsWith(".") || + branch.endsWith(".lock") + ) { + throw new Error("GitHub remediation base branch is invalid."); + } + return branch; +} + +function remediationBranch(proposalId: string): string { + if (!/^[a-f0-9]{64}$/.test(proposalId)) throw new Error("Remediation proposal id is invalid."); + return `synsec/remediation/${proposalId.slice(0, 20)}`; +} + +function gitEnvironment(token: string): NodeJS.ProcessEnv { + const auth = Buffer.from(`x-access-token:${token}`, "utf8").toString("base64"); + const env: NodeJS.ProcessEnv = { + GIT_TERMINAL_PROMPT: "0", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null", + GIT_CONFIG_COUNT: "2", + GIT_CONFIG_KEY_0: "http.https://github.com/.extraheader", + GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${auth}`, + GIT_CONFIG_KEY_1: "protocol.file.allow", + GIT_CONFIG_VALUE_1: "never", + GIT_LFS_SKIP_SMUDGE: "1", + }; + for (const key of ["PATH", "PATHEXT", "SYSTEMROOT", "COMSPEC", "WINDIR", "TEMP", "TMP", "TMPDIR", "HOME", "USERPROFILE", "LANG", "LC_ALL", "SSL_CERT_FILE", "SSL_CERT_DIR"]) { + const value = process.env[key]; + if (value !== undefined) env[key] = value; + } + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined && key.startsWith("LC_")) env[key] = value; + } + return env; +} + +async function defaultGitRunner(args: readonly string[], options: RemediationGitOptions): Promise { + return await new Promise((resolvePromise, reject) => { + const child = spawn("git", [...args], { + cwd: options.cwd, + env: options.env, + shell: false, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let outputBytes = 0; + let overflow = false; + let timedOut = false; + let settled = false; + const terminate = (): void => { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + }; + const timeout = setTimeout(() => { + timedOut = true; + terminate(); + }, options.timeoutMs); + const finish = (callback: () => void): void => { + if (settled) return; + settled = true; + clearTimeout(timeout); + callback(); + }; + const collect = (chunk: string, target: "stdout" | "stderr"): void => { + outputBytes += Buffer.byteLength(chunk); + if (outputBytes > MAX_OUTPUT_BYTES) { + overflow = true; + terminate(); + return; + } + if (target === "stdout") stdout += chunk; + else stderr += chunk; + }; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => collect(chunk, "stdout")); + child.stderr.on("data", (chunk: string) => collect(chunk, "stderr")); + child.once("error", (error) => finish(() => reject(error))); + child.once("close", (code) => finish(() => { + if (overflow) return reject(new Error(`git output exceeded the ${MAX_OUTPUT_BYTES}-byte remediation limit.`)); + if (timedOut) return reject(new Error(`git timed out after ${options.timeoutMs} milliseconds during remediation.`)); + resolvePromise({ exitCode: code ?? -1, stdout, stderr }); + })); + }); +} + +function failure(stage: string, result: RemediationGitResult): Error { + const detail = result.stderr.replace(/[\r\n]+/g, " ").trim().slice(0, 500); + return new Error(`GitHub remediation failed during ${stage} (git exit ${result.exitCode})${detail ? `: ${detail}` : "."}`); +} + +async function requireGit( + runner: RemediationGitRunner, + args: readonly string[], + options: RemediationGitOptions, + stage: string, +): Promise { + const result = await runner(args, options); + if (result.exitCode !== 0) throw failure(stage, result); + return result; +} + +function parseRemoteRef(result: RemediationGitResult, expectedRef: string): string | undefined { + if (result.exitCode === 2 && !result.stdout.trim()) return undefined; + if (result.exitCode !== 0) throw failure("remote ref lookup", result); + const lines = result.stdout.trim().split(/\r?\n/).filter(Boolean); + if (lines.length !== 1) throw new Error("GitHub remediation remote ref lookup returned ambiguous output."); + const line = lines[0]; + if (line === undefined) throw new Error("GitHub remediation remote ref lookup returned malformed output."); + const [sha, ref, ...extra] = line.split(/\s+/); + if (!sha || ref !== expectedRef || extra.length > 0) throw new Error("GitHub remediation remote ref lookup returned malformed output."); + return validateGitHubCommitSha(sha); +} + +function expectedStatus(change: RemediationChange): "A" | "M" { + return change.operation === "create" ? "A" : "M"; +} + +function assertStagedChanges(output: string, changes: readonly RemediationChange[]): void { + const actual = new Map(); + for (const line of output.split(/\r?\n/).filter(Boolean)) { + const match = /^([AM])\t(.+)$/.exec(line); + if (!match) throw new Error("GitHub remediation staged an unsupported change type."); + const status = match[1]; + const path = match[2]; + if (!status || !path) throw new Error("GitHub remediation staged malformed path metadata."); + if (actual.has(path)) throw new Error("GitHub remediation staged a duplicate path."); + actual.set(path, status); + } + if (actual.size !== changes.length) throw new Error("GitHub remediation staged paths differ from the approved proposal."); + for (const change of changes) { + if (actual.get(change.path) !== expectedStatus(change)) { + throw new Error("GitHub remediation staged paths or operations differ from the approved proposal."); + } + } +} + +function safeApiVersion(value: string | undefined): string { + const version = value?.trim() || "2022-11-28"; + if (!/^\d{4}-\d{2}-\d{2}$/.test(version)) throw new Error("GitHub API version is invalid."); + return version; +} + +function safeUserAgent(value: string | undefined): string { + const userAgent = value?.trim() || "synsec-remediation/0.2"; + if (!userAgent || userAgent.length > 200 || /[\r\n\0]/.test(userAgent)) throw new Error("GitHub user agent is invalid."); + return userAgent; +} + +async function createPullRequest(input: { + fetchImpl: typeof fetch; + repository: string; + token: string; + branch: string; + baseBranch: string; + execution: ApprovedRemediationExecution; + apiVersion: string; + userAgent: string; +}): Promise<{ number: number; htmlUrl: string }> { + const response = await input.fetchImpl(`https://api.github.com/repos/${input.repository}/pulls`, { + method: "POST", + redirect: "error", + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${input.token}`, + "x-github-api-version": input.apiVersion, + "user-agent": input.userAgent, + "content-type": "application/json", + }, + body: JSON.stringify({ + title: `SynSec remediation ${input.execution.proposal.proposalId.slice(0, 12)}`, + head: input.branch, + base: input.baseBranch, + body: [ + input.execution.proposal.summary, + "", + `Approved SynSec proposal: ${input.execution.proposal.proposalId}`, + `Findings addressed: ${input.execution.proposal.findingIds.length}`, + "", + "This pull request was created only after explicit approval of the exact patch set and commit provenance.", + ].join("\n"), + }), + }); + if (!response.ok) throw new Error(`GitHub remediation pull-request creation failed with HTTP ${response.status}.`); + const payload = await response.json() as { number?: unknown; html_url?: unknown }; + if (typeof payload.number !== "number" || !Number.isSafeInteger(payload.number) || payload.number <= 0) { + throw new Error("GitHub remediation pull-request response did not contain a valid number."); + } + if (typeof payload.html_url !== "string" || !/^https:\/\/github\.com\//.test(payload.html_url)) { + throw new Error("GitHub remediation pull-request response did not contain a fixed-host GitHub URL."); + } + return { number: payload.number, htmlUrl: payload.html_url }; +} + +/** + * Apply one explicitly approved remediation proposal to an already acquired exact worktree, push a + * deterministic non-force branch to github.com, and open a pull request through api.github.com. + * + * The writer revalidates proposal/approval integrity immediately before acting, rechecks the remote + * base ref before any mutation, verifies the local worktree commit, checks the patch before applying + * it, and requires Git's staged path/status set to exactly equal the approved proposal. Installation + * credentials are used only for GitHub transport and never written into the repository or passed to + * scanners. + */ +export async function createApprovedGitHubRemediationPullRequest( + input: GitHubRemediationWriteInput, + options: GitHubRemediationWriterOptions = {}, +): Promise { + const repository = validateGitHubRepositoryIdentity(input.repository); + const baseBranch = branchName(input.baseBranch); + authorizeRemediationExecution({ + proposal: input.execution.proposal, + approval: input.execution.approval, + currentHeadSha: input.execution.targetCommitSha, + }); + const targetCommitSha = validateGitHubCommitSha(input.execution.targetCommitSha); + if (input.execution.proposal.targetCommitSha !== targetCommitSha) { + throw new Error("Approved remediation execution target does not match its proposal."); + } + const token = installationToken(input.installationToken); + const timeoutMs = boundedTimeout(options.timeoutMs); + const runner = options.gitRunner ?? defaultGitRunner; + const workspace = resolve(input.workspace); + const remote = `https://github.com/${repository}.git`; + const branch = remediationBranch(input.execution.proposal.proposalId); + const env = gitEnvironment(token); + const gitOptions: RemediationGitOptions = { cwd: workspace, env, timeoutMs }; + + const localHead = (await requireGit(runner, ["rev-parse", "--verify", "HEAD"], gitOptions, "local commit verification")).stdout.trim().toLowerCase(); + if (validateGitHubCommitSha(localHead) !== targetCommitSha) { + throw new Error("GitHub remediation workspace is not at the approved target commit."); + } + + const baseRef = `refs/heads/${baseBranch}`; + const baseLookup = await runner(["ls-remote", "--refs", remote, baseRef], gitOptions); + const remoteBaseSha = parseRemoteRef(baseLookup, baseRef); + if (!remoteBaseSha || remoteBaseSha !== targetCommitSha) { + throw new Error("GitHub remediation base branch moved after approval; regenerate and reapprove the patch set."); + } + + const patchDirectory = await mkdtemp(join(resolve(options.tempRoot?.trim() || tmpdir()), "synsec-remediation-")); + const patchPath = join(patchDirectory, "approved.patch"); + try { + await writeFile(patchPath, input.execution.proposal.changes.map((change) => change.patch).join("\n"), { encoding: "utf8", mode: 0o600 }); + await requireGit(runner, ["apply", "--cached", "--check", "--whitespace=nowarn", patchPath], gitOptions, "approved patch check"); + await requireGit(runner, ["apply", "--cached", "--whitespace=nowarn", patchPath], gitOptions, "approved patch application"); + const staged = await requireGit(runner, ["diff", "--cached", "--name-status", "--no-renames"], gitOptions, "staged change verification"); + assertStagedChanges(staged.stdout, input.execution.proposal.changes); + + const commitEnv: NodeJS.ProcessEnv = { + ...env, + GIT_AUTHOR_NAME: "SynSec", + GIT_AUTHOR_EMAIL: "synsec@users.noreply.github.com", + GIT_COMMITTER_NAME: "SynSec", + GIT_COMMITTER_EMAIL: "synsec@users.noreply.github.com", + GIT_AUTHOR_DATE: input.execution.approval.approvedAt, + GIT_COMMITTER_DATE: input.execution.approval.approvedAt, + }; + const commitOptions: RemediationGitOptions = { ...gitOptions, env: commitEnv }; + await requireGit( + runner, + ["commit", "--no-gpg-sign", "--no-verify", "-m", `SynSec remediation ${input.execution.proposal.proposalId.slice(0, 12)}`], + commitOptions, + "remediation commit", + ); + const commitSha = validateGitHubCommitSha((await requireGit(runner, ["rev-parse", "--verify", "HEAD"], gitOptions, "remediation commit verification")).stdout.trim()); + + const remediationRef = `refs/heads/${branch}`; + const existing = parseRemoteRef(await runner(["ls-remote", "--refs", remote, remediationRef], gitOptions), remediationRef); + if (existing && existing !== commitSha) { + throw new Error("GitHub remediation branch already exists with different contents."); + } + if (!existing) { + await requireGit(runner, ["push", remote, `HEAD:${remediationRef}`], gitOptions, "non-force remediation branch push"); + } + + const pullRequest = await createPullRequest({ + fetchImpl: options.fetch ?? fetch, + repository, + token, + branch, + baseBranch, + execution: input.execution, + apiVersion: safeApiVersion(options.apiVersion), + userAgent: safeUserAgent(options.userAgent), + }); + return { + repository, + proposalId: input.execution.proposal.proposalId, + branch, + commitSha, + pullRequestNumber: pullRequest.number, + pullRequestUrl: pullRequest.htmlUrl, + }; + } finally { + await rm(patchDirectory, { recursive: true, force: true }); + } +} diff --git a/packages/github/src/replay-store.ts b/packages/github/src/replay-store.ts new file mode 100644 index 00000000..0a0ee96e --- /dev/null +++ b/packages/github/src/replay-store.ts @@ -0,0 +1,212 @@ +import { createHash, randomBytes } from "node:crypto"; +import { link, lstat, open, readFile, readdir, rm } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { ensurePrivateDirectory } from "./private-directory.js"; + +const DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; +const MIN_RETENTION_MS = 60 * 60 * 1000; +const MAX_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; +const MAX_DELIVERY_ID_LENGTH = 128; +const MAX_RECORD_BYTES = 1024; +const MAX_PRUNE_ENTRIES = 10_000; + +interface ReplayRecord { + version: 1; + deliveryId: string; + receivedAt: string; +} + +export interface GitHubWebhookReplayStoreOptions { + retentionMs?: number; + now?: () => number; +} + +export interface GitHubWebhookDeliveryClaim { + accepted: boolean; + deliveryId: string; + receivedAt: string; +} + +function validatedDeliveryId(value: string): string { + const deliveryId = value.trim(); + if (!deliveryId) throw new Error("GitHub webhook delivery id is required."); + if (deliveryId.length > MAX_DELIVERY_ID_LENGTH) { + throw new Error(`GitHub webhook delivery id exceeds ${MAX_DELIVERY_ID_LENGTH} characters.`); + } + if (!/^[A-Za-z0-9._:-]+$/.test(deliveryId)) { + throw new Error("GitHub webhook delivery id contains unsupported characters."); + } + return deliveryId; +} + +function validatedRetention(value: number | undefined): number { + const retention = value ?? DEFAULT_RETENTION_MS; + if (!Number.isSafeInteger(retention) || retention < MIN_RETENTION_MS || retention > MAX_RETENTION_MS) { + throw new Error(`Webhook replay retention must be an integer between ${MIN_RETENTION_MS} and ${MAX_RETENTION_MS} milliseconds.`); + } + return retention; +} + +function validatedReceivedAt(value: string): string { + const normalized = value.trim(); + if (!normalized || !Number.isFinite(Date.parse(normalized))) { + throw new Error("GitHub webhook replay receivedAt must be an ISO timestamp."); + } + return normalized; +} + +function recordPath(directory: string, deliveryId: string): string { + const digest = createHash("sha256").update(deliveryId, "utf8").digest("hex"); + return join(directory, `${digest}.json`); +} + +function parseRecord(text: string, expectedDeliveryId?: string): ReplayRecord { + let value: unknown; + try { + value = JSON.parse(text); + } catch { + throw new Error("Stored GitHub webhook replay record is invalid JSON."); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Stored GitHub webhook replay record is invalid."); + } + const record = value as Partial; + if (record.version !== 1 || typeof record.deliveryId !== "string" || typeof record.receivedAt !== "string") { + throw new Error("Stored GitHub webhook replay record has an invalid shape."); + } + const deliveryId = validatedDeliveryId(record.deliveryId); + if (expectedDeliveryId !== undefined && deliveryId !== expectedDeliveryId) { + throw new Error("Stored GitHub webhook replay record has an invalid shape."); + } + const receivedAt = validatedReceivedAt(record.receivedAt); + return { version: 1, deliveryId, receivedAt }; +} + +async function readRecord(path: string, expectedDeliveryId?: string): Promise { + const metadata = await lstat(path); + if (metadata.isSymbolicLink() || !metadata.isFile() || metadata.size > MAX_RECORD_BYTES) { + throw new Error("Stored GitHub webhook replay record is invalid, symlinked, or oversized."); + } + return parseRecord(await readFile(path, "utf8"), expectedDeliveryId); +} + +function isAlreadyExists(error: unknown): boolean { + return error instanceof Error + && Object.prototype.hasOwnProperty.call(error, "code") + && (error as NodeJS.ErrnoException).code === "EEXIST"; +} + +function isNotFound(error: unknown): boolean { + return error instanceof Error + && Object.prototype.hasOwnProperty.call(error, "code") + && (error as NodeJS.ErrnoException).code === "ENOENT"; +} + +/** + * Durable replay protection for GitHub webhook delivery ids. + * + * Each claim is fully written and fsynced to a private temporary file before an + * atomic hard-link creates the canonical marker. Concurrent processes sharing the + * same store therefore cannot both accept a delivery or observe a partial record. + * Delivery ids are hashed for filenames and never interpreted as paths. + */ +export class FileGitHubWebhookReplayStore { + readonly directory: string; + readonly retentionMs: number; + private readonly now: () => number; + + constructor(directory: string, options: GitHubWebhookReplayStoreOptions = {}) { + const normalized = directory.trim(); + if (!normalized) throw new Error("Webhook replay-store directory is required."); + this.directory = resolve(normalized); + this.retentionMs = validatedRetention(options.retentionMs); + this.now = options.now ?? Date.now; + } + + async claim(deliveryIdValue: string): Promise { + const deliveryId = validatedDeliveryId(deliveryIdValue); + const now = this.now(); + if (!Number.isFinite(now) || now <= 0) throw new Error("Webhook replay-store clock must be a positive timestamp."); + const receivedAt = new Date(now).toISOString(); + const path = recordPath(this.directory, deliveryId); + await ensurePrivateDirectory(this.directory); + + for (let attempt = 0; attempt < 2; attempt += 1) { + const tempPath = join(this.directory, `.claim-${process.pid}-${randomBytes(12).toString("hex")}.tmp`); + const handle = await open(tempPath, "wx", 0o600); + try { + const record: ReplayRecord = { version: 1, deliveryId, receivedAt }; + await handle.writeFile(`${JSON.stringify(record)}\n`, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + + try { + await link(tempPath, path); + return { accepted: true, deliveryId, receivedAt }; + } catch (error) { + if (!isAlreadyExists(error)) throw error; + + const existing = await readRecord(path, deliveryId); + const existingAt = Date.parse(existing.receivedAt); + if (now - existingAt < this.retentionMs) { + return { accepted: false, deliveryId, receivedAt: existing.receivedAt }; + } + + await rm(path, { force: true }); + } finally { + await rm(tempPath, { force: true }); + } + } + + throw new Error("Unable to claim expired GitHub webhook delivery id safely."); + } + + /** + * Release only the still-current accepted claim after downstream processing fails. + * The original receivedAt value binds the release to this claim and an expired claim + * is never removed, preventing a late worker from deleting a newer reclaimed marker. + */ + async release(deliveryIdValue: string, receivedAtValue: string): Promise { + const deliveryId = validatedDeliveryId(deliveryIdValue); + const receivedAt = validatedReceivedAt(receivedAtValue); + const now = this.now(); + if (!Number.isFinite(now) || now <= 0) throw new Error("Webhook replay-store clock must be a positive timestamp."); + await ensurePrivateDirectory(this.directory); + const path = recordPath(this.directory, deliveryId); + let existing: ReplayRecord; + try { + existing = await readRecord(path, deliveryId); + } catch (error) { + if (isNotFound(error)) return false; + throw error; + } + if (existing.receivedAt !== receivedAt) return false; + if (now - Date.parse(existing.receivedAt) >= this.retentionMs) return false; + await rm(path); + return true; + } + + async pruneExpired(): Promise { + const now = this.now(); + if (!Number.isFinite(now) || now <= 0) throw new Error("Webhook replay-store clock must be a positive timestamp."); + await ensurePrivateDirectory(this.directory); + const entries = (await readdir(this.directory, { withFileTypes: true })) + .filter((entry) => entry.isFile() && /^[a-f0-9]{64}\.json$/.test(entry.name)) + .slice(0, MAX_PRUNE_ENTRIES); + + let removed = 0; + for (const entry of entries) { + const path = join(this.directory, entry.name); + const record = await readRecord(path); + if (recordPath(this.directory, record.deliveryId) !== path) { + throw new Error("Stored GitHub webhook replay record does not match its delivery-id filename."); + } + if (now - Date.parse(record.receivedAt) < this.retentionMs) continue; + await rm(path, { force: true }); + removed += 1; + } + return removed; + } +} diff --git a/packages/github/src/repository-acquisition.ts b/packages/github/src/repository-acquisition.ts new file mode 100644 index 00000000..76698c17 --- /dev/null +++ b/packages/github/src/repository-acquisition.ts @@ -0,0 +1,329 @@ +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { markGitHubWorkspaceOwned } from "./workspace-ownership.js"; + +const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; +const MIN_TIMEOUT_MS = 10_000; +const MAX_TIMEOUT_MS = 30 * 60 * 1000; +const MAX_OUTPUT_BYTES = 1024 * 1024; +const MAX_TOKEN_LENGTH = 4096; +const OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/; +const REPOSITORY_PATTERN = /^[A-Za-z0-9._-]{1,100}$/; + +export interface GitCommandResult { + exitCode: number; + stdout: string; + stderr: string; +} + +export interface GitCommandOptions { + cwd: string; + env: NodeJS.ProcessEnv; + timeoutMs: number; + signal?: AbortSignal; +} + +export type GitCommandRunner = ( + args: readonly string[], + options: GitCommandOptions, +) => Promise; + +export interface GitHubRepositoryAcquisitionOptions { + workspaceRoot?: string; + timeoutMs?: number; + signal?: AbortSignal; + gitRunner?: GitCommandRunner; + now?: () => number; +} + +export interface AcquiredGitHubRepository { + repository: string; + commitSha: string; + workspace: string; + cleanup(): Promise; +} + +export interface AcquiredGitHubScanTarget extends AcquiredGitHubRepository { + base?: { + commitSha: string; + workspace: string; + }; +} + +function boundedTimeout(value: number | undefined): number { + const timeoutMs = value ?? DEFAULT_TIMEOUT_MS; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < MIN_TIMEOUT_MS || timeoutMs > MAX_TIMEOUT_MS) { + throw new Error(`GitHub repository acquisition timeout must be between ${MIN_TIMEOUT_MS} and ${MAX_TIMEOUT_MS} milliseconds.`); + } + return timeoutMs; +} + +function installationToken(value: string): string { + const token = value.trim(); + if (!token) throw new Error("GitHub installation token is required for repository acquisition."); + if (token.length > MAX_TOKEN_LENGTH) throw new Error(`GitHub installation token exceeds ${MAX_TOKEN_LENGTH} characters.`); + if(/[\r\n\0]/.test(token)) throw new Error("GitHub installation token contains unsupported characters."); + return token; +} + +/** Validate one github.com owner/name identity before it is allowed to become a transport URL. */ +export function validateGitHubRepositoryIdentity(value: string): string { + const normalized = value.trim(); + const pieces = normalized.split("/"); + if (pieces.length !== 2) throw new Error("GitHub repository must be in owner/name form."); + const [owner, repository] = pieces; + if (!owner || !repository || !OWNER_PATTERN.test(owner) || !REPOSITORY_PATTERN.test(repository)) { + throw new Error("GitHub repository contains characters that are unsafe for fixed-host acquisition."); + } + if (repository === "." || repository === "..") { + throw new Error("GitHub repository name is invalid."); + } + return `${owner}/${repository}`; +} + +export function validateGitHubCommitSha(value: string): string { + const normalized = value.trim().toLowerCase(); + if (!/^[a-f0-9]{40,64}$/.test(normalized)) { + throw new Error("GitHub commit SHA must be a 40-64 character hexadecimal object id."); + } + return normalized; +} + +function gitEnvironment(token: string, source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const auth = Buffer.from(`x-access-token:${token}`, "utf8").toString("base64"); + const env: NodeJS.ProcessEnv = { + GIT_TERMINAL_PROMPT: "0", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null", + GIT_CONFIG_COUNT: "2", + GIT_CONFIG_KEY_0: "http.https://github.com/.extraheader", + GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${auth}`, + GIT_CONFIG_KEY_1: "protocol.file.allow", + GIT_CONFIG_VALUE_1: "never", + GIT_LFS_SKIP_SMUDGE: "1", + }; + + for (const key of [ + "PATH", + "PATHEXT", + "SYSTEMROOT", + "COMSPEC", + "WINDIR", + "TEMP", + "TMP", + "TMPDIR", + "HOME", + "USERPROFILE", + "LANG", + "LC_ALL", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + ]) { + const value = source[key]; + if (value !== undefined) env[key] = value; + } + for (const [key, value] of Object.entries(source)) { + if (value !== undefined && key.startsWith("LC_")) env[key] = value; + } + return env; +} + +async function defaultGitRunner(args: readonly string[], options: GitCommandOptions): Promise { + if (options.signal?.aborted) throw new Error("GitHub repository acquisition was aborted before git started."); + return await new Promise((resolvePromise, reject) => { + const child = spawn("git", [...args], { + cwd: options.cwd, + env: options.env, + shell: false, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let stdoutBytes = 0; + let stderrBytes = 0; + let settled = false; + let timedOut = false; + let overflow = false; + + const terminate = (): void => { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + }; + const timeout = setTimeout(() => { + timedOut = true; + terminate(); + }, options.timeoutMs); + const onAbort = (): void => terminate(); + options.signal?.addEventListener("abort", onAbort, { once: true }); + + const finish = (callback: () => void): void => { + if (settled) return; + settled = true; + clearTimeout(timeout); + options.signal?.removeEventListener("abort", onAbort); + callback(); + }; + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdoutBytes += Buffer.byteLength(chunk); + if (stdoutBytes > MAX_OUTPUT_BYTES) { + overflow = true; + terminate(); + return; + } + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderrBytes += Buffer.byteLength(chunk); + if (stderrBytes > MAX_OUTPUT_BYTES) { + overflow = true; + terminate(); + return; + } + stderr += chunk; + }); + child.once("error", (error) => finish(() => reject(error))); + child.once("close", (code) => finish(() => { + if (overflow) return reject(new Error(`git output exceeded the ${MAX_OUTPUT_BYTES}-byte acquisition limit.`)); + if (timedOut) return reject(new Error(`git timed out after ${options.timeoutMs} milliseconds during repository acquisition.`)); + if (options.signal?.aborted) return reject(new Error("GitHub repository acquisition was aborted.")); + resolvePromise({ exitCode: code ?? -1, stdout, stderr }); + })); + }); +} + +function commandFailure(stage: string, result: GitCommandResult): Error { + const detail = result.stderr.replace(/[\r\n]+/g, " ").trim().slice(0, 500); + return new Error(`GitHub repository acquisition failed during ${stage} (git exit ${result.exitCode})${detail ? `: ${detail}` : "."}`); +} + +async function requireGitSuccess( + runner: GitCommandRunner, + args: readonly string[], + options: GitCommandOptions, + stage: string, +): Promise { + const result = await runner(args, options); + if (result.exitCode !== 0) throw commandFailure(stage, result); + return result; +} + +/** + * Materialize exactly one installation-authorized github.com commit into a fresh detached workspace. + * + * The repository identity and commit SHA are validated before URL construction. Git receives the + * short-lived installation credential only through its child environment, never argv or persisted + * repository configuration. System/global git configuration and file:// transport are disabled so + * local URL rewrite rules cannot silently redirect the fixed GitHub transport. Submodules and LFS + * objects are not initialized. A restrictive ownership marker is created before Git runs so a later + * maintenance pass can distinguish crashed SynSec workspaces from unrelated directories. The caller + * owns cleanup after a successful acquisition. + */ +export async function acquireGitHubRepositoryCommit(input: { + repository: string; + commitSha: string; + installationToken: string; +}, options: GitHubRepositoryAcquisitionOptions = {}): Promise { + const repository = validateGitHubRepositoryIdentity(input.repository); + const commitSha = validateGitHubCommitSha(input.commitSha); + const token = installationToken(input.installationToken); + const timeoutMs = boundedTimeout(options.timeoutMs); + const runner = options.gitRunner ?? defaultGitRunner; + const root = resolve(options.workspaceRoot?.trim() || tmpdir()); + await mkdir(root, { recursive: true, mode: 0o700 }); + const workspace = await mkdtemp(join(root, "synsec-github-")); + try { + await markGitHubWorkspaceOwned(workspace, options.now ?? Date.now); + } catch (error) { + await rm(workspace, { recursive: true, force: true }); + throw error; + } + const env = gitEnvironment(token); + const commandOptions: GitCommandOptions = { cwd: workspace, env, timeoutMs, ...(options.signal ? { signal: options.signal } : {}) }; + const remote = `https://github.com/${repository}.git`; + + try { + await requireGitSuccess(runner, ["init", "--quiet"], commandOptions, "workspace initialization"); + await requireGitSuccess( + runner, + ["fetch", "--quiet", "--no-tags", "--depth=1", remote, commitSha], + commandOptions, + "exact commit fetch", + ); + await requireGitSuccess(runner, ["checkout", "--quiet", "--detach", "FETCH_HEAD"], commandOptions, "detached checkout"); + const resolved = await requireGitSuccess(runner, ["rev-parse", "--verify", "HEAD"], commandOptions, "commit verification"); + if (resolved.stdout.trim().toLowerCase() !== commitSha) { + throw new Error("GitHub repository acquisition produced a commit different from the requested SHA."); + } + } catch (error) { + await rm(workspace, { recursive: true, force: true }); + throw error; + } + + return { + repository, + commitSha, + workspace, + cleanup: async () => { + await rm(workspace, { recursive: true, force: true }); + }, + }; +} + +/** + * Acquire the exact head commit and, when supplied, the exact PR base commit as a second isolated + * workspace. The same short-lived installation credential is used only during this acquisition + * phase; neither workspace contains persisted Git credentials. Cleanup is all-or-nothing. + */ +export async function acquireGitHubRepositoryScanTarget(input: { + repository: string; + commitSha: string; + baseCommitSha?: string; + installationToken: string; +}, options: GitHubRepositoryAcquisitionOptions = {}): Promise { + const repository = validateGitHubRepositoryIdentity(input.repository); + const commitSha = validateGitHubCommitSha(input.commitSha); + const baseCommitSha = input.baseCommitSha === undefined + ? undefined + : validateGitHubCommitSha(input.baseCommitSha); + + const head = await acquireGitHubRepositoryCommit({ + repository, + commitSha, + installationToken: input.installationToken, + }, options); + + if (!baseCommitSha) return head; + if (baseCommitSha === commitSha) { + return { + ...head, + base: { commitSha: baseCommitSha, workspace: head.workspace }, + }; + } + + let base: AcquiredGitHubRepository | undefined; + try { + base = await acquireGitHubRepositoryCommit({ + repository, + commitSha: baseCommitSha, + installationToken: input.installationToken, + }, options); + } catch (error) { + await head.cleanup(); + throw error; + } + + return { + repository, + commitSha, + workspace: head.workspace, + base: { commitSha: base.commitSha, workspace: base.workspace }, + cleanup: async () => { + await Promise.all([head.cleanup(), base?.cleanup()]); + }, + }; +} diff --git a/packages/github/src/retention.ts b/packages/github/src/retention.ts new file mode 100644 index 00000000..85ec7496 --- /dev/null +++ b/packages/github/src/retention.ts @@ -0,0 +1,85 @@ +import { stat } from "node:fs/promises"; +import { join } from "node:path"; +import { FileGitHubScanQueue } from "./scan-queue.js"; + +const DEFAULT_FAILED_JOB_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; +const MIN_FAILED_JOB_RETENTION_MS = 60 * 60 * 1000; +const MAX_FAILED_JOB_RETENTION_MS = 180 * 24 * 60 * 60 * 1000; + +export interface GitHubAppRetentionOptions { + failedJobRetentionMs?: number; + maxDeletes?: number; + now?: () => number; +} + +export interface GitHubAppRetentionResult { + inspected: number; + deleted: number; + retainedFailed: number; +} + +function boundedRetention(value: number | undefined): number { + const retention = value ?? DEFAULT_FAILED_JOB_RETENTION_MS; + if ( + !Number.isSafeInteger(retention) || + retention < MIN_FAILED_JOB_RETENTION_MS || + retention > MAX_FAILED_JOB_RETENTION_MS + ) { + throw new Error( + `Failed GitHub scan-job retention must be between ${MIN_FAILED_JOB_RETENTION_MS} and ${MAX_FAILED_JOB_RETENTION_MS} milliseconds.`, + ); + } + return retention; +} + +function boundedDeletes(value: number | undefined): number { + const maxDeletes = value ?? 100; + if (!Number.isSafeInteger(maxDeletes) || maxDeletes < 1 || maxDeletes > 1_000) { + throw new Error("GitHub App retention maxDeletes must be between 1 and 1000."); + } + return maxDeletes; +} + +async function recordModifiedAt(queue: FileGitHubScanQueue, jobId: string): Promise { + const metadata = await stat(join(queue.directory, `${jobId}.json`)); + if (!metadata.isFile() || !Number.isFinite(metadata.mtimeMs) || metadata.mtimeMs <= 0) { + throw new Error("Stored GitHub scan job has invalid retention metadata."); + } + return metadata.mtimeMs; +} + +/** + * Delete only terminal failed queue records older than the configured retention window. + * + * Age is measured from the durable record's last modification, which is refreshed when the queue + * marks a job failed. Pending and leased work is never deleted, even when old. Deletion is capped + * per invocation so operator maintenance cannot turn into an unbounded filesystem sweep. The queue + * validates every record before this function sees it, so malformed durable state continues to fail + * closed. Failed-record deletion is deliberately separate from leased-job completion so retention + * can never bypass lease fencing. + */ +export async function pruneGitHubAppFailedJobs( + queue: FileGitHubScanQueue, + options: GitHubAppRetentionOptions = {}, +): Promise { + const retentionMs = boundedRetention(options.failedJobRetentionMs); + const maxDeletes = boundedDeletes(options.maxDeletes); + const now = (options.now ?? Date.now)(); + if (!Number.isFinite(now) || now <= 0) throw new Error("GitHub App retention clock must be a positive timestamp."); + + const jobs = await queue.list(); + let deleted = 0; + let retainedFailed = 0; + + for (const job of jobs) { + if (job.status !== "failed") continue; + const expired = (await recordModifiedAt(queue, job.jobId)) <= now - retentionMs; + if (!expired || deleted >= maxDeletes) { + retainedFailed += 1; + continue; + } + if (await queue.deleteFailed(job.jobId)) deleted += 1; + } + + return { inspected: jobs.length, deleted, retainedFailed }; +} diff --git a/packages/github/src/runtime-credentials.ts b/packages/github/src/runtime-credentials.ts new file mode 100644 index 00000000..14ff1e86 --- /dev/null +++ b/packages/github/src/runtime-credentials.ts @@ -0,0 +1,131 @@ +import type { GitHubWebhookSecret } from "./app.js"; + +export interface GitHubAppRuntimeCredentialSnapshot { + generation: string; + privateKey: string; + webhookSecret: GitHubWebhookSecret; +} + +export interface GitHubAppRuntimeCredentialStatus { + version: 1; + generation: string; + webhookSecretCount: 1 | 2; + reloadCount: number; + interpretation: "memory-only-runtime-credential-generation"; +} + +export interface GitHubAppRuntimeCredentialSource { + getPrivateKey(): string; + getWebhookSecret(): GitHubWebhookSecret; + getStatus(): GitHubAppRuntimeCredentialStatus; + reload(load: () => Promise): Promise; +} + +const MAX_PRIVATE_KEY_BYTES = 64 * 1024; +const MAX_WEBHOOK_SECRET_BYTES = 4096; +const MAX_GENERATION_LENGTH = 128; +const SAFE_GENERATION = /^[A-Za-z0-9][A-Za-z0-9._:@/-]*$/; + +function generation(value: unknown): string { + if (typeof value !== "string") throw new Error("GitHub App credential generation must be a string."); + const normalized = value.trim(); + if (!normalized || normalized.length > MAX_GENERATION_LENGTH || !SAFE_GENERATION.test(normalized)) { + throw new Error("GitHub App credential generation must be a bounded non-secret identifier."); + } + return normalized; +} + +function privateKey(value: unknown): string { + if (typeof value !== "string" || !value.trim()) throw new Error("GitHub App private key is required."); + if (Buffer.byteLength(value, "utf8") > MAX_PRIVATE_KEY_BYTES) { + throw new Error(`GitHub App private key exceeds ${MAX_PRIVATE_KEY_BYTES} bytes.`); + } + const normalized = value.trim(); + const pkcs1 = normalized.startsWith("-----BEGIN RSA PRIVATE KEY-----") + && normalized.endsWith("-----END RSA PRIVATE KEY-----"); + const pkcs8 = normalized.startsWith("-----BEGIN PRIVATE KEY-----") + && normalized.endsWith("-----END PRIVATE KEY-----"); + if (!pkcs1 && !pkcs8) throw new Error("GitHub App private key must be PEM encoded."); + return value; +} + +function webhookSecret(value: GitHubWebhookSecret): GitHubWebhookSecret { + const values = typeof value === "string" ? [value] : [...value]; + if (values.length < 1 || values.length > 2) { + throw new Error("GitHub App webhook verification requires one secret or two secrets during rotation overlap."); + } + const normalized = values.map((secret) => { + if (typeof secret !== "string") throw new Error("GitHub App webhook secret must be a string."); + const bytes = Buffer.byteLength(secret, "utf8"); + if (bytes < 32 || bytes > MAX_WEBHOOK_SECRET_BYTES) { + throw new Error(`GitHub App webhook secret must contain between 32 and ${MAX_WEBHOOK_SECRET_BYTES} bytes.`); + } + return secret; + }); + if (new Set(normalized).size !== normalized.length) { + throw new Error("GitHub App webhook rotation secrets must be distinct."); + } + return normalized.length === 1 ? normalized[0]! : [normalized[0]!, normalized[1]!] as const; +} + +function validateSnapshot(value: GitHubAppRuntimeCredentialSnapshot): GitHubAppRuntimeCredentialSnapshot { + if (!value || typeof value !== "object") throw new Error("GitHub App runtime credential snapshot is required."); + return { + generation: generation(value.generation), + privateKey: privateKey(value.privateKey), + webhookSecret: webhookSecret(value.webhookSecret), + }; +} + +/** + * Own one memory-only GitHub App credential generation and swap it atomically after validation. + * + * The loader callback is the integration boundary for a secret manager, mounted credential file, + * supervisor IPC mechanism, or other operator-controlled source. SynSec never persists, serializes, + * logs, or returns credential values from status APIs. Reloads are serialized; a loader/validation + * failure leaves the previous generation active. The generation identifier is deployment metadata, + * not a credential version attestation from GitHub or the external secret manager. + */ +export function createGitHubAppRuntimeCredentialSource( + initial: GitHubAppRuntimeCredentialSnapshot, +): GitHubAppRuntimeCredentialSource { + let active = validateSnapshot(initial); + let reloadCount = 0; + let reloadTail: Promise = Promise.resolve(); + + const status = (): GitHubAppRuntimeCredentialStatus => ({ + version: 1, + generation: active.generation, + webhookSecretCount: (typeof active.webhookSecret === "string" ? 1 : active.webhookSecret.length) as 1 | 2, + reloadCount, + interpretation: "memory-only-runtime-credential-generation", + }); + + return { + getPrivateKey(): string { + return active.privateKey; + }, + getWebhookSecret(): GitHubWebhookSecret { + return active.webhookSecret; + }, + getStatus: status, + async reload(load): Promise { + if (typeof load !== "function") throw new Error("GitHub App credential reload loader is required."); + let resolveTurn!: () => void; + const previous = reloadTail; + reloadTail = new Promise((resolve) => { resolveTurn = resolve; }); + await previous; + try { + const candidate = validateSnapshot(await load()); + if (candidate.generation === active.generation) { + throw new Error("GitHub App credential reload generation must differ from the active generation."); + } + active = candidate; + reloadCount += 1; + return status(); + } finally { + resolveTurn(); + } + }, + }; +} diff --git a/packages/github/src/sarif-publisher.ts b/packages/github/src/sarif-publisher.ts new file mode 100644 index 00000000..98bd56c9 --- /dev/null +++ b/packages/github/src/sarif-publisher.ts @@ -0,0 +1,97 @@ +import { gzipSync } from "node:zlib"; +import type { SynSecReport } from "@synsec/report"; +import { toSarif } from "@synsec/report"; +import type { GitHubPullRequestContext } from "./index.js"; +import { reportMatchesGitHubCommit } from "./orchestrator.js"; +import type { GitHubPublisherOptions } from "./publisher.js"; + +const MAX_COMPRESSED_SARIF_BYTES = 10 * 1024 * 1024; + +export interface GitHubSarifPublication { + id: string; + url?: string; + commitSha: string; + ref: string; + compressedBytes: number; +} + +function repositoryParts(repository: string): [string, string] { + const match = /^([^/\s]+)\/([^/\s]+)$/.exec(repository); + if (!match?.[1] || !match[2]) throw new Error("Invalid GitHub repository in publication context."); + return [encodeURIComponent(match[1]), encodeURIComponent(match[2])]; +} + +export function sarifRefForContext(context: GitHubPullRequestContext): string { + if (context.pullRequestNumber) return `refs/pull/${context.pullRequestNumber}/head`; + if (context.ref?.startsWith("refs/")) return context.ref; + if (context.headRef) return `refs/heads/${context.headRef}`; + throw new Error("GitHub SARIF publication requires a fully qualified repository ref."); +} + +function safeResponseText(value: string, token: string): string { + return value.replaceAll(token, "[REDACTED]").replace(/[\r\n]+/g, " ").slice(0, 500); +} + +/** + * Upload one completed report as SARIF to GitHub code scanning. The destination is derived only + * from validated GitHub context and is always api.github.com; scanner/report content cannot choose + * a host. The report must identify the same commit being published. + */ +export async function publishGitHubSarif( + report: SynSecReport, + context: GitHubPullRequestContext, + token: string, + options: GitHubPublisherOptions = {}, +): Promise { + const reportSha = report.target.commitSha?.trim(); + if (!reportSha) throw new Error("GitHub SARIF publication requires a report commit SHA."); + if (!reportMatchesGitHubCommit(reportSha, context.sha)) { + throw new Error("SynSec report commit does not match the GitHub commit selected for SARIF publication."); + } + if (!token.trim()) throw new Error("GitHub SARIF publication requires a non-empty token."); + + const ref = sarifRefForContext(context); + const sarif = Buffer.from(JSON.stringify(toSarif(report)), "utf8"); + const compressed = gzipSync(sarif, { level: 9 }); + if (compressed.byteLength > MAX_COMPRESSED_SARIF_BYTES) { + throw new Error(`Compressed SARIF exceeds the ${MAX_COMPRESSED_SARIF_BYTES}-byte GitHub upload limit.`); + } + + const [owner, repository] = repositoryParts(context.repository); + const endpoint = `https://api.github.com/repos/${owner}/${repository}/code-scanning/sarifs`; + const transport = options.fetch ?? fetch; + const response = await transport(endpoint, { + method: "POST", + redirect: "error", + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "content-type": "application/json", + "user-agent": options.userAgent ?? "synsec/0.2", + "x-github-api-version": options.apiVersion ?? "2022-11-28", + }, + body: JSON.stringify({ + commit_sha: context.sha, + ref, + sarif: compressed.toString("base64"), + }), + }); + + if (!response.ok) { + const detail = safeResponseText(await response.text(), token); + throw new Error(`GitHub SARIF API returned HTTP ${response.status}${detail ? `: ${detail}` : ""}.`); + } + + const payload = await response.json() as { id?: unknown; url?: unknown }; + if (typeof payload.id !== "string" || !payload.id.trim()) { + throw new Error("GitHub SARIF API returned no upload id."); + } + + return { + id: payload.id, + ...(typeof payload.url === "string" && payload.url ? { url: payload.url } : {}), + commitSha: context.sha, + ref, + compressedBytes: compressed.byteLength, + }; +} diff --git a/packages/github/src/scan-queue.ts b/packages/github/src/scan-queue.ts new file mode 100644 index 00000000..296ce324 --- /dev/null +++ b/packages/github/src/scan-queue.ts @@ -0,0 +1,371 @@ +import { randomBytes } from "node:crypto"; +import { lstat, open, readFile, readdir, rename, rm, stat } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { ensurePrivateDirectory } from "./private-directory.js"; + +const MAX_JOB_BYTES = 16 * 1024; +const MAX_QUEUE_ENTRIES = 10_000; +const DEFAULT_LEASE_MS = 5 * 60 * 1000; +const MIN_LEASE_MS = 10_000; +const MAX_LEASE_MS = 60 * 60 * 1000; +const MAX_ATTEMPTS = 5; + +export type GitHubScanJobEvent = "push" | "pull_request"; +export type GitHubScanJobStatus = "pending" | "leased" | "failed"; + +export interface GitHubScanJobInput { + deliveryId: string; + installationId: number; + repository: string; + headSha: string; + event: GitHubScanJobEvent; + baseSha?: string; + pullRequestNumber?: number; + createdAt?: string; +} + +export interface GitHubScanJob { + version: 1; + jobId: string; + deliveryId: string; + installationId: number; + repository: string; + headSha: string; + event: GitHubScanJobEvent; + baseSha?: string; + pullRequestNumber?: number; + createdAt: string; + attempts: number; + status: GitHubScanJobStatus; + leaseUntil?: string; + /** Unique fencing identity for the current lease. Legacy persisted leases may omit it until reclaim. */ + leaseId?: string; +} + +export interface GitHubScanQueueOptions { + leaseMs?: number; + now?: () => number; +} + +function boundedString(value: unknown, label: string, maxLength: number): string { + if (typeof value !== "string") throw new Error(`${label} must be a string.`); + const normalized = value.trim(); + if (!normalized) throw new Error(`${label} is required.`); + if (normalized.length > maxLength) throw new Error(`${label} exceeds ${maxLength} characters.`); + return normalized; +} + +function positiveInteger(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) throw new Error(`${label} must be a positive integer.`); + return value; +} + +function repositoryName(value: unknown): string { + const repository = boundedString(value, "GitHub repository", 255); + if (!/^[^/\s]+\/[^/\s]+$/.test(repository)) throw new Error("GitHub repository must be in owner/name form."); + return repository; +} + +function sha(value: unknown, label: string): string { + const normalized = boundedString(value, label, 64).toLowerCase(); + if (!/^[a-f0-9]{40,64}$/.test(normalized)) throw new Error(`${label} must be a hexadecimal commit SHA.`); + return normalized; +} + +function timestamp(value: unknown, label: string): string { + const normalized = boundedString(value, label, 64); + if (!Number.isFinite(Date.parse(normalized))) throw new Error(`${label} must be an ISO timestamp.`); + return normalized; +} + +function deliveryId(value: unknown): string { + const normalized = boundedString(value, "GitHub delivery id", 128); + if (!/^[A-Za-z0-9._:-]+$/.test(normalized)) throw new Error("GitHub delivery id contains unsupported characters."); + return normalized; +} + +function jobId(value: unknown): string { + const normalized = boundedString(value, "GitHub scan job id", 64); + if (!/^[a-f0-9]{32}$/.test(normalized)) throw new Error("GitHub scan job id is invalid."); + return normalized; +} + +function leaseIdentity(value: unknown): string { + const normalized = boundedString(value, "GitHub scan job lease id", 64).toLowerCase(); + if (!/^[a-f0-9]{32}$/.test(normalized)) throw new Error("GitHub scan job lease id is invalid."); + return normalized; +} + +function leaseMs(value: number | undefined): number { + const lease = value ?? DEFAULT_LEASE_MS; + if (!Number.isSafeInteger(lease) || lease < MIN_LEASE_MS || lease > MAX_LEASE_MS) { + throw new Error(`GitHub scan job lease must be between ${MIN_LEASE_MS} and ${MAX_LEASE_MS} milliseconds.`); + } + return lease; +} + +function validateJob(value: unknown): GitHubScanJob { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Stored GitHub scan job has an invalid shape."); + const record = value as Partial; + if (record.version !== 1) throw new Error("Stored GitHub scan job has an unsupported version."); + const event = record.event; + if (event !== "push" && event !== "pull_request") throw new Error("Stored GitHub scan job has an invalid event type."); + const status = record.status; + if (status !== "pending" && status !== "leased" && status !== "failed") throw new Error("Stored GitHub scan job has an invalid status."); + const attempts = typeof record.attempts === "number" && Number.isSafeInteger(record.attempts) && record.attempts >= 0 && record.attempts <= MAX_ATTEMPTS + ? record.attempts + : undefined; + if (attempts === undefined) throw new Error("Stored GitHub scan job has an invalid attempt count."); + const pullRequestNumber = record.pullRequestNumber === undefined ? undefined : positiveInteger(record.pullRequestNumber, "GitHub pull request number"); + const baseSha = record.baseSha === undefined ? undefined : sha(record.baseSha, "GitHub base SHA"); + if (event === "pull_request" && (!baseSha || !pullRequestNumber)) throw new Error("Pull request scan jobs require base SHA and pull request number."); + if (event === "push" && (baseSha || pullRequestNumber)) throw new Error("Push scan jobs must not contain pull request metadata."); + const leaseUntil = record.leaseUntil === undefined ? undefined : timestamp(record.leaseUntil, "GitHub scan job leaseUntil"); + const leaseId = record.leaseId === undefined ? undefined : leaseIdentity(record.leaseId); + if (status === "leased" && !leaseUntil) throw new Error("Leased GitHub scan jobs require leaseUntil."); + if (status !== "leased" && (leaseUntil || leaseId)) throw new Error("Only leased GitHub scan jobs may contain lease metadata."); + return { + version: 1, + jobId: jobId(record.jobId), + deliveryId: deliveryId(record.deliveryId), + installationId: positiveInteger(record.installationId, "GitHub installation id"), + repository: repositoryName(record.repository), + headSha: sha(record.headSha, "GitHub head SHA"), + event, + ...(baseSha ? { baseSha } : {}), + ...(pullRequestNumber ? { pullRequestNumber } : {}), + createdAt: timestamp(record.createdAt, "GitHub scan job createdAt"), + attempts, + status, + ...(leaseUntil ? { leaseUntil } : {}), + ...(leaseId ? { leaseId } : {}), + }; +} + +function pathFor(directory: string, id: string): string { + return join(directory, `${jobId(id)}.json`); +} + +async function readJob(path: string): Promise { + const metadata = await lstat(path); + if (metadata.isSymbolicLink() || !metadata.isFile() || metadata.size > MAX_JOB_BYTES) { + throw new Error("Stored GitHub scan job is invalid, symlinked, or oversized."); + } + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(path, "utf8")); + } catch { + throw new Error("Stored GitHub scan job is invalid JSON."); + } + return validateJob(parsed); +} + +async function writeJob(directory: string, record: GitHubScanJob): Promise { + await ensurePrivateDirectory(directory); + const path = pathFor(directory, record.jobId); + const tempPath = join(directory, `.job-${record.jobId}-${randomBytes(8).toString("hex")}.tmp`); + const handle = await open(tempPath, "wx", 0o600); + try { + await handle.writeFile(`${JSON.stringify(record)}\n`, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await rename(tempPath, path); + } finally { + await rm(tempPath, { force: true }); + } +} + +function isNotFound(error: unknown): boolean { + return error instanceof Error && Object.prototype.hasOwnProperty.call(error, "code") && (error as NodeJS.ErrnoException).code === "ENOENT"; +} + +/** Durable bounded local queue for commit-pinned GitHub App scan work. */ +export class FileGitHubScanQueue { + readonly directory: string; + readonly leaseMs: number; + private readonly now: () => number; + /** Serialize enqueue duplicate/capacity checks within this instance. Cross-process atomicity is not claimed. */ + private enqueueTail: Promise = Promise.resolve(); + /** Serialize claims within this queue instance. Cross-process/multi-host atomicity is intentionally not claimed. */ + private claimTail: Promise = Promise.resolve(); + + constructor(directory: string, options: GitHubScanQueueOptions = {}) { + const normalized = directory.trim(); + if (!normalized) throw new Error("GitHub scan-queue directory is required."); + this.directory = resolve(normalized); + this.leaseMs = leaseMs(options.leaseMs); + this.now = options.now ?? Date.now; + } + + async enqueue(input: GitHubScanJobInput): Promise { + let release!: () => void; + const previous = this.enqueueTail; + this.enqueueTail = new Promise((resolveEnqueue) => { release = resolveEnqueue; }); + await previous; + try { + return await this.enqueueSerialized(input); + } finally { + release(); + } + } + + private async enqueueSerialized(input: GitHubScanJobInput): Promise { + const now = this.now(); + if (!Number.isFinite(now) || now <= 0) throw new Error("GitHub scan-queue clock must be a positive timestamp."); + const event = input.event; + if (event !== "push" && event !== "pull_request") throw new Error("GitHub scan job event must be push or pull_request."); + const candidate = validateJob({ + version: 1, + jobId: randomBytes(16).toString("hex"), + deliveryId: input.deliveryId, + installationId: input.installationId, + repository: input.repository, + headSha: input.headSha, + event, + ...(input.baseSha ? { baseSha: input.baseSha } : {}), + ...(input.pullRequestNumber ? { pullRequestNumber: input.pullRequestNumber } : {}), + createdAt: input.createdAt ?? new Date(now).toISOString(), + attempts: 0, + status: "pending", + }); + const existing = await this.list(); + if (existing.some((job) => job.deliveryId === candidate.deliveryId)) throw new Error("GitHub delivery id is already queued."); + if (existing.length >= MAX_QUEUE_ENTRIES) throw new Error(`GitHub scan queue reached the ${MAX_QUEUE_ENTRIES}-job limit.`); + await writeJob(this.directory, candidate); + return candidate; + } + + async list(): Promise { + await ensurePrivateDirectory(this.directory); + const entries = (await readdir(this.directory, { withFileTypes: true })).filter((entry) => entry.isFile() && /^[a-f0-9]{32}\.json$/.test(entry.name)); + if (entries.length > MAX_QUEUE_ENTRIES) throw new Error(`GitHub scan queue exceeds the ${MAX_QUEUE_ENTRIES}-job limit.`); + const jobs: GitHubScanJob[] = []; + for (const entry of entries) { + const record = await readJob(join(this.directory, entry.name)); + if (`${record.jobId}.json` !== entry.name) throw new Error("Stored GitHub scan job id does not match its filename."); + jobs.push(record); + } + return jobs.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.jobId.localeCompare(b.jobId)); + } + + async claimNext(): Promise { + let release!: () => void; + const previous = this.claimTail; + this.claimTail = new Promise((resolveClaim) => { release = resolveClaim; }); + await previous; + try { + return await this.claimNextSerialized(); + } finally { + release(); + } + } + + private async claimNextSerialized(): Promise { + while (true) { + const now = this.now(); + if (!Number.isFinite(now) || now <= 0) throw new Error("GitHub scan-queue clock must be a positive timestamp."); + const jobs = await this.list(); + const candidate = jobs.find((job) => job.status === "pending" || (job.status === "leased" && Date.parse(job.leaseUntil ?? "") <= now)); + if (!candidate) return undefined; + if (candidate.attempts >= MAX_ATTEMPTS) { + const failed = { ...candidate, status: "failed" as const }; + delete failed.leaseUntil; + delete failed.leaseId; + await writeJob(this.directory, failed); + continue; + } + const leased: GitHubScanJob = { + ...candidate, + attempts: candidate.attempts + 1, + status: "leased", + leaseUntil: new Date(now + this.leaseMs).toISOString(), + leaseId: randomBytes(16).toString("hex"), + }; + await writeJob(this.directory, leased); + return leased; + } + } + + async assertLease(jobIdValue: string, expectedLeaseId: string): Promise { + const current = await this.require(jobIdValue); + const now = this.now(); + if (!Number.isFinite(now) || now <= 0) throw new Error("GitHub scan-queue clock must be a positive timestamp."); + const expected = leaseIdentity(expectedLeaseId); + if (current.status !== "leased" || !current.leaseId || current.leaseId !== expected) { + throw new Error("GitHub scan job lease is stale or no longer owned by this worker."); + } + if (Date.parse(current.leaseUntil ?? "") <= now) { + throw new Error("GitHub scan job lease has expired."); + } + return current; + } + + async renew(jobIdValue: string, expectedLeaseId: string): Promise { + const current = await this.assertLease(jobIdValue, expectedLeaseId); + const now = this.now(); + const renewed: GitHubScanJob = { + ...current, + leaseUntil: new Date(now + this.leaseMs).toISOString(), + }; + await writeJob(this.directory, renewed); + return renewed; + } + + async release(jobIdValue: string, expectedLeaseId: string): Promise { + const current = await this.assertLease(jobIdValue, expectedLeaseId); + const pending: GitHubScanJob = { ...current, status: "pending" }; + delete pending.leaseUntil; + delete pending.leaseId; + await writeJob(this.directory, pending); + return pending; + } + + async fail(jobIdValue: string, expectedLeaseId: string): Promise { + const current = await this.assertLease(jobIdValue, expectedLeaseId); + const failed: GitHubScanJob = { ...current, status: "failed" }; + delete failed.leaseUntil; + delete failed.leaseId; + await writeJob(this.directory, failed); + return failed; + } + + async complete(jobIdValue: string, expectedLeaseId: string): Promise { + const current = await this.assertLease(jobIdValue, expectedLeaseId); + try { + await stat(pathFor(this.directory, current.jobId)); + } catch (error) { + if (isNotFound(error)) return false; + throw error; + } + await rm(pathFor(this.directory, current.jobId)); + return true; + } + + async deleteFailed(jobIdValue: string): Promise { + const current = await this.require(jobIdValue); + if (current.status !== "failed") throw new Error("Only failed GitHub scan jobs can be deleted by retention."); + try { + await rm(pathFor(this.directory, current.jobId)); + return true; + } catch (error) { + if (isNotFound(error)) return false; + throw error; + } + } + + private async require(jobIdValue: string): Promise { + const id = jobId(jobIdValue); + await ensurePrivateDirectory(this.directory); + try { + const record = await readJob(pathFor(this.directory, id)); + if (record.jobId !== id) throw new Error("Stored GitHub scan job id does not match its filename."); + return record; + } catch (error) { + if (isNotFound(error)) throw new Error("GitHub scan job was not found."); + throw error; + } + } +} diff --git a/packages/github/src/scanner-isolation-profile.ts b/packages/github/src/scanner-isolation-profile.ts new file mode 100644 index 00000000..e370046f --- /dev/null +++ b/packages/github/src/scanner-isolation-profile.ts @@ -0,0 +1,129 @@ +export type SynSecScannerIsolationRuntime = "container" | "sandbox"; +export type SynSecScannerIsolationNetworkPolicy = "none" | "egress-filtered"; + +export interface SynSecScannerIsolationProfile { + schemaVersion: 1; + runtime: SynSecScannerIsolationRuntime; + cpuLimit: true; + memoryLimit: true; + networkPolicy: SynSecScannerIsolationNetworkPolicy; + repositoryReadOnly: true; + rootFilesystemReadOnly: true; + scratchSeparated: true; + credentialsExcluded: true; + durableStateExcluded: true; + privileged: false; + allowPrivilegeEscalation: false; + runAsNonRoot: true; + capabilitiesDropped: true; + hostNetwork: false; + hostPid: false; + hostIpc: false; + hostSocketMounts: false; +} + +export type SynSecScannerIsolationControl = + | "supported-runtime" + | "cpu-limit" + | "memory-limit" + | "restricted-network" + | "read-only-repository" + | "read-only-root-filesystem" + | "separate-scratch" + | "credentials-excluded" + | "durable-state-excluded" + | "not-privileged" + | "no-privilege-escalation" + | "run-as-non-root" + | "capabilities-dropped" + | "no-host-network" + | "no-host-pid" + | "no-host-ipc" + | "no-host-socket-mounts"; + +export interface SynSecScannerIsolationAssessment { + complete: boolean; + missing: SynSecScannerIsolationControl[]; + interpretation: "declared-infrastructure-controls-not-runtime-certification"; +} + +export const REQUIRED_SYNSEC_SCANNER_ISOLATION_CONTROLS = [ + "supported-runtime", + "cpu-limit", + "memory-limit", + "restricted-network", + "read-only-repository", + "read-only-root-filesystem", + "separate-scratch", + "credentials-excluded", + "durable-state-excluded", + "not-privileged", + "no-privilege-escalation", + "run-as-non-root", + "capabilities-dropped", + "no-host-network", + "no-host-pid", + "no-host-ipc", + "no-host-socket-mounts", +] as const satisfies readonly SynSecScannerIsolationControl[]; + +function hasControl(profile: Partial, control: SynSecScannerIsolationControl): boolean { + switch (control) { + case "supported-runtime": + return profile.runtime === "container" || profile.runtime === "sandbox"; + case "cpu-limit": + return profile.cpuLimit === true; + case "memory-limit": + return profile.memoryLimit === true; + case "restricted-network": + return profile.networkPolicy === "none" || profile.networkPolicy === "egress-filtered"; + case "read-only-repository": + return profile.repositoryReadOnly === true; + case "read-only-root-filesystem": + return profile.rootFilesystemReadOnly === true; + case "separate-scratch": + return profile.scratchSeparated === true; + case "credentials-excluded": + return profile.credentialsExcluded === true; + case "durable-state-excluded": + return profile.durableStateExcluded === true; + case "not-privileged": + return profile.privileged === false; + case "no-privilege-escalation": + return profile.allowPrivilegeEscalation === false; + case "run-as-non-root": + return profile.runAsNonRoot === true; + case "capabilities-dropped": + return profile.capabilitiesDropped === true; + case "no-host-network": + return profile.hostNetwork === false; + case "no-host-pid": + return profile.hostPid === false; + case "no-host-ipc": + return profile.hostIpc === false; + case "no-host-socket-mounts": + return profile.hostSocketMounts === false; + } +} + +/** + * Assess a secret-free declaration of scanner sandbox controls. + * + * This is intentionally stricter than the legacy high-level deployment declaration: it makes + * common container-escape and credential-boundary assumptions explicit so production deployment + * tooling can fail closed before scanner subprocesses are activated. It does not inspect the host + * or certify that the declared controls are actually enforced by Docker, Kubernetes, or another + * sandbox runtime. + */ +export function assessSynSecScannerIsolationProfile( + profile: Partial | undefined, +): SynSecScannerIsolationAssessment { + const missing = REQUIRED_SYNSEC_SCANNER_ISOLATION_CONTROLS.filter( + (control) => !profile || !hasControl(profile, control), + ); + return { + complete: profile?.schemaVersion === 1 && missing.length === 0, + missing: profile?.schemaVersion === 1 ? [...missing] : [...REQUIRED_SYNSEC_SCANNER_ISOLATION_CONTROLS], + interpretation: "declared-infrastructure-controls-not-runtime-certification", + }; +} diff --git a/packages/github/src/scanner-production-readiness.ts b/packages/github/src/scanner-production-readiness.ts new file mode 100644 index 00000000..9478a4b0 --- /dev/null +++ b/packages/github/src/scanner-production-readiness.ts @@ -0,0 +1,62 @@ +import { + validateGitHubAppDeployment, + type GitHubAppDeploymentConfig, + type GitHubAppDeploymentReadiness, +} from "./app-deployment.js"; +import { + assessSynSecScannerIsolationProfile, + type SynSecScannerIsolationAssessment, + type SynSecScannerIsolationProfile, +} from "./scanner-isolation-profile.js"; + +export interface SynSecGitHubAppScannerProductionReadinessInput { + deployment: GitHubAppDeploymentConfig; + scannerIsolationProfile?: Partial; +} + +export interface SynSecGitHubAppScannerProductionReadiness { + ready: boolean; + deployment: GitHubAppDeploymentReadiness; + scannerIsolation: SynSecScannerIsolationAssessment; + interpretation: "deployment-and-isolation-declarations-not-runtime-certification"; +} + +/** + * Compose the existing hosted deployment preflight with the stricter scanner isolation profile. + * + * Production callers cannot accidentally obtain a ready result from the advisory scanner-isolation + * mode: this function always re-validates deployment with `requireScannerIsolation: true` and also + * requires the versioned isolation profile to be complete. + */ +export function assessGitHubAppScannerProductionReadiness( + input: SynSecGitHubAppScannerProductionReadinessInput, +): SynSecGitHubAppScannerProductionReadiness { + const deployment = validateGitHubAppDeployment({ + ...input.deployment, + requireScannerIsolation: true, + }); + const scannerIsolation = assessSynSecScannerIsolationProfile(input.scannerIsolationProfile); + return { + ready: deployment.ready && scannerIsolation.complete, + deployment, + scannerIsolation, + interpretation: "deployment-and-isolation-declarations-not-runtime-certification", + }; +} + +export function assertGitHubAppScannerProductionReady( + input: SynSecGitHubAppScannerProductionReadinessInput, +): void { + const readiness = assessGitHubAppScannerProductionReadiness(input); + if (readiness.ready) return; + + const deploymentCodes = readiness.deployment.issues + .filter((issue) => issue.level === "error") + .map((issue) => issue.code); + const isolationControls = readiness.scannerIsolation.missing; + const diagnostics = [ + ...deploymentCodes.map((code) => `deployment:${code}`), + ...isolationControls.map((control) => `scanner-isolation:${control}`), + ]; + throw new Error(`GitHub App scanner production readiness failed: ${diagnostics.join(", ") || "unknown"}`); +} diff --git a/packages/github/src/shared-runtime.ts b/packages/github/src/shared-runtime.ts new file mode 100644 index 00000000..a35a09c9 --- /dev/null +++ b/packages/github/src/shared-runtime.ts @@ -0,0 +1,73 @@ +import type { GitHubWebhookSecret } from "./app.js"; +import { createGitHubAppWebhookHttpHandler, type GitHubAppWebhookHttpOptions } from "./app-http.js"; +import type { GitHubAppInstallationStore, GitHubWebhookReplayManager } from "./app-handler.js"; +import type { GitHubScanJobEnqueuer } from "./app-dispatch.js"; +import { + runConfiguredGitHubAppWorkerOnce, + type ConfiguredGitHubAppWorkerOptions, +} from "./app-worker-runner.js"; +import type { GitHubAppWorkerQueue } from "./app-worker.js"; +import type { GitHubAppSharedStateBackendContract } from "./shared-state-contract.js"; +import { assessGitHubAppSharedStateConformanceEvidence } from "./shared-state-evidence.js"; + +export interface GitHubAppSharedRuntimeOptions { + /** Concrete adapter/version declaration bound to the supplied conformance report. */ + backendContract: GitHubAppSharedStateBackendContract; + /** Portable report produced by the real-backend adversarial conformance harness. */ + conformanceReport: unknown; + webhookSecret: GitHubWebhookSecret; + replayStore: GitHubWebhookReplayManager; + installationStore: GitHubAppInstallationStore; + queue: GitHubScanJobEnqueuer & GitHubAppWorkerQueue; + webhookPath?: string; + onWebhookError?: (error: unknown) => void; + worker: Omit; +} + +export interface GitHubAppSharedRuntime { + backendId: string; + implementationVersion: string; + webhookHandler: ReturnType; + runWorkerOnce(): ReturnType; +} + +/** + * Compose externally implemented shared stores into SynSec's hosted intake/worker pipeline. + * + * Composition fails closed unless the supplied versioned backend contract is paired with complete, + * structurally valid conformance evidence for that exact adapter build. This still does not create a + * database client or independently certify that the adapter's harness used a real backend; it makes + * evidence consumption mandatory at the runtime integration boundary instead of trusting capability + * declarations alone. Backend credentials remain owned by the adapter and are not accepted by this API. + */ +export function createGitHubAppSharedRuntime(options: GitHubAppSharedRuntimeOptions): GitHubAppSharedRuntime { + const evidence = assessGitHubAppSharedStateConformanceEvidence( + options.backendContract, + options.conformanceReport, + ); + if (!evidence.ready) { + throw new Error(`GitHub App shared-state conformance evidence is not ready: ${evidence.issues.map((issue) => issue.code).join(", ")}`); + } + + const httpOptions: GitHubAppWebhookHttpOptions = { + webhookSecret: options.webhookSecret, + replayStore: options.replayStore, + installationStore: options.installationStore, + queue: options.queue, + ...(options.webhookPath ? { path: options.webhookPath } : {}), + ...(options.onWebhookError ? { onError: options.onWebhookError } : {}), + }; + const webhookHandler = createGitHubAppWebhookHttpHandler(httpOptions); + const workerOptions: ConfiguredGitHubAppWorkerOptions = { + ...options.worker, + queue: options.queue, + installationStore: options.installationStore, + }; + + return { + backendId: options.backendContract.backendId, + implementationVersion: options.backendContract.implementationVersion, + webhookHandler, + runWorkerOnce: () => runConfiguredGitHubAppWorkerOnce(workerOptions), + }; +} diff --git a/packages/github/src/shared-state-conformance-runner.ts b/packages/github/src/shared-state-conformance-runner.ts new file mode 100644 index 00000000..6ff690e9 --- /dev/null +++ b/packages/github/src/shared-state-conformance-runner.ts @@ -0,0 +1,168 @@ +import { + GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS, + assessGitHubAppSharedStateConformanceCoverage, + type GitHubAppSharedStateConformanceCoverageAssessment, +} from "./shared-state-conformance.js"; + +const DEFAULT_SCENARIO_TIMEOUT_MS = 15_000; +const MIN_SCENARIO_TIMEOUT_MS = 100; +const MAX_SCENARIO_TIMEOUT_MS = 120_000; +const MAX_IDENTIFIER_LENGTH = 128; +const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +export type GitHubAppSharedStateConformanceScenarioId = + (typeof GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS)[number]["id"]; + +export interface GitHubAppSharedStateConformanceAdapter { + /** Stable non-secret adapter identity matching the shared-state backend contract. */ + backendId: string; + /** Exact adapter/build version matching the shared-state backend contract. */ + implementationVersion: string; + /** + * Reset adapter-owned test state before a scenario. This hook must be safe to run repeatedly. + * Credentials and connection strings remain adapter-private and must not be returned. + */ + reset(): Promise; + scenarios: Readonly Promise>>; +} + +export interface GitHubAppSharedStateConformanceRunOptions { + scenarioTimeoutMs?: number; + now?: () => number; +} + +export interface GitHubAppSharedStateConformanceScenarioResult { + id: GitHubAppSharedStateConformanceScenarioId; + status: "passed" | "failed" | "timed-out"; + durationMs: number; +} + +export interface GitHubAppSharedStateConformanceRunReport { + schemaVersion: 1; + backendId: string; + implementationVersion: string; + complete: boolean; + scenarioTimeoutMs: number; + results: GitHubAppSharedStateConformanceScenarioResult[]; + coverage: GitHubAppSharedStateConformanceCoverageAssessment; +} + +function validatedTimeout(value: number | undefined): number { + const timeout = value ?? DEFAULT_SCENARIO_TIMEOUT_MS; + if (!Number.isSafeInteger(timeout) || timeout < MIN_SCENARIO_TIMEOUT_MS || timeout > MAX_SCENARIO_TIMEOUT_MS) { + throw new Error( + `Shared-state conformance scenario timeout must be an integer between ${MIN_SCENARIO_TIMEOUT_MS} and ${MAX_SCENARIO_TIMEOUT_MS} milliseconds.`, + ); + } + return timeout; +} + +function validatedIdentifier(value: string, field: string): string { + if (typeof value !== "string" || value.length > MAX_IDENTIFIER_LENGTH || !IDENTIFIER_PATTERN.test(value)) { + throw new Error(`${field} must be a bounded non-secret identifier.`); + } + return value; +} + +function validateAdapter(adapter: GitHubAppSharedStateConformanceAdapter): { + backendId: string; + implementationVersion: string; +} { + if (!adapter || typeof adapter !== "object") { + throw new Error("Shared-state conformance adapter is required."); + } + const backendId = validatedIdentifier(adapter.backendId, "Shared-state backend id"); + const implementationVersion = validatedIdentifier( + adapter.implementationVersion, + "Shared-state implementation version", + ); + if (typeof adapter.reset !== "function") { + throw new Error("Shared-state conformance adapter reset() is required."); + } + if (!adapter.scenarios || typeof adapter.scenarios !== "object") { + throw new Error("Shared-state conformance adapter scenarios are required."); + } + + const requiredIds = new Set( + GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS.map((scenario) => scenario.id), + ); + const suppliedIds = Object.keys(adapter.scenarios); + const missing = [...requiredIds].filter((id) => typeof adapter.scenarios[id as GitHubAppSharedStateConformanceScenarioId] !== "function"); + const unknown = suppliedIds.filter((id) => !requiredIds.has(id)); + if (missing.length > 0 || unknown.length > 0) { + throw new Error("Shared-state conformance adapter must implement exactly the required scenario ids."); + } + return { backendId, implementationVersion }; +} + +async function runWithTimeout( + operation: () => Promise, + timeoutMs: number, +): Promise<"passed" | "failed" | "timed-out"> { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + Promise.resolve() + .then(operation) + .then(() => "passed" as const, () => "failed" as const), + new Promise<"timed-out">((resolve) => { + timer = setTimeout(() => resolve("timed-out"), timeoutMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +/** + * Execute the canonical shared-state adversarial matrix against an adapter-provided real backend. + * + * Scenarios run sequentially and are reset independently so one failed scenario cannot manufacture + * evidence for another. Failure details are intentionally excluded from the report: database errors + * can contain credentials, hostnames, queries, or customer data. Adapter test harnesses may log their + * own sanitized diagnostics separately. + * + * The report is bound to the same bounded backend id and implementation version used by the versioned + * shared-state contract. A passing report is still evidence, not backend certification by itself. + */ +export async function runGitHubAppSharedStateConformance( + adapter: GitHubAppSharedStateConformanceAdapter, + options: GitHubAppSharedStateConformanceRunOptions = {}, +): Promise { + const { backendId, implementationVersion } = validateAdapter(adapter); + const scenarioTimeoutMs = validatedTimeout(options.scenarioTimeoutMs); + const now = options.now ?? Date.now; + const results: GitHubAppSharedStateConformanceScenarioResult[] = []; + + for (const scenario of GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS) { + const startedAt = now(); + let status: GitHubAppSharedStateConformanceScenarioResult["status"]; + try { + await adapter.reset(); + status = await runWithTimeout(adapter.scenarios[scenario.id], scenarioTimeoutMs); + } catch { + status = "failed"; + } + const finishedAt = now(); + results.push({ + id: scenario.id, + status, + durationMs: Math.max(0, Math.min(Number.MAX_SAFE_INTEGER, Math.trunc(finishedAt - startedAt))), + }); + } + + const completedScenarioIds = results + .filter((result) => result.status === "passed") + .map((result) => result.id); + const coverage = assessGitHubAppSharedStateConformanceCoverage(completedScenarioIds); + + return { + schemaVersion: 1, + backendId, + implementationVersion, + complete: coverage.complete, + scenarioTimeoutMs, + results, + coverage, + }; +} diff --git a/packages/github/src/shared-state-conformance.ts b/packages/github/src/shared-state-conformance.ts new file mode 100644 index 00000000..d1f95502 --- /dev/null +++ b/packages/github/src/shared-state-conformance.ts @@ -0,0 +1,113 @@ +import { + REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES, + type GitHubAppSharedStateCapability, +} from "./app-deployment.js"; + +export type GitHubAppSharedStateConformanceRisk = + | "duplicate-processing" + | "stale-worker" + | "authorization-race" + | "partial-state"; + +export interface GitHubAppSharedStateConformanceScenario { + id: string; + capability: GitHubAppSharedStateCapability; + risk: GitHubAppSharedStateConformanceRisk; + invariant: string; +} + +/** + * Stable minimum adversarial scenarios a shared-state adapter must exercise against its real + * backend before SynSec can treat the capability declaration as supported by conformance evidence. + */ +export const GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS = [ + { + id: "replay.concurrent-duplicate-claim", + capability: "atomicReplayClaim", + risk: "duplicate-processing", + invariant: "Concurrent claims for one delivery admit at most one accepted replay claim.", + }, + { + id: "queue.concurrent-idempotent-insert", + capability: "atomicQueueInsertion", + risk: "duplicate-processing", + invariant: "Concurrent insertion of the same logical scan work produces one durable job identity.", + }, + { + id: "queue.concurrent-claim-fence", + capability: "atomicQueueClaimWithFence", + risk: "stale-worker", + invariant: "Competing workers cannot both hold a valid lease, and every successful claim receives a fresh fence.", + }, + { + id: "queue.stale-fence-renewal", + capability: "compareAndSetLeaseRenewal", + risk: "stale-worker", + invariant: "A superseded fence cannot renew a newer lease.", + }, + { + id: "queue.stale-fence-terminal-transitions", + capability: "fencedQueueTransitions", + risk: "stale-worker", + invariant: "A superseded fence cannot release, fail, or complete work owned by a newer lease.", + }, + { + id: "installation.concurrent-selection-mutation", + capability: "transactionalInstallationState", + risk: "partial-state", + invariant: "Concurrent installation and repository-selection mutations expose only complete authorization states.", + }, + { + id: "authorization.cross-replica-revocation", + capability: "sharedAuthorizationState", + risk: "authorization-race", + invariant: "Authorization removal becomes authoritative for independent intake and worker replicas before further protected work proceeds.", + }, +] as const satisfies readonly GitHubAppSharedStateConformanceScenario[]; + +export interface GitHubAppSharedStateConformanceCoverageAssessment { + complete: boolean; + coveredScenarioIds: string[]; + missingScenarioIds: string[]; + missingCapabilities: GitHubAppSharedStateCapability[]; +} + +const REQUIRED_SCENARIO_IDS = new Set( + GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS.map((scenario) => scenario.id), +); + +/** + * Assess scenario coverage from bounded scenario identifiers produced by an adapter's real-backend + * test suite. Unknown identifiers are ignored so callers cannot satisfy the contract with arbitrary + * free-form evidence. This function does not execute or trust a database by itself. + */ +export function assessGitHubAppSharedStateConformanceCoverage( + completedScenarioIds: readonly string[], +): GitHubAppSharedStateConformanceCoverageAssessment { + const covered = new Set(); + for (const id of completedScenarioIds) { + if (REQUIRED_SCENARIO_IDS.has(id)) covered.add(id); + } + + const coveredScenarioIds = GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS + .filter((scenario) => covered.has(scenario.id)) + .map((scenario) => scenario.id); + const missingScenarioIds = GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS + .filter((scenario) => !covered.has(scenario.id)) + .map((scenario) => scenario.id); + const missingCapabilitySet = new Set( + GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS + .filter((scenario) => !covered.has(scenario.id)) + .map((scenario) => scenario.capability), + ); + const missingCapabilities = REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES.filter( + (capability) => missingCapabilitySet.has(capability), + ); + + return { + complete: missingScenarioIds.length === 0, + coveredScenarioIds, + missingScenarioIds, + missingCapabilities: [...missingCapabilities], + }; +} diff --git a/packages/github/src/shared-state-contract.ts b/packages/github/src/shared-state-contract.ts new file mode 100644 index 00000000..ee8c5653 --- /dev/null +++ b/packages/github/src/shared-state-contract.ts @@ -0,0 +1,186 @@ +import { + REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES, + type GitHubAppSharedStateCapabilities, + type GitHubAppSharedStateCapability, +} from "./app-deployment.js"; + +export const GITHUB_APP_SHARED_STATE_CONTRACT_VERSION = 1 as const; + +export type GitHubAppSharedStateEvidenceMechanism = + | "database-constraint" + | "serializable-transaction" + | "compare-and-set" + | "fencing-token" + | "shared-durable-store"; + +export interface GitHubAppSharedStateCapabilityEvidence { + capability: GitHubAppSharedStateCapability; + mechanism: GitHubAppSharedStateEvidenceMechanism; + /** Stable implementation/test reference; never a connection string or credential. */ + reference: string; +} + +export interface GitHubAppSharedStateBackendContract { + contractVersion: typeof GITHUB_APP_SHARED_STATE_CONTRACT_VERSION; + /** Stable non-secret adapter identity, for example `postgres-v1`. */ + backendId: string; + /** Adapter/build version used to produce the declaration. */ + implementationVersion: string; + capabilities: GitHubAppSharedStateCapabilities; + evidence: GitHubAppSharedStateCapabilityEvidence[]; +} + +export type GitHubAppSharedStateContractIssueCode = + | "invalid-shape" + | "unsupported-contract-version" + | "invalid-backend-id" + | "invalid-implementation-version" + | "invalid-capabilities" + | "invalid-evidence" + | "duplicate-evidence" + | "missing-capability-evidence"; + +export interface GitHubAppSharedStateContractIssue { + code: GitHubAppSharedStateContractIssueCode; + capability?: GitHubAppSharedStateCapability; + message: string; +} + +export interface GitHubAppSharedStateContractAssessment { + ready: boolean; + issues: GitHubAppSharedStateContractIssue[]; + missingEvidence: GitHubAppSharedStateCapability[]; +} + +const MAX_IDENTIFIER_LENGTH = 128; +const MAX_REFERENCE_LENGTH = 240; +const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const EVIDENCE_MECHANISMS = new Set([ + "database-constraint", + "serializable-transaction", + "compare-and-set", + "fencing-token", + "shared-durable-store", +]); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function exactKeys(record: Record, allowed: readonly string[]): boolean { + const allowedSet = new Set(allowed); + return Object.keys(record).every((key) => allowedSet.has(key)); +} + +function validIdentifier(value: unknown): value is string { + return typeof value === "string" && value.length <= MAX_IDENTIFIER_LENGTH && IDENTIFIER_PATTERN.test(value); +} + +function validReference(value: unknown): value is string { + return typeof value === "string" + && value.length > 0 + && value.length <= MAX_REFERENCE_LENGTH + && !/[\u0000-\u001f\u007f]/.test(value) + && !/:\/\//.test(value) + && !/@/.test(value); +} + +function parseCapabilities(value: unknown): GitHubAppSharedStateCapabilities | undefined { + if (!isRecord(value) || !exactKeys(value, REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES)) return undefined; + for (const capability of REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES) { + if (value[capability] !== true) return undefined; + } + return value as unknown as GitHubAppSharedStateCapabilities; +} + +/** + * Validate a secret-free declaration describing one concrete shared-state adapter build. + * + * This does not certify the backend or execute concurrency tests. It makes the integration + * boundary versioned, attributable, complete, and suitable for binding to independent + * conformance-test evidence without accepting connection details or arbitrary URLs. + */ +export function assessGitHubAppSharedStateBackendContract( + value: unknown, +): GitHubAppSharedStateContractAssessment { + const issues: GitHubAppSharedStateContractIssue[] = []; + const missingEvidence: GitHubAppSharedStateCapability[] = []; + + if (!isRecord(value) || !exactKeys(value, ["contractVersion", "backendId", "implementationVersion", "capabilities", "evidence"])) { + return { + ready: false, + issues: [{ code: "invalid-shape", message: "Shared-state backend contract has an invalid or unsupported shape." }], + missingEvidence: [...REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES], + }; + } + + if (value.contractVersion !== GITHUB_APP_SHARED_STATE_CONTRACT_VERSION) { + issues.push({ + code: "unsupported-contract-version", + message: `Shared-state backend contract version must be ${GITHUB_APP_SHARED_STATE_CONTRACT_VERSION}.`, + }); + } + if (!validIdentifier(value.backendId)) { + issues.push({ code: "invalid-backend-id", message: "Shared-state backend id must be a bounded non-secret identifier." }); + } + if (!validIdentifier(value.implementationVersion)) { + issues.push({ + code: "invalid-implementation-version", + message: "Shared-state implementation version must be a bounded non-secret identifier.", + }); + } + + const capabilities = parseCapabilities(value.capabilities); + if (!capabilities) { + issues.push({ + code: "invalid-capabilities", + message: "Shared-state backend contract must explicitly declare every required capability as true.", + }); + } + + const evidenced = new Set(); + if (!Array.isArray(value.evidence) || value.evidence.length > REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES.length) { + issues.push({ code: "invalid-evidence", message: "Shared-state capability evidence must contain at most one entry per required capability." }); + } else { + for (const entry of value.evidence) { + if (!isRecord(entry) || !exactKeys(entry, ["capability", "mechanism", "reference"])) { + issues.push({ code: "invalid-evidence", message: "Shared-state capability evidence contains an invalid entry." }); + continue; + } + const capability = typeof entry.capability === "string" + && REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES.includes(entry.capability as GitHubAppSharedStateCapability) + ? entry.capability as GitHubAppSharedStateCapability + : undefined; + if (!capability || typeof entry.mechanism !== "string" + || !EVIDENCE_MECHANISMS.has(entry.mechanism as GitHubAppSharedStateEvidenceMechanism) + || !validReference(entry.reference)) { + issues.push({ code: "invalid-evidence", message: "Shared-state capability evidence contains unsupported or unsafe values." }); + continue; + } + if (evidenced.has(capability)) { + issues.push({ code: "duplicate-evidence", capability, message: `Shared-state capability ${capability} has duplicate evidence.` }); + continue; + } + evidenced.add(capability); + } + } + + for (const capability of REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES) { + if (!evidenced.has(capability)) { + missingEvidence.push(capability); + issues.push({ + code: "missing-capability-evidence", + capability, + message: `Shared-state capability ${capability} is missing implementation evidence.`, + }); + } + } + + return { ready: issues.length === 0 && Boolean(capabilities), issues, missingEvidence }; +} + +export function assertGitHubAppSharedStateBackendContract(value: unknown): asserts value is GitHubAppSharedStateBackendContract { + const assessment = assessGitHubAppSharedStateBackendContract(value); + if (assessment.ready) return; + throw new Error(`GitHub App shared-state backend contract is not ready: ${assessment.issues.map((issue) => issue.code).join(", ")}`); +} diff --git a/packages/github/src/shared-state-evidence.ts b/packages/github/src/shared-state-evidence.ts new file mode 100644 index 00000000..f7d1643c --- /dev/null +++ b/packages/github/src/shared-state-evidence.ts @@ -0,0 +1,189 @@ +import { + GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS, + assessGitHubAppSharedStateConformanceCoverage, +} from "./shared-state-conformance.js"; +import { + GITHUB_APP_SHARED_STATE_CONTRACT_VERSION, + assessGitHubAppSharedStateBackendContract, + type GitHubAppSharedStateBackendContract, +} from "./shared-state-contract.js"; + +const REPORT_SCHEMA_VERSION = 1; +const MAX_IDENTIFIER_LENGTH = 128; +const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const RESULT_STATUSES = new Set(["passed", "failed", "timed-out"]); + +export type GitHubAppSharedStateEvidenceIssueCode = + | "invalid-backend-contract" + | "invalid-conformance-report" + | "backend-id-mismatch" + | "implementation-version-mismatch" + | "incomplete-conformance"; + +export interface GitHubAppSharedStateEvidenceIssue { + code: GitHubAppSharedStateEvidenceIssueCode; + message: string; +} + +export interface GitHubAppSharedStateEvidenceAssessment { + ready: boolean; + issues: GitHubAppSharedStateEvidenceIssue[]; + passedScenarioIds: string[]; + missingScenarioIds: string[]; +} + +interface ParsedConformanceReport { + backendId: string; + implementationVersion: string; + passedScenarioIds: string[]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function exactKeys(record: Record, allowed: readonly string[]): boolean { + const allowedSet = new Set(allowed); + const keys = Object.keys(record); + return keys.length === allowed.length && keys.every((key) => allowedSet.has(key)); +} + +function validIdentifier(value: unknown): value is string { + return typeof value === "string" + && value.length > 0 + && value.length <= MAX_IDENTIFIER_LENGTH + && IDENTIFIER_PATTERN.test(value); +} + +function sameStringArray(value: unknown, expected: readonly string[]): boolean { + return Array.isArray(value) + && value.length === expected.length + && value.every((entry, index) => typeof entry === "string" && entry === expected[index]); +} + +function parseConformanceReport(value: unknown): ParsedConformanceReport | undefined { + if (!isRecord(value) || !exactKeys(value, [ + "schemaVersion", + "backendId", + "implementationVersion", + "complete", + "scenarioTimeoutMs", + "results", + "coverage", + ])) return undefined; + if (value.schemaVersion !== REPORT_SCHEMA_VERSION) return undefined; + if (!validIdentifier(value.backendId) || !validIdentifier(value.implementationVersion)) return undefined; + if (typeof value.complete !== "boolean") return undefined; + if (!Number.isSafeInteger(value.scenarioTimeoutMs) || (value.scenarioTimeoutMs as number) < 100 || (value.scenarioTimeoutMs as number) > 120_000) { + return undefined; + } + if (!Array.isArray(value.results) || value.results.length !== GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS.length) { + return undefined; + } + + const requiredIds = GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS.map((scenario) => scenario.id); + const requiredIdSet = new Set(requiredIds); + const seen = new Set(); + const passedScenarioIds: string[] = []; + for (const result of value.results) { + if (!isRecord(result) || !exactKeys(result, ["id", "status", "durationMs"])) return undefined; + if (typeof result.id !== "string" || !requiredIdSet.has(result.id) || seen.has(result.id)) return undefined; + if (typeof result.status !== "string" || !RESULT_STATUSES.has(result.status)) return undefined; + if (!Number.isSafeInteger(result.durationMs) || (result.durationMs as number) < 0) return undefined; + seen.add(result.id); + if (result.status === "passed") passedScenarioIds.push(result.id); + } + if (requiredIds.some((id) => !seen.has(id))) return undefined; + + const coverage = assessGitHubAppSharedStateConformanceCoverage(passedScenarioIds); + if (value.complete !== coverage.complete) return undefined; + if (!isRecord(value.coverage) || !exactKeys(value.coverage, [ + "complete", + "coveredScenarioIds", + "missingScenarioIds", + "missingCapabilities", + ])) return undefined; + if (value.coverage.complete !== coverage.complete) return undefined; + if (!sameStringArray(value.coverage.coveredScenarioIds, coverage.coveredScenarioIds)) return undefined; + if (!sameStringArray(value.coverage.missingScenarioIds, coverage.missingScenarioIds)) return undefined; + if (!sameStringArray(value.coverage.missingCapabilities, coverage.missingCapabilities)) return undefined; + + return { + backendId: value.backendId, + implementationVersion: value.implementationVersion, + passedScenarioIds: coverage.coveredScenarioIds, + }; +} + +/** + * Validate that a portable conformance artifact is structurally sound, complete, and bound to the + * same concrete adapter revision as a valid versioned backend contract. + * + * This gate intentionally ignores backend-provided error text and does not certify a database. It is + * suitable for deployment/provisioning policy that must reject detached, stale, or tampered evidence. + */ +export function assessGitHubAppSharedStateConformanceEvidence( + backendContract: unknown, + conformanceReport: unknown, +): GitHubAppSharedStateEvidenceAssessment { + const issues: GitHubAppSharedStateEvidenceIssue[] = []; + const contractAssessment = assessGitHubAppSharedStateBackendContract(backendContract); + if (!contractAssessment.ready) { + issues.push({ + code: "invalid-backend-contract", + message: "Shared-state backend contract is not ready for conformance evidence binding.", + }); + } + + const report = parseConformanceReport(conformanceReport); + if (!report) { + issues.push({ + code: "invalid-conformance-report", + message: "Shared-state conformance report has an invalid, incomplete, or unsupported shape.", + }); + return { + ready: false, + issues, + passedScenarioIds: [], + missingScenarioIds: GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS.map((scenario) => scenario.id), + }; + } + + const coverage = assessGitHubAppSharedStateConformanceCoverage(report.passedScenarioIds); + if (!coverage.complete) { + issues.push({ + code: "incomplete-conformance", + message: "Shared-state conformance report does not pass every required adversarial scenario.", + }); + } + + if (contractAssessment.ready) { + const contract = backendContract as GitHubAppSharedStateBackendContract; + if (contract.contractVersion !== GITHUB_APP_SHARED_STATE_CONTRACT_VERSION) { + issues.push({ + code: "invalid-backend-contract", + message: "Shared-state backend contract version is unsupported.", + }); + } else { + if (contract.backendId !== report.backendId) { + issues.push({ + code: "backend-id-mismatch", + message: "Shared-state conformance report is bound to a different backend id.", + }); + } + if (contract.implementationVersion !== report.implementationVersion) { + issues.push({ + code: "implementation-version-mismatch", + message: "Shared-state conformance report is bound to a different implementation version.", + }); + } + } + } + + return { + ready: issues.length === 0, + issues, + passedScenarioIds: coverage.coveredScenarioIds, + missingScenarioIds: coverage.missingScenarioIds, + }; +} diff --git a/packages/github/src/workspace-ownership.ts b/packages/github/src/workspace-ownership.ts new file mode 100644 index 00000000..5a9aef31 --- /dev/null +++ b/packages/github/src/workspace-ownership.ts @@ -0,0 +1,174 @@ +import { randomBytes } from "node:crypto"; +import { lstat, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { basename, join, resolve } from "node:path"; + +const MARKER_FILE = ".synsec-workspace.json"; +const WORKSPACE_PREFIX = "synsec-github-"; +const MARKER_VERSION = 1; +const MIN_RETENTION_MS = 60 * 60 * 1000; +const MAX_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; +const DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000; +const DEFAULT_MAX_DELETES = 32; +const MAX_DELETES = 256; +const MAX_MARKER_BYTES = 4096; + +export interface GitHubWorkspaceOwnershipMarker { + version: 1; + workspaceId: string; + createdAt: string; +} + +export interface GitHubWorkspaceReconciliationOptions { + retentionMs?: number; + maxDeletes?: number; + deleteOwned?: boolean; + now?: () => number; +} + +export interface GitHubWorkspaceReconciliationResult { + inspected: number; + owned: number; + stale: number; + deleted: number; + skipped: number; +} + +function boundedRetention(value: number | undefined): number { + const retentionMs = value ?? DEFAULT_RETENTION_MS; + if (!Number.isSafeInteger(retentionMs) || retentionMs < MIN_RETENTION_MS || retentionMs > MAX_RETENTION_MS) { + throw new Error(`GitHub workspace retention must be between ${MIN_RETENTION_MS} and ${MAX_RETENTION_MS} milliseconds.`); + } + return retentionMs; +} + +function boundedMaxDeletes(value: number | undefined): number { + const maxDeletes = value ?? DEFAULT_MAX_DELETES; + if (!Number.isSafeInteger(maxDeletes) || maxDeletes < 1 || maxDeletes > MAX_DELETES) { + throw new Error(`GitHub workspace reconciliation maxDeletes must be between 1 and ${MAX_DELETES}.`); + } + return maxDeletes; +} + +function parseMarker(raw: string): GitHubWorkspaceOwnershipMarker { + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + throw new Error("GitHub workspace ownership marker is not valid JSON."); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("GitHub workspace ownership marker has an invalid shape."); + } + const record = value as Record; + const keys = Object.keys(record).sort(); + if (keys.join(",") !== "createdAt,version,workspaceId") { + throw new Error("GitHub workspace ownership marker contains unsupported fields."); + } + if (record.version !== MARKER_VERSION) throw new Error("GitHub workspace ownership marker version is unsupported."); + if (typeof record.workspaceId !== "string" || !/^[a-f0-9]{32}$/.test(record.workspaceId)) { + throw new Error("GitHub workspace ownership marker id is invalid."); + } + if (typeof record.createdAt !== "string") throw new Error("GitHub workspace ownership marker timestamp is invalid."); + const createdAt = Date.parse(record.createdAt); + if (!Number.isFinite(createdAt)) throw new Error("GitHub workspace ownership marker timestamp is invalid."); + return { version: 1, workspaceId: record.workspaceId, createdAt: record.createdAt }; +} + +export async function markGitHubWorkspaceOwned(workspace: string, now: () => number = Date.now): Promise { + const created = now(); + if (!Number.isFinite(created) || created <= 0) throw new Error("GitHub workspace ownership clock must be a positive timestamp."); + const marker: GitHubWorkspaceOwnershipMarker = { + version: 1, + workspaceId: randomBytes(16).toString("hex"), + createdAt: new Date(created).toISOString(), + }; + await writeFile(join(resolve(workspace), MARKER_FILE), `${JSON.stringify(marker)}\n`, { + encoding: "utf8", + mode: 0o600, + flag: "wx", + }); + return marker; +} + +async function readOwnedMarker(workspace: string): Promise { + const markerPath = join(workspace, MARKER_FILE); + let metadata; + try { + metadata = await lstat(markerPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size <= 0 || metadata.size > MAX_MARKER_BYTES) return undefined; + const raw = await readFile(markerPath, "utf8"); + return parseMarker(raw); +} + +/** + * Inspect or remove stale SynSec-owned acquisition workspaces under one configured root. + * + * Observation is the default. Deletion requires `deleteOwned:true`, a valid restrictive ownership + * marker, the expected generated directory prefix, an age beyond the bounded retention window, and + * a bounded deletion batch. Unrelated directories, symlinks, missing markers, and malformed marker + * shapes are never removed. A malformed marker is counted as skipped rather than interpreted as + * evidence of ownership. + */ +export async function reconcileGitHubOwnedWorkspaces( + workspaceRoot: string, + options: GitHubWorkspaceReconciliationOptions = {}, +): Promise { + const root = resolve(workspaceRoot); + const retentionMs = boundedRetention(options.retentionMs); + const maxDeletes = boundedMaxDeletes(options.maxDeletes); + const now = options.now ?? Date.now; + const currentTime = now(); + if (!Number.isFinite(currentTime) || currentTime <= 0) throw new Error("GitHub workspace reconciliation clock must be a positive timestamp."); + + const result: GitHubWorkspaceReconciliationResult = { inspected: 0, owned: 0, stale: 0, deleted: 0, skipped: 0 }; + const entries = await readdir(root, { withFileTypes: true }); + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (!entry.name.startsWith(WORKSPACE_PREFIX)) continue; + result.inspected += 1; + if (!entry.isDirectory() || entry.isSymbolicLink()) { + result.skipped += 1; + continue; + } + const workspace = join(root, entry.name); + if (basename(workspace) !== entry.name) { + result.skipped += 1; + continue; + } + let marker: GitHubWorkspaceOwnershipMarker | undefined; + try { + marker = await readOwnedMarker(workspace); + } catch { + result.skipped += 1; + continue; + } + if (!marker) { + result.skipped += 1; + continue; + } + result.owned += 1; + const age = currentTime - Date.parse(marker.createdAt); + if (!Number.isFinite(age) || age < retentionMs) continue; + result.stale += 1; + if (!options.deleteOwned || result.deleted >= maxDeletes) continue; + + // Re-read immediately before deletion so replacement/tampering after discovery fails closed. + let current: GitHubWorkspaceOwnershipMarker | undefined; + try { + current = await readOwnedMarker(workspace); + } catch { + result.skipped += 1; + continue; + } + if (!current || current.workspaceId !== marker.workspaceId || current.createdAt !== marker.createdAt) { + result.skipped += 1; + continue; + } + await rm(workspace, { recursive: true, force: false }); + result.deleted += 1; + } + return result; +} diff --git a/packages/github/tsconfig.json b/packages/github/tsconfig.json new file mode 100644 index 00000000..1e7ad954 --- /dev/null +++ b/packages/github/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [ + { "path": "../config" }, + { "path": "../core" }, + { "path": "../report" }, + { "path": "../scanner-sdk" }, + { "path": "../scanners" }, + { "path": "../engine" }, + { "path": "../workflows" } + ], + "include": ["src/**/*.ts"] +} diff --git a/packages/lifecycle/package.json b/packages/lifecycle/package.json new file mode 100644 index 00000000..828a6c19 --- /dev/null +++ b/packages/lifecycle/package.json @@ -0,0 +1,22 @@ +{ + "name": "@synsec/lifecycle", + "version": "0.2.0", + "private": true, + "type": "module", + "exports": { + ".": "./dist/index.js", + "./review-comments": "./dist/review-comments.js", + "./review-deadlines": "./dist/review-deadlines.js", + "./review-policy": "./dist/review-policy.js", + "./triage-view": "./dist/triage-view.js", + "./triage-html": "./dist/triage-html.js" + }, + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@synsec/report": "0.2.0" + } +} diff --git a/packages/lifecycle/src/index.ts b/packages/lifecycle/src/index.ts new file mode 100644 index 00000000..6e722572 --- /dev/null +++ b/packages/lifecycle/src/index.ts @@ -0,0 +1,464 @@ +import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import type { CorrelatedFinding } from "@synsec/core"; +import type { SynSecReport } from "@synsec/report"; + +export type FindingState = + | "new" + | "confirmed" + | "false-positive" + | "accepted-risk" + | "fixed" + | "regressed"; + +export interface FindingLifecycleRecord { + fingerprint: string; + state: FindingState; + updatedAt: string; + note?: string; + /** Optional human/team assignment metadata. This is triage state, not scanner evidence. */ + owner?: string; + /** Optional human re-review deadline. This is governance metadata, not scanner evidence. */ + reviewAt?: string; + reportId?: string; + /** Last source path observed for scope-aware incremental reconciliation. */ + lastSeenPath?: string; +} + +export interface FindingLifecycleStore { + schemaVersion: 1; + records: Record; +} + +export interface LifecycleSummary { + new: number; + confirmed: number; + falsePositive: number; + acceptedRisk: number; + fixed: number; + regressed: number; +} + +export type VerificationStatus = "fixed" | "persisting" | "inconclusive" | "missing-baseline"; + +export interface FindingVerification { + fingerprint: string; + title?: string; + status: VerificationStatus; + reasons: string[]; +} + +export interface RemediationVerification { + schemaVersion: 1; + generatedAt: string; + beforeReportId: string; + afterReportId: string; + items: FindingVerification[]; + newFindings: string[]; + summary: { + fixed: number; + persisting: number; + inconclusive: number; + missingBaseline: number; + newFindings: number; + }; +} + +const MAX_LIFECYCLE_BYTES = 16 * 1024 * 1024; +const MAX_LIFECYCLE_RECORDS = 100_000; +const MAX_FINGERPRINT_LENGTH = 512; +const MAX_NOTE_LENGTH = 10_000; +const MAX_OWNER_LENGTH = 255; +const MAX_REPORT_ID_LENGTH = 512; +const MAX_PATH_LENGTH = 4096; + +export function emptyLifecycleStore(): FindingLifecycleStore { + return { schemaVersion: 1, records: {} }; +} + +export function isFindingState(value: unknown): value is FindingState { + return value === "new" || value === "confirmed" || value === "false-positive" || value === "accepted-risk" || value === "fixed" || value === "regressed"; +} + +function boundedString(value: unknown, maxLength: number, required = false): value is string { + if (typeof value !== "string") return false; + const trimmed = value.trim(); + if (required && !trimmed) return false; + return value.length <= maxLength; +} + +function validOwner(value: unknown): value is string { + return boundedString(value, MAX_OWNER_LENGTH, true) && !/[\r\n\0]/.test(value); +} + +function validTimestamp(value: unknown): value is string { + return boundedString(value, 128, true) && Number.isFinite(Date.parse(value)); +} + +function normalizedReviewAt(value: string | null | undefined, previous?: string): string | undefined { + if (value === undefined) return previous; + if (value === null || !value.trim()) return undefined; + const normalized = value.trim(); + if (!validTimestamp(normalized)) throw new Error("Finding review deadline must be a valid timestamp."); + return normalized; +} + +function isLifecycleRecord(value: unknown, key: string): value is FindingLifecycleRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const record = value as Record; + if (!boundedString(record.fingerprint, MAX_FINGERPRINT_LENGTH, true) || record.fingerprint !== key) return false; + if (!isFindingState(record.state) || !validTimestamp(record.updatedAt)) return false; + if (record.note !== undefined && !boundedString(record.note, MAX_NOTE_LENGTH)) return false; + if (record.owner !== undefined && !validOwner(record.owner)) return false; + if (record.reviewAt !== undefined && !validTimestamp(record.reviewAt)) return false; + if (record.reportId !== undefined && !boundedString(record.reportId, MAX_REPORT_ID_LENGTH, true)) return false; + if (record.lastSeenPath !== undefined && !boundedString(record.lastSeenPath, MAX_PATH_LENGTH, true)) return false; + return true; +} + +export function isLifecycleStore(value: unknown): value is FindingLifecycleStore { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const record = value as Record; + if (record.schemaVersion !== 1 || typeof record.records !== "object" || record.records === null || Array.isArray(record.records)) return false; + const entries = Object.entries(record.records as Record); + if (entries.length > MAX_LIFECYCLE_RECORDS) return false; + return entries.every(([key, item]) => boundedString(key, MAX_FINGERPRINT_LENGTH, true) && isLifecycleRecord(item, key)); +} + +export async function readLifecycleStore(path: string): Promise { + try { + const metadata = await stat(path); + if (!metadata.isFile()) throw new Error(`SynSec lifecycle store is not a file: ${path}`); + if (metadata.size > MAX_LIFECYCLE_BYTES) { + throw new Error(`SynSec lifecycle store exceeds the ${MAX_LIFECYCLE_BYTES}-byte limit: ${path}`); + } + const parsed = JSON.parse(await readFile(path, "utf8")) as unknown; + if (!isLifecycleStore(parsed)) throw new Error(`Not a supported SynSec lifecycle store: ${path}`); + return parsed; + } catch (error) { + const code = typeof error === "object" && error !== null && "code" in error + ? String((error as { code?: unknown }).code) + : ""; + if (code === "ENOENT") return emptyLifecycleStore(); + throw error; + } +} + +export async function writeLifecycleStore(path: string, store: FindingLifecycleStore): Promise { + if (!isLifecycleStore(store)) throw new Error("Refusing to write an invalid SynSec lifecycle store."); + const directory = dirname(path); + await mkdir(directory, { recursive: true }); + const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`; + const serialized = `${JSON.stringify(store, null, 2)}\n`; + if (Buffer.byteLength(serialized) > MAX_LIFECYCLE_BYTES) { + throw new Error(`SynSec lifecycle store exceeds the ${MAX_LIFECYCLE_BYTES}-byte limit.`); + } + + try { + await writeFile(temporaryPath, serialized, { encoding: "utf8", mode: 0o600, flag: "wx" }); + await chmod(temporaryPath, 0o600).catch(() => undefined); + await rename(temporaryPath, path); + await chmod(path, 0o600).catch(() => undefined); + } finally { + await rm(temporaryPath, { force: true }).catch(() => undefined); + } +} + +export function setFindingState( + store: FindingLifecycleStore, + fingerprint: string, + state: FindingState, + options: { note?: string; reportId?: string; updatedAt?: string; reviewAt?: string | null } = {}, +): FindingLifecycleStore { + if (!fingerprint.trim()) throw new Error("Finding fingerprint cannot be empty."); + const updatedAt = options.updatedAt ?? new Date().toISOString(); + if (!validTimestamp(updatedAt)) throw new Error("Finding lifecycle timestamp must be a valid timestamp."); + const updated: FindingLifecycleStore = { + schemaVersion: 1, + records: { ...store.records }, + }; + const previous = store.records[fingerprint]; + const record: FindingLifecycleRecord = { + fingerprint, + state, + updatedAt, + }; + const note = options.note?.trim() || previous?.note; + if (note) record.note = note; + if (previous?.owner) record.owner = previous.owner; + const reviewAt = normalizedReviewAt(options.reviewAt, previous?.reviewAt); + if (reviewAt) record.reviewAt = reviewAt; + const reportId = options.reportId ?? previous?.reportId; + if (reportId) record.reportId = reportId; + if (previous?.lastSeenPath) record.lastSeenPath = previous.lastSeenPath; + updated.records[fingerprint] = record; + return updated; +} + +/** + * Assign or clear human ownership without changing scanner-derived finding state. + * The owner is bounded triage metadata only; control characters are rejected so the value is safe + * for deterministic text/JSON presentation. Pass undefined/null/blank to clear an assignment. + */ +export function setFindingOwner( + store: FindingLifecycleStore, + fingerprint: string, + owner?: string | null, + updatedAt = new Date().toISOString(), +): FindingLifecycleStore { + const key = fingerprint.trim(); + if (!key) throw new Error("Finding fingerprint cannot be empty."); + const previous = store.records[key]; + if (!previous) throw new Error(`Finding lifecycle record does not exist: ${key}`); + if (!validTimestamp(updatedAt)) throw new Error("Finding ownership timestamp must be a valid timestamp."); + + const normalizedOwner = owner?.trim() || undefined; + if (normalizedOwner !== undefined && !validOwner(normalizedOwner)) { + throw new Error(`Finding owner must be at most ${MAX_OWNER_LENGTH} characters and contain no control line breaks.`); + } + if (previous.owner === normalizedOwner) return store; + + const nextRecord: FindingLifecycleRecord = { + ...previous, + updatedAt, + }; + if (normalizedOwner) nextRecord.owner = normalizedOwner; + else delete nextRecord.owner; + return { + schemaVersion: 1, + records: { + ...store.records, + [key]: nextRecord, + }, + }; +} + +/** Set or clear a human re-review deadline without changing finding state or scanner evidence. */ +export function setFindingReviewAt( + store: FindingLifecycleStore, + fingerprint: string, + reviewAt?: string | null, + updatedAt = new Date().toISOString(), +): FindingLifecycleStore { + const key = fingerprint.trim(); + if (!key) throw new Error("Finding fingerprint cannot be empty."); + const previous = store.records[key]; + if (!previous) throw new Error(`Finding lifecycle record does not exist: ${key}`); + if (!validTimestamp(updatedAt)) throw new Error("Finding review metadata timestamp must be a valid timestamp."); + const normalized = normalizedReviewAt(reviewAt); + if (previous.reviewAt === normalized) return store; + + const nextRecord: FindingLifecycleRecord = { ...previous, updatedAt }; + if (normalized) nextRecord.reviewAt = normalized; + else delete nextRecord.reviewAt; + return { + schemaVersion: 1, + records: { + ...store.records, + [key]: nextRecord, + }, + }; +} + +function autoTransition(previous: FindingLifecycleRecord | undefined, present: boolean): FindingState | undefined { + if (present) { + if (!previous) return "new"; + if (previous.state === "fixed") return "regressed"; + return previous.state; + } + + if (!previous) return undefined; + if (previous.state === "new" || previous.state === "confirmed" || previous.state === "regressed") return "fixed"; + return previous.state; +} + +function normalizePath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, "").replace(/^\//, "").toLowerCase(); +} + +function reportCanConcludeAbsence(report: SynSecReport, previous: FindingLifecycleRecord): boolean { + if (report.scope?.mode === "repository") return true; + if (report.scope?.mode !== "changed-files" || !previous.lastSeenPath) return false; + const changed = new Set((report.scope.changedFiles ?? []).map(normalizePath)); + return changed.has(normalizePath(previous.lastSeenPath)); +} + +export function reconcileLifecycle( + report: SynSecReport, + previous: FindingLifecycleStore, + updatedAt = new Date().toISOString(), +): FindingLifecycleStore { + const currentByFingerprint = new Map(report.findings.map((finding) => [finding.fingerprint, finding])); + const all = new Set([...Object.keys(previous.records), ...currentByFingerprint.keys()]); + const next: FindingLifecycleStore = { schemaVersion: 1, records: {} }; + + for (const fingerprint of all) { + const prior = previous.records[fingerprint]; + const current = currentByFingerprint.get(fingerprint); + const present = Boolean(current); + const absenceCovered = prior ? reportCanConcludeAbsence(report, prior) : false; + const state = present + ? autoTransition(prior, true) + : absenceCovered + ? autoTransition(prior, false) + : prior?.state; + if (!state) continue; + + const stateChanged = prior?.state !== state; + const record: FindingLifecycleRecord = { + fingerprint, + state, + updatedAt: stateChanged ? updatedAt : (prior?.updatedAt ?? updatedAt), + }; + + if (present || absenceCovered) record.reportId = report.reportId; + else if (prior?.reportId) record.reportId = prior.reportId; + if (prior?.note) record.note = prior.note; + if (prior?.owner) record.owner = prior.owner; + if (prior?.reviewAt) record.reviewAt = prior.reviewAt; + const lastSeenPath = current?.primary.location?.path ?? prior?.lastSeenPath; + if (lastSeenPath) record.lastSeenPath = lastSeenPath; + next.records[fingerprint] = record; + } + + return next; +} + +export function lifecycleSummary(store: FindingLifecycleStore): LifecycleSummary { + const summary: LifecycleSummary = { + new: 0, + confirmed: 0, + falsePositive: 0, + acceptedRisk: 0, + fixed: 0, + regressed: 0, + }; + for (const record of Object.values(store.records)) { + if (record.state === "new") summary.new += 1; + else if (record.state === "confirmed") summary.confirmed += 1; + else if (record.state === "false-positive") summary.falsePositive += 1; + else if (record.state === "accepted-risk") summary.acceptedRisk += 1; + else if (record.state === "fixed") summary.fixed += 1; + else if (record.state === "regressed") summary.regressed += 1; + } + return summary; +} + +export function currentLifecycleRecords( + report: SynSecReport, + store: FindingLifecycleStore, +): FindingLifecycleRecord[] { + const current = new Set(report.findings.map((finding) => finding.fingerprint)); + return Object.values(store.records) + .filter((record) => current.has(record.fingerprint)) + .sort((a, b) => a.fingerprint.localeCompare(b.fingerprint)); +} + +function afterScopeCoversFinding(after: SynSecReport, finding: CorrelatedFinding): { covered: boolean; reason?: string } { + if (after.scope?.mode === "repository") return { covered: true }; + if (after.scope?.mode !== "changed-files") { + return { covered: false, reason: "The after report has no repository-wide or changed-file scan scope metadata." }; + } + const path = finding.primary.location?.path; + if (!path) { + return { covered: false, reason: "The finding has no source path, so a changed-file scan cannot prove it was rechecked." }; + } + const changed = new Set((after.scope.changedFiles ?? []).map(normalizePath)); + if (!changed.has(normalizePath(path))) { + return { covered: false, reason: `The after report did not scan the finding path ${path} in its changed-file scope.` }; + } + return { covered: true }; +} + +function afterReranDetectingScanner(after: SynSecReport, finding: CorrelatedFinding): { covered: boolean; reason?: string } { + const afterScanners = new Set(after.scanners.map((scanner) => scanner.scanner.toLowerCase())); + const detecting = [...new Set(finding.sources.map((source) => source.name.toLowerCase()))]; + if (detecting.some((name) => afterScanners.has(name))) return { covered: true }; + return { + covered: false, + reason: `None of the scanner(s) that detected the finding were present in the after report: ${detecting.join(", ") || "unknown"}.`, + }; +} + +export function verifyRemediation( + before: SynSecReport, + after: SynSecReport, + requestedFingerprints?: readonly string[], + generatedAt = new Date().toISOString(), +): RemediationVerification { + const beforeByFingerprint = new Map(before.findings.map((finding) => [finding.fingerprint, finding])); + const afterByFingerprint = new Map(after.findings.map((finding) => [finding.fingerprint, finding])); + const targets = requestedFingerprints && requestedFingerprints.length > 0 + ? [...new Set(requestedFingerprints)] + : before.findings.map((finding) => finding.fingerprint); + + const items: FindingVerification[] = targets.map((fingerprint) => { + const baseline = beforeByFingerprint.get(fingerprint); + if (!baseline) { + return { + fingerprint, + status: "missing-baseline" as const, + reasons: ["The requested fingerprint is not present in the before report."], + }; + } + + const persisting = afterByFingerprint.get(fingerprint); + if (persisting) { + return { + fingerprint, + title: baseline.primary.title, + status: "persisting" as const, + reasons: ["The same normalized finding fingerprint is still present after remediation."], + }; + } + + const scope = afterScopeCoversFinding(after, baseline); + const scanner = afterReranDetectingScanner(after, baseline); + const reasons = [scope.reason, scanner.reason].filter((reason): reason is string => Boolean(reason)); + return { + fingerprint, + title: baseline.primary.title, + status: scope.covered && scanner.covered ? "fixed" as const : "inconclusive" as const, + reasons: scope.covered && scanner.covered + ? ["The finding disappeared after its source path and at least one detecting scanner were rechecked."] + : reasons, + }; + }); + + const beforeFingerprints = new Set(before.findings.map((finding) => finding.fingerprint)); + const newFindings = after.findings + .map((finding) => finding.fingerprint) + .filter((fingerprint) => !beforeFingerprints.has(fingerprint)) + .sort(); + + return { + schemaVersion: 1, + generatedAt, + beforeReportId: before.reportId, + afterReportId: after.reportId, + items, + newFindings, + summary: { + fixed: items.filter((item) => item.status === "fixed").length, + persisting: items.filter((item) => item.status === "persisting").length, + inconclusive: items.filter((item) => item.status === "inconclusive").length, + missingBaseline: items.filter((item) => item.status === "missing-baseline").length, + newFindings: newFindings.length, + }, + }; +} + +export async function writeRemediationVerification(path: string, verification: RemediationVerification): Promise { + const directory = dirname(path); + await mkdir(directory, { recursive: true }); + const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`; + const serialized = `${JSON.stringify(verification, null, 2)}\n`; + try { + await writeFile(temporaryPath, serialized, { encoding: "utf8", mode: 0o600, flag: "wx" }); + await chmod(temporaryPath, 0o600).catch(() => undefined); + await rename(temporaryPath, path); + await chmod(path, 0o600).catch(() => undefined); + } finally { + await rm(temporaryPath, { force: true }).catch(() => undefined); + } +} diff --git a/packages/lifecycle/src/review-comments.ts b/packages/lifecycle/src/review-comments.ts new file mode 100644 index 00000000..9086a23d --- /dev/null +++ b/packages/lifecycle/src/review-comments.ts @@ -0,0 +1,200 @@ +import { createHash } from "node:crypto"; +import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +export interface FindingReviewComment { + id: string; + fingerprint: string; + body: string; + createdAt: string; + author?: string; +} + +export interface FindingReviewCommentStore { + schemaVersion: 1; + comments: Record; +} + +const MAX_STORE_BYTES = 8 * 1024 * 1024; +const MAX_TOTAL_COMMENTS = 100_000; +const MAX_COMMENTS_PER_FINDING = 100; +const MAX_FINGERPRINT_LENGTH = 512; +const MAX_COMMENT_ID_LENGTH = 128; +const MAX_AUTHOR_LENGTH = 255; +const MAX_BODY_LENGTH = 10_000; + +function boundedText(value: unknown, maxLength: number, required = false): value is string { + if (typeof value !== "string" || value.length > maxLength || /\0/.test(value)) return false; + if (required && !value.trim()) return false; + return true; +} + +function validTimestamp(value: unknown): value is string { + return boundedText(value, 128, true) && Number.isFinite(Date.parse(value)); +} + +function exactKeys(value: Record, allowed: readonly string[]): boolean { + const keys = Object.keys(value).sort(); + const expected = [...allowed].sort(); + return keys.length === expected.length && keys.every((key, index) => key === expected[index]); +} + +function isComment(value: unknown, fingerprint: string): value is FindingReviewComment { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const record = value as Record; + const allowed = record.author === undefined + ? ["id", "fingerprint", "body", "createdAt"] + : ["id", "fingerprint", "body", "createdAt", "author"]; + if (!exactKeys(record, allowed)) return false; + return boundedText(record.id, MAX_COMMENT_ID_LENGTH, true) + && boundedText(record.fingerprint, MAX_FINGERPRINT_LENGTH, true) + && record.fingerprint === fingerprint + && boundedText(record.body, MAX_BODY_LENGTH, true) + && validTimestamp(record.createdAt) + && (record.author === undefined || boundedText(record.author, MAX_AUTHOR_LENGTH, true)); +} + +export function emptyFindingReviewCommentStore(): FindingReviewCommentStore { + return { schemaVersion: 1, comments: {} }; +} + +export function isFindingReviewCommentStore(value: unknown): value is FindingReviewCommentStore { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const root = value as Record; + if (!exactKeys(root, ["schemaVersion", "comments"]) || root.schemaVersion !== 1) return false; + if (typeof root.comments !== "object" || root.comments === null || Array.isArray(root.comments)) return false; + + let total = 0; + for (const [fingerprint, comments] of Object.entries(root.comments as Record)) { + if (!boundedText(fingerprint, MAX_FINGERPRINT_LENGTH, true) || !Array.isArray(comments)) return false; + if (comments.length === 0 || comments.length > MAX_COMMENTS_PER_FINDING) return false; + total += comments.length; + if (total > MAX_TOTAL_COMMENTS) return false; + if (!comments.every((comment) => isComment(comment, fingerprint))) return false; + const ids = new Set(comments.map((comment) => (comment as FindingReviewComment).id)); + if (ids.size !== comments.length) return false; + } + return true; +} + +export async function readFindingReviewCommentStore(path: string): Promise { + try { + const metadata = await stat(path); + if (!metadata.isFile()) throw new Error(`SynSec finding review comment store is not a file: ${path}`); + if (metadata.size > MAX_STORE_BYTES) { + throw new Error(`SynSec finding review comment store exceeds the ${MAX_STORE_BYTES}-byte limit: ${path}`); + } + const parsed = JSON.parse(await readFile(path, "utf8")) as unknown; + if (!isFindingReviewCommentStore(parsed)) { + throw new Error(`Not a supported SynSec finding review comment store: ${path}`); + } + return parsed; + } catch (error) { + const code = typeof error === "object" && error !== null && "code" in error + ? String((error as { code?: unknown }).code) + : ""; + if (code === "ENOENT") return emptyFindingReviewCommentStore(); + throw error; + } +} + +export async function writeFindingReviewCommentStore( + path: string, + store: FindingReviewCommentStore, +): Promise { + if (!isFindingReviewCommentStore(store)) { + throw new Error("Refusing to write an invalid SynSec finding review comment store."); + } + const serialized = `${JSON.stringify(store, null, 2)}\n`; + if (Buffer.byteLength(serialized) > MAX_STORE_BYTES) { + throw new Error(`SynSec finding review comment store exceeds the ${MAX_STORE_BYTES}-byte limit.`); + } + + const directory = dirname(path); + await mkdir(directory, { recursive: true }); + const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`; + try { + await writeFile(temporaryPath, serialized, { encoding: "utf8", mode: 0o600, flag: "wx" }); + await chmod(temporaryPath, 0o600).catch(() => undefined); + await rename(temporaryPath, path); + await chmod(path, 0o600).catch(() => undefined); + } finally { + await rm(temporaryPath, { force: true }).catch(() => undefined); + } +} + +function commentId(fingerprint: string, body: string, author: string | undefined, createdAt: string): string { + return createHash("sha256") + .update(fingerprint) + .update("\0") + .update(createdAt) + .update("\0") + .update(author ?? "") + .update("\0") + .update(body) + .digest("hex"); +} + +/** + * Append bounded human triage commentary without modifying finding state or scanner evidence. + * + * Comment text is operator-supplied metadata only. SynSec does not automatically copy source + * excerpts, scanner diagnostics, tokens, or repository credentials into this store. The API is + * append-only so prior review context cannot be silently rewritten by a later scan. + */ +export function addFindingReviewComment( + store: FindingReviewCommentStore, + fingerprint: string, + body: string, + options: { author?: string; createdAt?: string } = {}, +): FindingReviewCommentStore { + const normalizedFingerprint = fingerprint.trim(); + const normalizedBody = body.trim(); + const author = options.author?.trim() || undefined; + const createdAt = options.createdAt ?? new Date().toISOString(); + + if (!boundedText(normalizedFingerprint, MAX_FINGERPRINT_LENGTH, true)) { + throw new Error(`Finding fingerprint must be at most ${MAX_FINGERPRINT_LENGTH} characters.`); + } + if (!boundedText(normalizedBody, MAX_BODY_LENGTH, true)) { + throw new Error(`Finding review comment must be between 1 and ${MAX_BODY_LENGTH} characters and contain no NUL bytes.`); + } + if (author !== undefined && !boundedText(author, MAX_AUTHOR_LENGTH, true)) { + throw new Error(`Finding review comment author must be at most ${MAX_AUTHOR_LENGTH} characters.`); + } + if (!validTimestamp(createdAt)) throw new Error("Finding review comment timestamp must be valid."); + + const existing = store.comments[normalizedFingerprint] ?? []; + if (existing.length >= MAX_COMMENTS_PER_FINDING) { + throw new Error(`Finding review comments are limited to ${MAX_COMMENTS_PER_FINDING} entries per finding.`); + } + const total = Object.values(store.comments).reduce((count, comments) => count + comments.length, 0); + if (total >= MAX_TOTAL_COMMENTS) { + throw new Error(`Finding review comment store is limited to ${MAX_TOTAL_COMMENTS} total comments.`); + } + + const comment: FindingReviewComment = { + id: commentId(normalizedFingerprint, normalizedBody, author, createdAt), + fingerprint: normalizedFingerprint, + body: normalizedBody, + createdAt, + ...(author ? { author } : {}), + }; + if (existing.some((item) => item.id === comment.id)) return store; + + return { + schemaVersion: 1, + comments: { + ...store.comments, + [normalizedFingerprint]: [...existing, comment], + }, + }; +} + +export function commentsForFinding( + store: FindingReviewCommentStore, + fingerprint: string, +): readonly FindingReviewComment[] { + return [...(store.comments[fingerprint.trim()] ?? [])] + .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)); +} diff --git a/packages/lifecycle/src/review-deadlines.ts b/packages/lifecycle/src/review-deadlines.ts new file mode 100644 index 00000000..b375ab1e --- /dev/null +++ b/packages/lifecycle/src/review-deadlines.ts @@ -0,0 +1,101 @@ +import type { FindingLifecycleStore, FindingState } from "./index.js"; + +const DEFAULT_DUE_SOON_MS = 7 * 24 * 60 * 60 * 1000; +const MAX_DUE_SOON_MS = 365 * 24 * 60 * 60 * 1000; + +export type LifecycleReviewDeadlineStatus = "overdue" | "due-soon" | "scheduled"; + +export interface LifecycleReviewDeadlineItem { + fingerprint: string; + state: FindingState; + reviewAt: string; + status: LifecycleReviewDeadlineStatus; +} + +export interface LifecycleReviewDeadlineAssessment { + schemaVersion: 1; + generatedAt: string; + dueSoonWindowMs: number; + items: LifecycleReviewDeadlineItem[]; + summary: { + reviewable: number; + unscheduled: number; + overdue: number; + dueSoon: number; + scheduled: number; + }; +} + +function reviewableState(state: FindingState): boolean { + return state === "accepted-risk" || state === "false-positive"; +} + +/** + * Assess governance re-review deadlines for active exception decisions. + * + * Only accepted-risk and false-positive records are reviewable here. Scanner-derived states are not + * modified, no lifecycle records are written, and notes/owners/report ids/source paths are excluded + * from the returned artifact. Missing review deadlines are reported separately so an operator can + * distinguish an overdue exception from one that has never been scheduled for re-review. + */ +export function assessLifecycleReviewDeadlines( + store: FindingLifecycleStore, + options: { now?: string; dueSoonWindowMs?: number } = {}, +): LifecycleReviewDeadlineAssessment { + const nowText = options.now ?? new Date().toISOString(); + const now = Date.parse(nowText); + if (!Number.isFinite(now)) throw new Error("Lifecycle review assessment time must be a valid timestamp."); + + const dueSoonWindowMs = options.dueSoonWindowMs ?? DEFAULT_DUE_SOON_MS; + if (!Number.isSafeInteger(dueSoonWindowMs) || dueSoonWindowMs < 0 || dueSoonWindowMs > MAX_DUE_SOON_MS) { + throw new Error(`Lifecycle review due-soon window must be an integer between 0 and ${MAX_DUE_SOON_MS} milliseconds.`); + } + + let reviewable = 0; + let unscheduled = 0; + const items: LifecycleReviewDeadlineItem[] = []; + + for (const record of Object.values(store.records)) { + if (!reviewableState(record.state)) continue; + reviewable += 1; + if (!record.reviewAt) { + unscheduled += 1; + continue; + } + + const reviewAt = Date.parse(record.reviewAt); + if (!Number.isFinite(reviewAt)) { + throw new Error("Lifecycle review assessment encountered an invalid review deadline."); + } + const status: LifecycleReviewDeadlineStatus = reviewAt <= now + ? "overdue" + : reviewAt - now <= dueSoonWindowMs + ? "due-soon" + : "scheduled"; + items.push({ + fingerprint: record.fingerprint, + state: record.state, + reviewAt: record.reviewAt, + status, + }); + } + + items.sort((a, b) => { + const byTime = Date.parse(a.reviewAt) - Date.parse(b.reviewAt); + return byTime !== 0 ? byTime : a.fingerprint.localeCompare(b.fingerprint); + }); + + return { + schemaVersion: 1, + generatedAt: new Date(now).toISOString(), + dueSoonWindowMs, + items, + summary: { + reviewable, + unscheduled, + overdue: items.filter((item) => item.status === "overdue").length, + dueSoon: items.filter((item) => item.status === "due-soon").length, + scheduled: items.filter((item) => item.status === "scheduled").length, + }, + }; +} diff --git a/packages/lifecycle/src/review-policy.ts b/packages/lifecycle/src/review-policy.ts new file mode 100644 index 00000000..ca5b33b4 --- /dev/null +++ b/packages/lifecycle/src/review-policy.ts @@ -0,0 +1,64 @@ +import type { LifecycleReviewDeadlineAssessment } from "./review-deadlines.js"; + +export type LifecycleReviewPolicyViolation = "overdue" | "unscheduled"; + +export interface LifecycleReviewPolicy { + failOnOverdue?: boolean; + failOnUnscheduled?: boolean; +} + +export interface LifecycleReviewPolicyResult { + schemaVersion: 1; + generatedAt: string; + ready: boolean; + violations: LifecycleReviewPolicyViolation[]; + summary: { + reviewable: number; + overdue: number; + dueSoon: number; + scheduled: number; + unscheduled: number; + }; +} + +/** + * Evaluate exception-review governance without copying finding identifiers, source paths, owners, + * notes, report ids, or review timestamps into the policy artifact. + * + * This is a governance gate only. It never mutates lifecycle records or converts human exception + * decisions into scanner-derived finding states. + */ +export function evaluateLifecycleReviewPolicy( + assessment: LifecycleReviewDeadlineAssessment, + policy: LifecycleReviewPolicy = {}, +): LifecycleReviewPolicyResult { + if (assessment.schemaVersion !== 1) throw new Error("Unsupported lifecycle review assessment schema version."); + + const counts = assessment.summary; + for (const [name, value] of Object.entries(counts)) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`Lifecycle review assessment contains an invalid ${name} count.`); + } + } + if (counts.overdue + counts.dueSoon + counts.scheduled + counts.unscheduled !== counts.reviewable) { + throw new Error("Lifecycle review assessment summary is internally inconsistent."); + } + + const violations: LifecycleReviewPolicyViolation[] = []; + if (policy.failOnOverdue === true && counts.overdue > 0) violations.push("overdue"); + if (policy.failOnUnscheduled === true && counts.unscheduled > 0) violations.push("unscheduled"); + + return { + schemaVersion: 1, + generatedAt: assessment.generatedAt, + ready: violations.length === 0, + violations, + summary: { + reviewable: counts.reviewable, + overdue: counts.overdue, + dueSoon: counts.dueSoon, + scheduled: counts.scheduled, + unscheduled: counts.unscheduled, + }, + }; +} diff --git a/packages/lifecycle/src/triage-html.ts b/packages/lifecycle/src/triage-html.ts new file mode 100644 index 00000000..562450dd --- /dev/null +++ b/packages/lifecycle/src/triage-html.ts @@ -0,0 +1,73 @@ +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import type { FindingTriageView } from "./triage-view.js"; + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function stateLabel(value: string): string { + return value.replaceAll("-", " "); +} + +/** + * Render a self-contained local finding-triage dashboard from the sanitized triage-view model. + * The renderer has no access to repository source, scanner diagnostics, finding metadata, or tokens. + */ +export function renderFindingTriageHtml(view: FindingTriageView): string { + const rows = view.items.map((item) => { + const comments = item.comments.length === 0 + ? "No review comments" + : `
    ${item.comments.map((comment) => `
  1. ${escapeHtml(comment.body)}
    ${escapeHtml(comment.author ?? "unattributed")} · ${escapeHtml(comment.createdAt)}
  2. `).join("")}
`; + const review = item.reviewAt + ? `
${item.reviewStatus === "due" ? "Review overdue" : "Review by"}
${escapeHtml(item.reviewAt)}
` + : ""; + return `
+
${escapeHtml(item.title)}${escapeHtml(item.severity)}
+
+
State
${escapeHtml(stateLabel(item.state))}
+
Owner
${escapeHtml(item.owner ?? "unassigned")}
+
Updated
${escapeHtml(item.updatedAt)}
+ ${review} +
+ ${item.note ? `

${escapeHtml(item.note)}

` : ""} +
Review comments (${item.comments.length})${comments}
+
${escapeHtml(item.fingerprint)}
+
`; + }).join("\n"); + + return ` + + + + + +SynSec finding triage + + + +

SynSec finding triage

+

Human triage metadata only · report ${escapeHtml(view.reportId)}

+
+ ${view.summary.current} current + ${view.summary.assigned} assigned + ${view.summary.unassigned} unassigned + ${view.summary.commented} commented +
+${rows || "

No current lifecycle findings.

"} + +\n`; +} + +export async function writeFindingTriageHtml(path: string, view: FindingTriageView): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, renderFindingTriageHtml(view), { encoding: "utf8", mode: 0o600 }); + await chmod(path, 0o600).catch(() => undefined); +} diff --git a/packages/lifecycle/src/triage-view.ts b/packages/lifecycle/src/triage-view.ts new file mode 100644 index 00000000..5552c340 --- /dev/null +++ b/packages/lifecycle/src/triage-view.ts @@ -0,0 +1,96 @@ +import type { SynSecReport } from "@synsec/report"; +import type { FindingLifecycleStore, FindingState } from "./index.js"; +import { + commentsForFinding, + type FindingReviewComment, + type FindingReviewCommentStore, +} from "./review-comments.js"; + +export type FindingReviewDeadlineStatus = "scheduled" | "due"; + +export interface FindingTriageViewItem { + fingerprint: string; + title: string; + severity: string; + state: FindingState; + updatedAt: string; + owner?: string; + note?: string; + reviewAt?: string; + /** Derived human-governance presentation state; never scanner evidence or a lifecycle transition. */ + reviewStatus?: FindingReviewDeadlineStatus; + comments: FindingReviewComment[]; +} + +export interface FindingTriageView { + schemaVersion: 1; + reportId: string; + items: FindingTriageViewItem[]; + summary: { + current: number; + assigned: number; + unassigned: number; + commented: number; + }; + /** Triage view intentionally excludes source excerpts and scanner evidence. */ + interpretation: "triage-metadata-not-scanner-evidence"; +} + +function reviewDeadlineStatus(reviewAt: string | undefined, now: number): FindingReviewDeadlineStatus | undefined { + if (!reviewAt) return undefined; + return Date.parse(reviewAt) <= now ? "due" : "scheduled"; +} + +/** + * Compose current finding lifecycle, ownership, re-review deadlines, and human review comments for UI/API presentation. + * + * Only findings present in the supplied report are returned. The view carries title/severity for + * orientation plus bounded human triage metadata; source locations, source excerpts, scanner + * diagnostics, artifacts, repository URLs, and finding metadata are deliberately not copied. + * Review deadline status is derived presentation metadata only and never mutates lifecycle state. + */ +export function buildFindingTriageView( + report: SynSecReport, + lifecycle: FindingLifecycleStore, + reviewComments: FindingReviewCommentStore, + options: { now?: number } = {}, +): FindingTriageView { + const now = options.now ?? Date.now(); + if (!Number.isFinite(now) || now <= 0) throw new Error("Finding triage view clock must be a positive timestamp."); + + const items = report.findings + .flatMap((finding): FindingTriageViewItem[] => { + const record = lifecycle.records[finding.fingerprint]; + if (!record) return []; + const comments = [...commentsForFinding(reviewComments, finding.fingerprint)]; + const reviewStatus = reviewDeadlineStatus(record.reviewAt, now); + return [{ + fingerprint: finding.fingerprint, + title: finding.primary.title, + severity: finding.primary.severity, + state: record.state, + updatedAt: record.updatedAt, + ...(record.owner ? { owner: record.owner } : {}), + ...(record.note ? { note: record.note } : {}), + ...(record.reviewAt ? { reviewAt: record.reviewAt } : {}), + ...(reviewStatus ? { reviewStatus } : {}), + comments, + }]; + }) + .sort((a, b) => a.fingerprint.localeCompare(b.fingerprint)); + + const assigned = items.filter((item) => Boolean(item.owner)).length; + const commented = items.filter((item) => item.comments.length > 0).length; + return { + schemaVersion: 1, + reportId: report.reportId, + items, + summary: { + current: items.length, + assigned, + unassigned: items.length - assigned, + commented, + }, + interpretation: "triage-metadata-not-scanner-evidence", + }; +} diff --git a/packages/lifecycle/tsconfig.json b/packages/lifecycle/tsconfig.json new file mode 100644 index 00000000..4ca5cc64 --- /dev/null +++ b/packages/lifecycle/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [ + { "path": "../report" } + ], + "include": ["src/**/*.ts"] +} diff --git a/packages/report/package.json b/packages/report/package.json new file mode 100644 index 00000000..8ef9db2a --- /dev/null +++ b/packages/report/package.json @@ -0,0 +1,44 @@ +{ + "name": "@synsec/report", + "version": "0.2.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./markdown": { + "types": "./dist/markdown.d.ts", + "import": "./dist/markdown.js" + }, + "./baseline": { + "types": "./dist/baseline.d.ts", + "import": "./dist/baseline.js" + }, + "./history": { + "types": "./dist/history.d.ts", + "import": "./dist/history.js" + }, + "./history-store": { + "types": "./dist/history-store.d.ts", + "import": "./dist/history-store.js" + }, + "./history-html": { + "types": "./dist/history-html.d.ts", + "import": "./dist/history-html.js" + }, + "./sbom-html": { + "types": "./dist/sbom-html.d.ts", + "import": "./dist/sbom-html.js" + } + }, + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@synsec/core": "0.1.0" + } +} diff --git a/packages/report/src/baseline.ts b/packages/report/src/baseline.ts new file mode 100644 index 00000000..78f9c9a1 --- /dev/null +++ b/packages/report/src/baseline.ts @@ -0,0 +1,51 @@ +import type { CorrelatedFinding } from "@synsec/core"; +import type { BaselineDelta, SynSecReport } from "./index.js"; + +function normalizePath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, "").replace(/^\//, "").toLowerCase(); +} + +function scannerReran(report: SynSecReport, finding: CorrelatedFinding): boolean { + const currentScanners = new Set(report.scanners.map((scanner) => scanner.scanner.trim().toLowerCase()).filter(Boolean)); + const detectingScanners = new Set(finding.sources.map((source) => source.name.trim().toLowerCase()).filter(Boolean)); + return [...detectingScanners].some((scanner) => currentScanners.has(scanner)); +} + +function scopeCoversFinding(report: SynSecReport, finding: CorrelatedFinding): boolean { + if (!report.scope || report.scope.mode === "repository") return true; + if (report.scope.mode !== "changed-files") return false; + const path = finding.primary.location?.path; + if (!path) return false; + const changed = new Set((report.scope.changedFiles ?? []).map(normalizePath)); + return changed.has(normalizePath(path)); +} + +/** + * Apply a baseline without inventing remediation conclusions outside the current scan's evidence. + * + * New/persisting findings are computed from normalized fingerprints as before. An absent baseline + * finding is considered fixed only when the current report actually covered that finding's path + * (for changed-file scans) and at least one scanner that previously detected it ran again. Findings + * that were not reassessed are deliberately omitted from `fixed`; absence is not evidence of a fix. + */ +export function applyEvidenceAwareBaseline(report: SynSecReport, baseline: SynSecReport): SynSecReport { + const current = new Set(report.findings.map((finding) => finding.fingerprint)); + const previous = new Set(baseline.findings.map((finding) => finding.fingerprint)); + const baselineByFingerprint = new Map(baseline.findings.map((finding) => [finding.fingerprint, finding])); + + const fixed = [...previous] + .filter((fingerprint) => { + if (current.has(fingerprint)) return false; + const finding = baselineByFingerprint.get(fingerprint); + return Boolean(finding && scopeCoversFinding(report, finding) && scannerReran(report, finding)); + }) + .sort(); + + const delta: BaselineDelta = { + new: [...current].filter((fingerprint) => !previous.has(fingerprint)).sort(), + fixed, + persisting: [...current].filter((fingerprint) => previous.has(fingerprint)).sort(), + }; + + return { ...report, baseline: delta }; +} diff --git a/packages/report/src/history-html.ts b/packages/report/src/history-html.ts new file mode 100644 index 00000000..b4c7e55f --- /dev/null +++ b/packages/report/src/history-html.ts @@ -0,0 +1,133 @@ +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import type { ReportHistory, ReportHistoryPoint } from "./history.js"; +import { buildHistoryFromStore } from "./history-store.js"; + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function signed(value: number): string { + return value > 0 ? `+${value}` : String(value); +} + +function dateLabel(value: string): string { + const date = new Date(value); + return Number.isFinite(date.getTime()) ? date.toISOString().slice(0, 10) : value; +} + +function chartPoints(points: readonly ReportHistoryPoint[], width: number, height: number): string { + if (points.length === 0) return ""; + const padding = 18; + const usableWidth = Math.max(1, width - padding * 2); + const usableHeight = Math.max(1, height - padding * 2); + return points.map((point, index) => { + const x = points.length === 1 ? width / 2 : padding + (index / (points.length - 1)) * usableWidth; + const y = padding + ((100 - Math.max(0, Math.min(100, point.securityScore))) / 100) * usableHeight; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).join(" "); +} + +function latestPoint(history: ReportHistory): ReportHistoryPoint | undefined { + return history.points.at(-1); +} + +export function renderHistoryHtml(history: ReportHistory, options: { title?: string } = {}): string { + const title = escapeHtml(options.title?.trim() || "SynSec security history"); + const latest = latestPoint(history); + const activeFindings = history.findings.filter((finding) => finding.presentInLatest); + const trendRows = history.points.slice().reverse().map((point) => ` + + ${escapeHtml(dateLabel(point.generatedAt))} + ${point.securityScore} + ${point.findingCount} + ${point.newCount} + ${point.fixedCount} + ${point.persistingCount} + ${escapeHtml(point.commitSha?.slice(0, 12) ?? "—")} + `).join(""); + const findingRows = activeFindings.slice(0, 100).map((finding) => ` + + ${escapeHtml(finding.highestSeverity)} + ${escapeHtml(finding.title)} + ${finding.occurrenceCount} + ${escapeHtml(dateLabel(finding.firstSeenAt))} + ${escapeHtml(dateLabel(finding.lastSeenAt))} + `).join(""); + const polyline = chartPoints(history.points, 760, 180); + + return ` + + + + + ${title} + + +
+

${title}

+

Trend-safe repository security history. No source excerpts or scanner diagnostics are embedded in this dashboard.

+
+
Latest score${latest?.securityScore ?? "—"}${latest ? "/100" : ""}
+
Active findings${latest?.findingCount ?? 0}
+
Score change${signed(history.scoreDelta)}
+
Finding change${signed(history.findingCountDelta)}
+
+

Security score

+ ${history.points.length ? ` + + + ` : `
No scan history is available yet.
`} +
+

Scan history

+ ${history.points.length ? `${trendRows}
DateScoreFindingsNewFixedPersistingCommit
` : `
No scans recorded.
`} +
+

Findings present in latest scan

+ ${activeFindings.length ? `${findingRows}
SeverityFindingOccurrencesFirst seenLast seen
` : `
No findings are present in the latest scan.
`} +
+
`; +} + +export async function writeHistoryHtml( + path: string, + history: ReportHistory, + options: { title?: string } = {}, +): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, renderHistoryHtml(history, options), { encoding: "utf8", mode: 0o600 }); + await chmod(path, 0o600).catch(() => undefined); +} + +export async function writeHistoryHtmlFromStore( + storePath: string, + outputPath: string, + options: { title?: string } = {}, +): Promise { + const history = await buildHistoryFromStore(storePath); + await writeHistoryHtml(outputPath, history, options); + return history; +} diff --git a/packages/report/src/history-store.ts b/packages/report/src/history-store.ts new file mode 100644 index 00000000..b8b58bc7 --- /dev/null +++ b/packages/report/src/history-store.ts @@ -0,0 +1,169 @@ +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import type { Severity } from "@synsec/core"; +import type { SeverityCounts, SynSecReport } from "./index.js"; +import { buildReportHistory, type ReportHistory, type ReportHistoryInput } from "./history.js"; + +export const HISTORY_STORE_SCHEMA_VERSION = 1 as const; +export const DEFAULT_HISTORY_RETENTION = 100; +export const MAX_HISTORY_RETENTION = 10_000; + +export interface StoredFindingSnapshot { + fingerprint: string; + primary: { + title: string; + severity: Severity; + }; +} + +export interface StoredReportSnapshot extends ReportHistoryInput { + target: { + commitSha?: string; + branch?: string; + }; + summary: SeverityCounts; + findings: StoredFindingSnapshot[]; +} + +export interface ReportHistoryStore { + schemaVersion: typeof HISTORY_STORE_SCHEMA_VERSION; + reports: StoredReportSnapshot[]; +} + +export interface AppendHistoryOptions { + maxReports?: number; +} + +function isSeverity(value: unknown): value is Severity { + return value === "critical" || value === "high" || value === "medium" || value === "low" || value === "info" || value === "unknown"; +} + +function isSeverityCounts(value: unknown): value is SeverityCounts { + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + return ["critical", "high", "medium", "low", "info", "unknown"].every( + (key) => typeof record[key] === "number" && Number.isFinite(record[key]) && (record[key] as number) >= 0, + ); +} + +function isStoredReportSnapshot(value: unknown): value is StoredReportSnapshot { + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + if ( + typeof record.reportId !== "string" || + typeof record.generatedAt !== "string" || + !Number.isFinite(Date.parse(record.generatedAt)) || + typeof record.securityScore !== "number" || + !Number.isFinite(record.securityScore) || + typeof record.findingCount !== "number" || + !Number.isInteger(record.findingCount) || + record.findingCount < 0 || + !isSeverityCounts(record.summary) || + !Array.isArray(record.findings) || + typeof record.target !== "object" || + record.target === null + ) { + return false; + } + + const target = record.target as Record; + if (target.commitSha !== undefined && typeof target.commitSha !== "string") return false; + if (target.branch !== undefined && typeof target.branch !== "string") return false; + + return record.findings.every((finding) => { + if (typeof finding !== "object" || finding === null) return false; + const item = finding as Record; + if (typeof item.fingerprint !== "string" || typeof item.primary !== "object" || item.primary === null) return false; + const primary = item.primary as Record; + return typeof primary.title === "string" && isSeverity(primary.severity); + }); +} + +export function snapshotReport(report: SynSecReport): StoredReportSnapshot { + return { + reportId: report.reportId, + generatedAt: report.generatedAt, + target: { + ...(report.target.commitSha ? { commitSha: report.target.commitSha } : {}), + ...(report.target.branch ? { branch: report.target.branch } : {}), + }, + securityScore: report.securityScore, + findingCount: report.findingCount, + summary: { ...report.summary }, + findings: report.findings.map((finding) => ({ + fingerprint: finding.fingerprint, + primary: { + title: finding.primary.title, + severity: finding.primary.severity, + }, + })), + }; +} + +export async function readHistoryStore(path: string): Promise { + let raw: string; + try { + raw = await readFile(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { schemaVersion: HISTORY_STORE_SCHEMA_VERSION, reports: [] }; + } + throw error; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + throw new Error(`History store is not valid JSON: ${path}`); + } + + if (typeof parsed !== "object" || parsed === null) throw new Error(`Unsupported SynSec history store: ${path}`); + const record = parsed as Record; + if (record.schemaVersion !== HISTORY_STORE_SCHEMA_VERSION || !Array.isArray(record.reports) || !record.reports.every(isStoredReportSnapshot)) { + throw new Error(`Unsupported SynSec history store: ${path}`); + } + + return { schemaVersion: HISTORY_STORE_SCHEMA_VERSION, reports: record.reports }; +} + +function retentionLimit(value: number | undefined): number { + const limit = value ?? DEFAULT_HISTORY_RETENTION; + if (!Number.isInteger(limit) || limit < 1 || limit > MAX_HISTORY_RETENTION) { + throw new Error(`History retention must be an integer between 1 and ${MAX_HISTORY_RETENTION}.`); + } + return limit; +} + +async function writeHistoryStore(path: string, store: ReportHistoryStore): Promise { + await mkdir(dirname(path), { recursive: true }); + const temporary = `${path}.${process.pid}.${Date.now()}.tmp`; + try { + await writeFile(temporary, `${JSON.stringify(store, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + await rename(temporary, path); + } finally { + await rm(temporary, { force: true }); + } +} + +export async function appendHistoryReport( + path: string, + report: SynSecReport, + options: AppendHistoryOptions = {}, +): Promise { + const limit = retentionLimit(options.maxReports); + const store = await readHistoryStore(path); + const snapshot = snapshotReport(report); + const reports = store.reports.filter((existing) => existing.reportId !== snapshot.reportId); + reports.push(snapshot); + reports.sort((a, b) => Date.parse(a.generatedAt) - Date.parse(b.generatedAt) || a.reportId.localeCompare(b.reportId)); + const bounded = reports.slice(Math.max(0, reports.length - limit)); + const next = { schemaVersion: HISTORY_STORE_SCHEMA_VERSION, reports: bounded } as const; + await writeHistoryStore(path, next); + return next; +} + +export async function buildHistoryFromStore(path: string): Promise { + const store = await readHistoryStore(path); + return buildReportHistory(store.reports); +} diff --git a/packages/report/src/history.ts b/packages/report/src/history.ts new file mode 100644 index 00000000..18aad66c --- /dev/null +++ b/packages/report/src/history.ts @@ -0,0 +1,168 @@ +import type { Severity } from "@synsec/core"; +import type { SeverityCounts, SynSecReport } from "./index.js"; + +export interface ReportHistoryInput { + reportId: string; + generatedAt: string; + target: { + commitSha?: string; + branch?: string; + }; + securityScore: number; + findingCount: number; + summary: SeverityCounts; + findings: Array<{ + fingerprint: string; + primary: { + title: string; + severity: Severity; + }; + }>; +} + +export interface ReportHistoryPoint { + reportId: string; + generatedAt: string; + commitSha?: string; + branch?: string; + securityScore: number; + findingCount: number; + summary: SeverityCounts; + newCount: number; + fixedCount: number; + persistingCount: number; +} + +export interface FindingHistory { + fingerprint: string; + title: string; + highestSeverity: Severity; + firstSeenAt: string; + lastSeenAt: string; + occurrenceCount: number; + presentInLatest: boolean; +} + +export interface ReportHistory { + schemaVersion: 1; + points: ReportHistoryPoint[]; + findings: FindingHistory[]; + scoreDelta: number; + findingCountDelta: number; +} + +const severityRank: Record = { + critical: 5, + high: 4, + medium: 3, + low: 2, + info: 1, + unknown: 0, +}; + +function timestamp(report: ReportHistoryInput): number { + const value = Date.parse(report.generatedAt); + if (!Number.isFinite(value)) throw new Error(`Report ${report.reportId} has an invalid generatedAt timestamp.`); + return value; +} + +function fingerprints(report: ReportHistoryInput): Set { + return new Set(report.findings.map((finding) => finding.fingerprint)); +} + +function deltaCounts(previous: ReportHistoryInput | undefined, current: ReportHistoryInput): Pick { + if (!previous) { + return { newCount: current.findingCount, fixedCount: 0, persistingCount: 0 }; + } + + const before = fingerprints(previous); + const after = fingerprints(current); + let newCount = 0; + let fixedCount = 0; + let persistingCount = 0; + for (const fingerprint of after) { + if (before.has(fingerprint)) persistingCount += 1; + else newCount += 1; + } + for (const fingerprint of before) { + if (!after.has(fingerprint)) fixedCount += 1; + } + return { newCount, fixedCount, persistingCount }; +} + +export function buildReportHistory(reports: readonly ReportHistoryInput[]): ReportHistory { + if (reports.length === 0) { + return { schemaVersion: 1, points: [], findings: [], scoreDelta: 0, findingCountDelta: 0 }; + } + + const ids = new Set(); + const timestamps = new Map(); + for (const report of reports) { + if (ids.has(report.reportId)) throw new Error(`Duplicate report id in history: ${report.reportId}`); + ids.add(report.reportId); + timestamps.set(report.reportId, timestamp(report)); + } + + const ordered = [...reports].sort( + (a, b) => (timestamps.get(a.reportId) ?? 0) - (timestamps.get(b.reportId) ?? 0) || a.reportId.localeCompare(b.reportId), + ); + const points: ReportHistoryPoint[] = []; + const findingMap = new Map(); + let previous: ReportHistoryInput | undefined; + + for (const report of ordered) { + const delta = deltaCounts(previous, report); + points.push({ + reportId: report.reportId, + generatedAt: report.generatedAt, + ...(report.target.commitSha ? { commitSha: report.target.commitSha } : {}), + ...(report.target.branch ? { branch: report.target.branch } : {}), + securityScore: report.securityScore, + findingCount: report.findingCount, + summary: { ...report.summary }, + ...delta, + }); + + for (const correlated of report.findings) { + const finding = correlated.primary; + const existing = findingMap.get(correlated.fingerprint); + if (!existing) { + findingMap.set(correlated.fingerprint, { + fingerprint: correlated.fingerprint, + title: finding.title, + highestSeverity: finding.severity, + firstSeenAt: report.generatedAt, + lastSeenAt: report.generatedAt, + occurrenceCount: 1, + presentInLatest: false, + }); + continue; + } + existing.lastSeenAt = report.generatedAt; + existing.occurrenceCount += 1; + if (severityRank[finding.severity] > severityRank[existing.highestSeverity]) { + existing.highestSeverity = finding.severity; + existing.title = finding.title; + } + } + previous = report; + } + + const latest = ordered.at(-1); + const latestFingerprints = latest ? fingerprints(latest) : new Set(); + const findings = [...findingMap.values()] + .map((finding) => ({ ...finding, presentInLatest: latestFingerprints.has(finding.fingerprint) })) + .sort((a, b) => severityRank[b.highestSeverity] - severityRank[a.highestSeverity] || a.firstSeenAt.localeCompare(b.firstSeenAt) || a.fingerprint.localeCompare(b.fingerprint)); + + const first = points[0]; + const last = points.at(-1); + return { + schemaVersion: 1, + points, + findings, + scoreDelta: first && last ? last.securityScore - first.securityScore : 0, + findingCountDelta: first && last ? last.findingCount - first.findingCount : 0, + }; +} + +export type { SynSecReport }; diff --git a/packages/report/src/index.ts b/packages/report/src/index.ts new file mode 100644 index 00000000..e05e60cc --- /dev/null +++ b/packages/report/src/index.ts @@ -0,0 +1,370 @@ +import { createHash } from "node:crypto"; +import { chmod, mkdir, open, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import type { + CorrelatedFinding, + Finding, + ScanArtifact, + ScannerExecutionScope, + ScanResult, + ScanTarget, + Severity, +} from "@synsec/core"; +import { correlateFindings, findingFingerprint } from "@synsec/core"; + +export const SYNSEC_REPORT_SCHEMA_VERSION = "1.0" as const; +const MAX_REPORT_BYTES = 64 * 1024 * 1024; + +export interface SeverityCounts { + critical: number; + high: number; + medium: number; + low: number; + info: number; + unknown: number; +} + +export interface ScannerRunSummary { + scanner: string; + startedAt: string; + completedAt: string; + findingCount: number; + artifactCount: number; + diagnostics: string[]; + executionScope?: ScannerExecutionScope; +} + +export interface BaselineDelta { + new: string[]; + fixed: string[]; + persisting: string[]; +} + +export interface RepositoryMetadata { + languages?: Record; + frameworks?: string[]; + fileCount?: number; +} + +export interface ScanScope { + mode: "repository" | "changed-files"; + baseRef?: string; + changedFiles?: string[]; +} + +export interface SynSecReport { + schemaVersion: typeof SYNSEC_REPORT_SCHEMA_VERSION; + reportId: string; + generatedAt: string; + toolVersion: string; + target: ScanTarget; + scanners: ScannerRunSummary[]; + rawFindingCount: number; + findingCount: number; + summary: SeverityCounts; + securityScore: number; + findings: CorrelatedFinding[]; + artifacts?: ScanArtifact[]; + scope?: ScanScope; + baseline?: BaselineDelta; + repository?: RepositoryMetadata; +} + +function emptyCounts(): SeverityCounts { + return { + critical: 0, + high: 0, + medium: 0, + low: 0, + info: 0, + unknown: 0, + }; +} + +export function countSeverities(findings: readonly CorrelatedFinding[]): SeverityCounts { + const counts = emptyCounts(); + for (const finding of findings) counts[finding.primary.severity] += 1; + return counts; +} + +export function calculateSecurityScore(counts: SeverityCounts): number { + const penalty = + counts.critical * 25 + + counts.high * 12 + + counts.medium * 5 + + counts.low * 1.5 + + counts.unknown * 1; + return Math.max(0, Math.round(100 - Math.min(100, penalty))); +} + +function makeReportId(target: ScanTarget, generatedAt: string): string { + return createHash("sha256") + .update(`${target.repositoryUrl ?? target.path}|${target.commitSha ?? ""}|${generatedAt}`) + .digest("hex") + .slice(0, 20); +} + +export function buildReport(input: { + target: ScanTarget; + scans: readonly ScanResult[]; + toolVersion?: string; + repository?: RepositoryMetadata; + scope?: ScanScope; +}): SynSecReport { + const rawFindings = input.scans.flatMap((scan) => scan.findings); + const artifacts = input.scans.flatMap((scan) => scan.artifacts ?? []); + const findings = correlateFindings(rawFindings); + const summary = countSeverities(findings); + const generatedAt = new Date().toISOString(); + + const report: SynSecReport = { + schemaVersion: SYNSEC_REPORT_SCHEMA_VERSION, + reportId: makeReportId(input.target, generatedAt), + generatedAt, + toolVersion: input.toolVersion ?? "0.2.0", + target: input.target, + scanners: input.scans.map((scan) => ({ + scanner: scan.scanner, + startedAt: scan.startedAt, + completedAt: scan.completedAt, + findingCount: scan.findings.length, + artifactCount: scan.artifacts?.length ?? 0, + diagnostics: scan.diagnostics, + ...(scan.executionScope ? { executionScope: scan.executionScope } : {}), + })), + rawFindingCount: rawFindings.length, + findingCount: findings.length, + summary, + securityScore: calculateSecurityScore(summary), + findings, + }; + + if (artifacts.length > 0) report.artifacts = artifacts; + if (input.scope) report.scope = input.scope; + if (input.repository) report.repository = input.repository; + return report; +} + +function reportFingerprints(report: SynSecReport): Set { + return new Set(report.findings.map((finding) => finding.fingerprint)); +} + +export function applyBaseline(report: SynSecReport, baseline: SynSecReport): SynSecReport { + const current = reportFingerprints(report); + const previous = reportFingerprints(baseline); + + const delta: BaselineDelta = { + new: [...current].filter((fingerprint) => !previous.has(fingerprint)).sort(), + fixed: [...previous].filter((fingerprint) => !current.has(fingerprint)).sort(), + persisting: [...current].filter((fingerprint) => previous.has(fingerprint)).sort(), + }; + + return { ...report, baseline: delta }; +} + +export function isSynSecReport(value: unknown): value is SynSecReport { + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + return ( + record.schemaVersion === SYNSEC_REPORT_SCHEMA_VERSION && + typeof record.reportId === "string" && + Array.isArray(record.findings) + ); +} + +export async function readReport(path: string): Promise { + const handle = await open(path, "r"); + try { + const metadata = await handle.stat(); + if (!metadata.isFile()) throw new Error(`SynSec report path is not a regular file: ${path}`); + if (metadata.size > MAX_REPORT_BYTES) throw new Error(`SynSec report exceeds ${MAX_REPORT_BYTES} bytes: ${path}`); + const parsed = JSON.parse(await handle.readFile("utf8")) as unknown; + if (!isSynSecReport(parsed)) throw new Error(`Not a supported SynSec report: ${path}`); + return parsed; + } finally { + await handle.close(); + } +} + +async function writePrivateFile(path: string, content: string): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, content, { encoding: "utf8", mode: 0o600 }); + await chmod(path, 0o600).catch(() => undefined); +} + +export async function writeReport(path: string, report: SynSecReport): Promise { + await writePrivateFile(path, `${JSON.stringify(report, null, 2)}\n`); +} + +function sarifLevel(severity: Severity): "error" | "warning" | "note" | "none" { + if (severity === "critical" || severity === "high") return "error"; + if (severity === "medium") return "warning"; + if (severity === "low" || severity === "info") return "note"; + return "none"; +} + +function ids(finding: Finding): string[] { + const identifiers = finding.identifiers; + if (!identifiers) return []; + return [ + ...(identifiers.cve ?? []), + ...(identifiers.cwe ?? []), + ...(identifiers.ghsa ?? []), + ...(identifiers.osv ?? []), + ]; +} + +export function toSarif(report: SynSecReport): Record { + const rules = report.findings.map((group) => { + const finding = group.primary; + const ruleId = finding.scanner.ruleId ?? group.fingerprint; + return { + id: ruleId, + name: ruleId, + shortDescription: { text: finding.title }, + fullDescription: { text: finding.description ?? finding.title }, + properties: { + category: finding.category, + severity: finding.severity, + confidence: finding.confidence, + identifiers: ids(finding), + scanners: group.sources.map((source) => source.name), + }, + }; + }); + + const results = report.findings.map((group, index) => { + const finding = group.primary; + const location = finding.location; + const result: Record = { + ruleId: finding.scanner.ruleId ?? group.fingerprint, + ruleIndex: index, + level: sarifLevel(finding.severity), + message: { text: finding.title }, + partialFingerprints: { "synsec/v1": group.fingerprint }, + properties: { + category: finding.category, + confidence: finding.confidence, + remediation: finding.remediation ?? null, + }, + }; + + if (location) { + result.locations = [ + { + physicalLocation: { + artifactLocation: { uri: location.path }, + region: { + startLine: location.startLine ?? 1, + endLine: location.endLine ?? location.startLine ?? 1, + startColumn: location.startColumn ?? 1, + endColumn: location.endColumn ?? location.startColumn ?? 1, + }, + }, + }, + ]; + } + return result; + }); + + return { + $schema: "https://json.schemastore.org/sarif-2.1.0.json", + version: "2.1.0", + runs: [ + { + tool: { + driver: { + name: "SynSec", + semanticVersion: report.toolVersion, + informationUri: "https://github.com/cmahmud/synsec", + rules, + }, + }, + results, + }, + ], + }; +} + +export async function writeSarif(path: string, report: SynSecReport): Promise { + await writePrivateFile(path, `${JSON.stringify(toSarif(report), null, 2)}\n`); +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function findingLocation(finding: Finding): string { + if (!finding.location) return "Repository"; + return `${finding.location.path}${finding.location.startLine ? `:${finding.location.startLine}` : ""}`; +} + +export function renderHtml(report: SynSecReport): string { + const cards = report.findings + .map((group) => { + const finding = group.primary; + const sourceNames = group.sources.map((source) => source.name).join(", "); + const remediation = finding.remediation + ? `

Remediation: ${escapeHtml(finding.remediation)}

` + : ""; + const description = finding.description + ? `

${escapeHtml(finding.description)}

` + : ""; + return `
+
${escapeHtml(finding.severity.toUpperCase())}

${escapeHtml(finding.title)}

+
${escapeHtml(findingLocation(finding))} · ${escapeHtml(sourceNames)} · confidence ${Math.round(finding.confidence * 100)}%
+ ${description} + ${remediation} +
`; + }) + .join("\n"); + + const baseline = report.baseline + ? `
Since baseline: ${report.baseline.new.length} new · ${report.baseline.fixed.length} fixed · ${report.baseline.persisting.length} persisting
` + : ""; + const sbomPackageCount = (report.artifacts ?? []) + .filter((artifact) => artifact.type === "sbom") + .reduce((total, artifact) => total + artifact.packageCount, 0); + const artifactSummary = sbomPackageCount > 0 + ? `
SBOM: ${sbomPackageCount} package(s) inventoried
` + : ""; + const scopeSummary = report.scope?.mode === "changed-files" + ? `
Scope: ${report.scope.changedFiles?.length ?? 0} changed file(s)${report.scope.baseRef ? ` since ${escapeHtml(report.scope.baseRef)}` : ""}
` + : ""; + + return ` + + + + +SynSec report + + +
+
SynSec repository security

${escapeHtml(report.target.repositoryUrl ?? report.target.path)}

Generated ${escapeHtml(report.generatedAt)} · ${report.findingCount} correlated finding(s) from ${report.rawFindingCount} raw result(s)
${baseline}${artifactSummary}${scopeSummary}
Security score
${report.securityScore}
+
+
Critical${report.summary.critical}
High${report.summary.high}
Medium${report.summary.medium}
Low${report.summary.low}
Info${report.summary.info}
Unknown${report.summary.unknown}
+
+
+
${cards || '
No findings. Keep the report as evidence of the scan.
'}
+
`; +} + +export async function writeHtml(path: string, report: SynSecReport): Promise { + await writePrivateFile(path, renderHtml(report)); +} + +export function findingIsNew(report: SynSecReport, finding: CorrelatedFinding): boolean { + return report.baseline ? report.baseline.new.includes(finding.fingerprint) : true; +} + +export function rawFingerprint(finding: Finding): string { + return findingFingerprint(finding); +} diff --git a/packages/report/src/markdown.ts b/packages/report/src/markdown.ts new file mode 100644 index 00000000..af056ece --- /dev/null +++ b/packages/report/src/markdown.ts @@ -0,0 +1,155 @@ +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import type { CorrelatedFinding, Finding } from "@synsec/core"; +import type { SynSecReport } from "./index.js"; + +function escapeCell(value: string): string { + return value.replaceAll("|", "\\|").replaceAll("\n", " "); +} + +function location(finding: Finding): string { + if (!finding.location?.path) return "repository"; + const line = finding.location.startLine ? `:${finding.location.startLine}` : ""; + return `${finding.location.path}${line}`; +} + +function identifiers(finding: Finding): string[] { + const ids = finding.identifiers; + if (!ids) return []; + return [...new Set([ + ...(ids.cve ?? []), + ...(ids.cwe ?? []), + ...(ids.ghsa ?? []), + ...(ids.osv ?? []), + ])]; +} + +function findingBaselineState(report: SynSecReport, finding: CorrelatedFinding): string | undefined { + if (!report.baseline) return undefined; + if (report.baseline.new.includes(finding.fingerprint)) return "new"; + if (report.baseline.persisting.includes(finding.fingerprint)) return "persisting"; + return undefined; +} + +function findingSection(report: SynSecReport, group: CorrelatedFinding, index: number): string { + const finding = group.primary; + const ids = identifiers(finding); + const sources = [...new Set(group.sources.map((source) => source.name))].join(", "); + const baseline = findingBaselineState(report, group); + const lines = [ + `### ${index + 1}. [${finding.severity.toUpperCase()}] ${finding.title}`, + "", + `- **Category:** ${finding.category}`, + `- **Confidence:** ${Math.round(finding.confidence * 100)}%`, + `- **Location:** \`${location(finding)}\``, + `- **Sources:** ${sources || "unknown"}`, + `- **Fingerprint:** \`${group.fingerprint}\``, + ]; + if (ids.length > 0) lines.push(`- **Identifiers:** ${ids.join(", ")}`); + if (baseline) lines.push(`- **Baseline:** ${baseline}`); + lines.push(""); + if (finding.description) lines.push(finding.description, ""); + if (finding.remediation) lines.push("**Remediation**", "", finding.remediation, ""); + if (group.duplicates.length > 0) { + lines.push(`Corroborated by ${group.duplicates.length} additional normalized result(s).`, ""); + } + return lines.join("\n"); +} + +export function renderMarkdown(report: SynSecReport): string { + const target = report.target.repositoryUrl ?? report.target.path; + const scope = report.scope?.mode === "changed-files" + ? `changed files since ${report.scope.baseRef ?? "configured base"} (${report.scope.changedFiles?.length ?? 0} files)` + : "repository"; + const sbomPackages = (report.artifacts ?? []) + .filter((artifact) => artifact.type === "sbom") + .reduce((total, artifact) => total + artifact.packageCount, 0); + + const lines = [ + "# SynSec Security Report", + "", + `**Target:** ${target}`, + `**Generated:** ${report.generatedAt}`, + `**Report ID:** \`${report.reportId}\``, + `**Scan scope:** ${scope}`, + `**Security score:** ${report.securityScore}/100`, + "", + "## Summary", + "", + "| Severity | Findings |", + "| --- | ---: |", + `| Critical | ${report.summary.critical} |`, + `| High | ${report.summary.high} |`, + `| Medium | ${report.summary.medium} |`, + `| Low | ${report.summary.low} |`, + `| Info | ${report.summary.info} |`, + `| Unknown | ${report.summary.unknown} |`, + "", + `SynSec correlated **${report.rawFindingCount} raw result(s)** into **${report.findingCount} logical finding(s)**.`, + "", + "## Scanner coverage", + "", + "| Scanner | Findings | Artifacts | Diagnostics |", + "| --- | ---: | ---: | --- |", + ...report.scanners.map((scanner) => + `| ${escapeCell(scanner.scanner)} | ${scanner.findingCount} | ${scanner.artifactCount} | ${escapeCell(scanner.diagnostics.join("; ") || "—")} |`, + ), + "", + ]; + + if (report.repository) { + const languages = Object.entries(report.repository.languages ?? {}) + .sort((a, b) => b[1] - a[1]) + .map(([name, count]) => `${name} (${count})`) + .join(", "); + lines.push( + "## Repository context", + "", + `- **Files inventoried:** ${report.repository.fileCount ?? "unknown"}`, + `- **Languages:** ${languages || "unknown"}`, + `- **Frameworks:** ${(report.repository.frameworks ?? []).join(", ") || "none detected"}`, + "", + ); + } + + if (sbomPackages > 0) { + lines.push( + "## SBOM", + "", + `- **SBOM packages inventoried:** ${sbomPackages}`, + "", + ); + } + + if (report.baseline) { + lines.push( + "## Baseline delta", + "", + `- **New:** ${report.baseline.new.length}`, + `- **Fixed:** ${report.baseline.fixed.length}`, + `- **Persisting:** ${report.baseline.persisting.length}`, + "", + ); + } + + lines.push("## Findings", ""); + if (report.findings.length === 0) { + lines.push("No findings were reported by the scanner engines that successfully ran.", ""); + } else { + report.findings.forEach((finding, index) => lines.push(findingSection(report, finding, index))); + } + + lines.push( + "## Interpretation note", + "", + "This report preserves deterministic scanner evidence and SynSec correlation. A finding should not be treated as proven exploitable solely because it appears here; confirm reachability, deployment context, and relevant mitigations before remediation decisions.", + "", + ); + return `${lines.join("\n").trimEnd()}\n`; +} + +export async function writeMarkdown(path: string, report: SynSecReport): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, renderMarkdown(report), { encoding: "utf8", mode: 0o600 }); + await chmod(path, 0o600).catch(() => undefined); +} diff --git a/packages/report/src/sbom-html.ts b/packages/report/src/sbom-html.ts new file mode 100644 index 00000000..c9b9ec81 --- /dev/null +++ b/packages/report/src/sbom-html.ts @@ -0,0 +1,124 @@ +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import type { SbomPackage } from "@synsec/core"; +import type { SynSecReport } from "./index.js"; + +export interface SbomViewPackage { + name: string; + version?: string; + type?: string; + purl?: string; + licenses: string[]; + locationCount: number; +} + +export interface SbomView { + schemaVersion: 1; + reportId: string; + packageCount: number; + uniquePackageCount: number; + packages: SbomViewPackage[]; + licenses: string[]; + producers: string[]; + /** Dependency inventory only; this view does not imply vulnerability or runtime reachability. */ + interpretation: "sbom-inventory-not-vulnerability-or-reachability"; +} + +const MAX_VIEW_PACKAGES = 100_000; + +function packageKey(pkg: SbomPackage): string { + return (pkg.purl?.trim() || `${pkg.type ?? ""}|${pkg.name}|${pkg.version ?? ""}`).toLowerCase(); +} + +export function buildSbomView(report: SynSecReport): SbomView { + const artifacts = (report.artifacts ?? []).filter((artifact) => artifact.type === "sbom"); + const byPackage = new Map(); + let packageCount = 0; + + for (const artifact of artifacts) { + packageCount += artifact.packages.length; + for (const pkg of artifact.packages) { + if (byPackage.size >= MAX_VIEW_PACKAGES && !byPackage.has(packageKey(pkg))) { + throw new Error(`SBOM view exceeds the ${MAX_VIEW_PACKAGES}-package limit.`); + } + const key = packageKey(pkg); + const existing = byPackage.get(key); + const licenses = [...new Set([...(existing?.licenses ?? []), ...(pkg.licenses ?? [])].map((value) => value.trim()).filter(Boolean))].sort(); + const candidate: SbomViewPackage = { + name: pkg.name, + ...(pkg.version ? { version: pkg.version } : {}), + ...(pkg.type ? { type: pkg.type } : {}), + ...(pkg.purl ? { purl: pkg.purl } : {}), + licenses, + locationCount: Math.max(existing?.locationCount ?? 0, pkg.locations?.length ?? 0), + }; + byPackage.set(key, candidate); + } + } + + const packages = [...byPackage.values()].sort((a, b) => + a.name.localeCompare(b.name) || (a.version ?? "").localeCompare(b.version ?? "") || (a.purl ?? "").localeCompare(b.purl ?? "")); + const licenses = [...new Set(packages.flatMap((pkg) => pkg.licenses))].sort(); + const producers = [...new Set(artifacts.map((artifact) => artifact.producer.trim()).filter(Boolean))].sort(); + + return { + schemaVersion: 1, + reportId: report.reportId, + packageCount, + uniquePackageCount: packages.length, + packages, + licenses, + producers, + interpretation: "sbom-inventory-not-vulnerability-or-reachability", + }; +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export function renderSbomHtml(view: SbomView): string { + const rows = view.packages.map((pkg) => ` +${escapeHtml(pkg.name)} +${escapeHtml(pkg.version ?? "—")} +${escapeHtml(pkg.type ?? "—")} +${pkg.purl ? `${escapeHtml(pkg.purl)}` : "—"} +${pkg.licenses.length ? escapeHtml(pkg.licenses.join(", ")) : "—"} +${pkg.locationCount} +`).join("\n"); + + return ` + + + + + +SynSec dependency inventory + + + +

SynSec dependency inventory

+

SBOM inventory only · report ${escapeHtml(view.reportId)}

+
+ ${view.uniquePackageCount} unique packages + ${view.packageCount} artifact package records + ${view.licenses.length} observed licenses + ${view.producers.length} SBOM producers +
+${rows ? `
${rows}
PackageVersionTypePURLLicensesLocations
` : "

No SBOM packages are present in this report.

"} + +\n`; +} + +export async function writeSbomHtml(path: string, view: SbomView): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, renderSbomHtml(view), { encoding: "utf8", mode: 0o600 }); + await chmod(path, 0o600).catch(() => undefined); +} diff --git a/packages/report/tsconfig.json b/packages/report/tsconfig.json new file mode 100644 index 00000000..ebe9ac5b --- /dev/null +++ b/packages/report/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [ + { "path": "../core" } + ], + "include": ["src/**/*.ts"] +} diff --git a/packages/repository/package.json b/packages/repository/package.json new file mode 100644 index 00000000..8ef617f9 --- /dev/null +++ b/packages/repository/package.json @@ -0,0 +1,54 @@ +{ + "name": "@synsec/repository", + "version": "0.2.0", + "private": true, + "type": "module", + "exports": { + ".": "./dist/index.js", + "./analysis": "./dist/analysis.js", + "./module-graph": "./dist/module-graph.js", + "./dependency-usage": "./dist/dependency-usage.js", + "./call-graph": "./dist/call-graph.js", + "./import-call-links": "./dist/import-call-links.js", + "./import-route-handlers": "./dist/import-route-handlers.js", + "./request-input-flow": "./dist/request-input-flow.js", + "./request-input-forwarding": "./dist/request-input-forwarding.js", + "./request-input-return-flow": "./dist/request-input-return-flow.js", + "./request-input-return-alias-flow": "./dist/request-input-return-alias-flow.js", + "./gin-request-input-flow": "./dist/gin-request-input-flow.js", + "./gin-request-input-forwarding": "./dist/gin-request-input-forwarding.js", + "./koa-request-input-flow": "./dist/koa-request-input-flow.js", + "./koa-request-input-forwarding": "./dist/koa-request-input-forwarding.js", + "./route-entrypoints": "./dist/route-entrypoints.js", + "./django-route-handlers": "./dist/django-route-handlers.js", + "./django-urlconf-composition": "./dist/django-urlconf-composition.js", + "./fastapi-route-dependencies": "./dist/fastapi-route-dependencies.js", + "./fastapi-router-composition": "./dist/fastapi-router-composition.js", + "./flask-blueprint-composition": "./dist/flask-blueprint-composition.js", + "./express-router-composition": "./dist/express-router-composition.js", + "./gin-router-composition": "./dist/gin-router-composition.js", + "./koa-router-composition": "./dist/koa-router-composition.js", + "./nestjs-controller-composition": "./dist/nestjs-controller-composition.js", + "./route-middleware-composition": "./dist/route-middleware-composition.js", + "./route-auth-context": "./dist/route-auth-context.js", + "./route-protection-context": "./dist/route-protection-context.js", + "./route-sink-context": "./dist/route-sink-context.js", + "./route-sink-flow": "./dist/route-sink-flow.js", + "./route-flow-analysis": "./dist/route-flow-analysis.js", + "./route-security-review": "./dist/route-security-review.js", + "./posture": "./dist/posture.js", + "./posture-html": "./dist/posture-html.js", + "./test-ownership": "./dist/test-ownership.js", + "./incremental-plan": "./dist/incremental-plan.js", + "./coverage-context": "./dist/coverage-context.js" + }, + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@synsec/core": "0.1.0", + "@synsec/report": "0.2.0" + } +} diff --git a/packages/repository/src/analysis.ts b/packages/repository/src/analysis.ts new file mode 100644 index 00000000..73f7454c --- /dev/null +++ b/packages/repository/src/analysis.ts @@ -0,0 +1,396 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, extname, resolve } from "node:path"; + +export interface IndexFileInput { + path: string; + size: number; +} + +export interface ModuleEdge { + from: string; + specifier: string; + kind: "import" | "require" | "dynamic-import" | "python-import" | "go-import"; + line: number; +} + +export interface RouteSignal { + path: string; + line: number; + method: string; + route: string; + frameworkHint?: string; + /** Conservative same-line named-handler candidate; never inferred from dynamic expressions. */ + handler?: string; +} + +export interface AuthSignal { + path: string; + line: number; + kind: "authentication" | "authorization" | "session" | "token"; + evidence: string; +} + +export interface SinkSignal { + path: string; + line: number; + kind: "process" | "filesystem" | "database" | "network"; + evidence: string; +} + +export interface RepositoryIndex { + schemaVersion: 1; + generatedAt: string; + indexedFileCount: number; + moduleEdges: ModuleEdge[]; + routes: RouteSignal[]; + authSignals: AuthSignal[]; + sinks: SinkSignal[]; +} + +export interface DependencyUsage { + packageName: string; + status: "observed-import" | "unknown"; + evidence: ModuleEdge[]; +} + +export interface RouteSecurityContext { + route: RouteSignal; + nearbyAuthSignals: AuthSignal[]; + nearbySinks: SinkSignal[]; +} + +export interface NearbyRouteSignal { + line: number; + distance: number; + method: string; + route: string; + frameworkHint?: string; + /** Static named-handler candidate when the route registration was unambiguous. */ + handler?: string; +} + +export interface NearbySecuritySignal { + line: number; + distance: number; + kind: AuthSignal["kind"] | SinkSignal["kind"]; +} + +export interface FindingRepositoryContext { + path: string; + line?: number; + radius: number; + nearbyRoutes: NearbyRouteSignal[]; + nearbyAuthSignals: NearbySecuritySignal[]; + nearbySinks: NearbySecuritySignal[]; + /** These are lexical proximity signals, not proof of data flow or reachability. */ + interpretation: "proximity-signals-only"; +} + +const analyzableExtensions = new Set([ + ".js", ".mjs", ".cjs", ".jsx", + ".ts", ".mts", ".cts", ".tsx", + ".py", ".go", ".rb", ".php", ".java", ".kt", ".kts", ".cs", +]); + +const MAX_INDEX_FILE_BYTES = 512_000; +const MAX_INDEX_FILES = 5_000; +const MAX_SIGNALS_PER_FILE = 500; + +function sourceLine(lines: readonly string[], index: number): string { + return (lines[index] ?? "").trim().slice(0, 300); +} + +function collectModuleEdges(path: string, content: string): ModuleEdge[] { + const edges: ModuleEdge[] = []; + const lines = content.split(/\r?\n/); + const extension = extname(path).toLowerCase(); + + for (let index = 0; index < lines.length && edges.length < MAX_SIGNALS_PER_FILE; index += 1) { + const line = lines[index] ?? ""; + const lineNumber = index + 1; + + if ([".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"].includes(extension)) { + const staticImport = line.match(/\b(?:import|export)\s+(?:[^"']*?\s+from\s+)?["']([^"']+)["']/); + if (staticImport?.[1]) edges.push({ from: path, specifier: staticImport[1], kind: "import", line: lineNumber }); + const requireCall = line.match(/\brequire\s*\(\s*["']([^"']+)["']\s*\)/); + if (requireCall?.[1]) edges.push({ from: path, specifier: requireCall[1], kind: "require", line: lineNumber }); + const dynamicImport = line.match(/\bimport\s*\(\s*["']([^"']+)["']\s*\)/); + if (dynamicImport?.[1]) edges.push({ from: path, specifier: dynamicImport[1], kind: "dynamic-import", line: lineNumber }); + continue; + } + + if (extension === ".py") { + const fromImport = line.match(/^\s*from\s+([A-Za-z0-9_.]+)\s+import\b/); + if (fromImport?.[1]) edges.push({ from: path, specifier: fromImport[1], kind: "python-import", line: lineNumber }); + const directImport = line.match(/^\s*import\s+([A-Za-z0-9_.]+)/); + if (directImport?.[1]) edges.push({ from: path, specifier: directImport[1], kind: "python-import", line: lineNumber }); + continue; + } + + if (extension === ".go") { + const single = line.match(/^\s*import\s+(?:[A-Za-z0-9_.]+\s+)?"([^"]+)"/); + if (single?.[1]) edges.push({ from: path, specifier: single[1], kind: "go-import", line: lineNumber }); + const grouped = line.match(/^\s*(?:[A-Za-z0-9_.]+\s+)?"([A-Za-z0-9_./-]+)"\s*$/); + if (grouped?.[1]) edges.push({ from: path, specifier: grouped[1], kind: "go-import", line: lineNumber }); + } + } + + return edges; +} + +function namedNodeRouteHandler(line: string): string | undefined { + const registration = line.match( + /\b(?:app|router|server)\.(?:get|post|put|patch|delete|options|head)\s*\(\s*(?:"[^"]*"|'[^']*'|`[^`]*`)\s*,\s*(.*?)\s*\)\s*;?\s*(?:\/\/.*)?$/i, + ); + const argumentsList = registration?.[1]?.trim(); + if (!argumentsList || !/^[A-Za-z_$][\w$]*(?:\s*,\s*[A-Za-z_$][\w$]*)*$/.test(argumentsList)) { + return undefined; + } + const names = argumentsList.split(",").map((value) => value.trim()); + return names.at(-1); +} + +function collectRoutes(path: string, content: string): RouteSignal[] { + const routes: RouteSignal[] = []; + const lines = content.split(/\r?\n/); + + for (let index = 0; index < lines.length && routes.length < MAX_SIGNALS_PER_FILE; index += 1) { + const line = lines[index] ?? ""; + const lineNumber = index + 1; + + const express = line.match(/\b(?:app|router|server)\.(get|post|put|patch|delete|options|head|use)\s*\(\s*["'`]([^"'`]+)["'`]/i); + if (express?.[1] && express[2]) { + const handler = namedNodeRouteHandler(line); + routes.push({ + path, + line: lineNumber, + method: express[1].toUpperCase(), + route: express[2], + frameworkHint: "Node HTTP router", + ...(handler ? { handler } : {}), + }); + continue; + } + + const decorator = line.match(/@(Get|Post|Put|Patch|Delete|Options|Head)\s*\(\s*["'`]([^"'`]*)["'`]\s*\)/); + if (decorator?.[1] && decorator[2] !== undefined) { + routes.push({ path, line: lineNumber, method: decorator[1].toUpperCase(), route: decorator[2] || "/", frameworkHint: "Decorator router" }); + continue; + } + + const pythonRoute = line.match(/@(?:app|router|blueprint)\.(get|post|put|patch|delete|route)\s*\(\s*["']([^"']+)["']/i); + if (pythonRoute?.[1] && pythonRoute[2]) { + routes.push({ path, line: lineNumber, method: pythonRoute[1].toUpperCase(), route: pythonRoute[2], frameworkHint: "Python web router" }); + continue; + } + + const django = line.match(/\bpath\s*\(\s*["']([^"']+)["']/); + if (django?.[1]) routes.push({ path, line: lineNumber, method: "ANY", route: django[1], frameworkHint: "Django URLConf" }); + } + + return routes; +} + +function collectAuthSignals(path: string, content: string): AuthSignal[] { + const output: AuthSignal[] = []; + const lines = content.split(/\r?\n/); + const patterns: Array<[AuthSignal["kind"], RegExp]> = [ + ["authentication", /\b(authenticate|authentication|requireAuth|isAuthenticated|passport\.authenticate|login_required)\b/i], + ["authorization", /\b(authorize|authorization|permission|permissions|role|roles|isAdmin|accessControl|acl)\b/i], + ["session", /\b(session|cookieSession|express-session|sessionMiddleware)\b/i], + ["token", /\b(jwt|bearer|access[_-]?token|id[_-]?token|verifyToken|decodeToken)\b/i], + ]; + + for (let index = 0; index < lines.length && output.length < MAX_SIGNALS_PER_FILE; index += 1) { + const line = lines[index] ?? ""; + for (const [kind, pattern] of patterns) { + if (!pattern.test(line)) continue; + output.push({ path, line: index + 1, kind, evidence: sourceLine(lines, index) }); + break; + } + } + return output; +} + +function collectSinkSignals(path: string, content: string): SinkSignal[] { + const output: SinkSignal[] = []; + const lines = content.split(/\r?\n/); + const patterns: Array<[SinkSignal["kind"], RegExp]> = [ + ["process", /\b(child_process|execFile|execSync|spawnSync|spawn\s*\(|exec\s*\(|subprocess\.|os\.system\s*\()/i], + ["filesystem", /\b(writeFile|writeFileSync|appendFile|appendFileSync|unlink|rmSync|rename|createWriteStream|shutil\.|os\.remove\s*\()/i], + ["database", /\b(query|execute|executemany|raw|rawQuery|createQueryRunner)\s*\(/i], + ["network", /\b(fetch|axios\.|http\.request|https\.request|requests\.(get|post|put|patch|delete)|urllib\.)/i], + ]; + + for (let index = 0; index < lines.length && output.length < MAX_SIGNALS_PER_FILE; index += 1) { + const line = lines[index] ?? ""; + for (const [kind, pattern] of patterns) { + if (!pattern.test(line)) continue; + output.push({ path, line: index + 1, kind, evidence: sourceLine(lines, index) }); + break; + } + } + return output; +} + +export async function buildRepositoryIndex(rootPath: string, files: readonly IndexFileInput[]): Promise { + const root = resolve(rootPath); + const moduleEdges: ModuleEdge[] = []; + const routes: RouteSignal[] = []; + const authSignals: AuthSignal[] = []; + const sinks: SinkSignal[] = []; + let indexedFileCount = 0; + + for (const file of files) { + if (indexedFileCount >= MAX_INDEX_FILES) break; + if (file.size > MAX_INDEX_FILE_BYTES || !analyzableExtensions.has(extname(file.path).toLowerCase())) continue; + const absolute = resolve(root, file.path); + const content = await readFile(absolute, "utf8").catch(() => undefined); + if (content === undefined || content.includes("\u0000")) continue; + + indexedFileCount += 1; + moduleEdges.push(...collectModuleEdges(file.path, content)); + routes.push(...collectRoutes(file.path, content)); + authSignals.push(...collectAuthSignals(file.path, content)); + sinks.push(...collectSinkSignals(file.path, content)); + } + + return { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + indexedFileCount, + moduleEdges, + routes, + authSignals, + sinks, + }; +} + +export function packageNameFromPurl(purl: string | undefined): string | undefined { + if (!purl?.startsWith("pkg:")) return undefined; + const slash = purl.indexOf("/"); + if (slash < 0) return undefined; + let value = purl.slice(slash + 1); + const query = value.search(/[?#]/); + if (query >= 0) value = value.slice(0, query); + const version = value.lastIndexOf("@"); + if (version > 0) value = value.slice(0, version); + try { + value = decodeURIComponent(value); + } catch { + // Keep the raw package path when malformed percent encoding is present. + } + return value || undefined; +} + +function moduleMatchesPackage(edge: ModuleEdge, packageName: string): boolean { + const specifier = edge.specifier.toLowerCase(); + const normalized = packageName.toLowerCase(); + const pythonNormalized = normalized.replaceAll("-", "_"); + if (specifier === normalized || specifier.startsWith(`${normalized}/`)) return true; + if (specifier === pythonNormalized || specifier.startsWith(`${pythonNormalized}.`)) return true; + return false; +} + +export function findDependencyUsage(index: RepositoryIndex, packageName: string, maxEvidence = 10): DependencyUsage { + const evidence = index.moduleEdges + .filter((edge) => moduleMatchesPackage(edge, packageName)) + .slice(0, Math.max(1, maxEvidence)); + return { + packageName, + status: evidence.length > 0 ? "observed-import" : "unknown", + evidence, + }; +} + +function normalizeIndexPath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, "").replace(/^\//, "").toLowerCase(); +} + +function distanceFrom(line: number | undefined, signalLine: number): number { + return line === undefined ? 0 : Math.abs(signalLine - line); +} + +export function findingRepositoryContext( + index: RepositoryIndex, + path: string, + line?: number, + radius = 40, + maxPerKind = 5, +): FindingRepositoryContext { + const normalizedPath = normalizeIndexPath(path); + const boundedRadius = Math.max(0, radius); + const limit = Math.max(1, maxPerKind); + const sameFile = (signalPath: string): boolean => normalizeIndexPath(signalPath) === normalizedPath; + const nearby = (signalLine: number): boolean => line === undefined || distanceFrom(line, signalLine) <= boundedRadius; + + const nearbyRoutes = index.routes + .filter((signal) => sameFile(signal.path) && nearby(signal.line)) + .map((signal): NearbyRouteSignal => ({ + line: signal.line, + distance: distanceFrom(line, signal.line), + method: signal.method, + route: signal.route, + ...(signal.frameworkHint ? { frameworkHint: signal.frameworkHint } : {}), + ...(signal.handler ? { handler: signal.handler } : {}), + })) + .sort((a, b) => a.distance - b.distance || a.line - b.line) + .slice(0, limit); + + const nearbyAuthSignals = index.authSignals + .filter((signal) => sameFile(signal.path) && nearby(signal.line)) + .map((signal): NearbySecuritySignal => ({ + line: signal.line, + distance: distanceFrom(line, signal.line), + kind: signal.kind, + })) + .sort((a, b) => a.distance - b.distance || a.line - b.line) + .slice(0, limit); + + const nearbySinks = index.sinks + .filter((signal) => sameFile(signal.path) && nearby(signal.line)) + .map((signal): NearbySecuritySignal => ({ + line: signal.line, + distance: distanceFrom(line, signal.line), + kind: signal.kind, + })) + .sort((a, b) => a.distance - b.distance || a.line - b.line) + .slice(0, limit); + + const context: FindingRepositoryContext = { + path, + radius: boundedRadius, + nearbyRoutes, + nearbyAuthSignals, + nearbySinks, + interpretation: "proximity-signals-only", + }; + if (line !== undefined) context.line = line; + return context; +} + +export function routeSecurityContext(index: RepositoryIndex, route: RouteSignal, radius = 30): RouteSecurityContext { + const nearby = (line: number): boolean => Math.abs(line - route.line) <= Math.max(0, radius); + return { + route, + nearbyAuthSignals: index.authSignals.filter((signal) => signal.path === route.path && nearby(signal.line)), + nearbySinks: index.sinks.filter((signal) => signal.path === route.path && nearby(signal.line)), + }; +} + +export async function writeRepositoryIndex(path: string, index: RepositoryIndex): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(index, null, 2)}\n`, "utf8"); +} + +export function isRepositoryIndex(value: unknown): value is RepositoryIndex { + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + return record.schemaVersion === 1 && Array.isArray(record.moduleEdges) && Array.isArray(record.routes) && Array.isArray(record.authSignals) && Array.isArray(record.sinks); +} + +export async function readRepositoryIndex(path: string): Promise { + const parsed = JSON.parse(await readFile(path, "utf8")) as unknown; + if (!isRepositoryIndex(parsed)) throw new Error(`Not a supported SynSec repository index: ${path}`); + return parsed; +} diff --git a/packages/repository/src/call-graph.ts b/packages/repository/src/call-graph.ts new file mode 100644 index 00000000..50dd0eca --- /dev/null +++ b/packages/repository/src/call-graph.ts @@ -0,0 +1,357 @@ +import { readFile, stat } from "node:fs/promises"; +import { extname, resolve } from "node:path"; +import type { IndexFileInput } from "./analysis.js"; + +export type CallGraphNodeKind = "function" | "arrow-function" | "python-function" | "go-function"; +export type CallResolution = "same-file-function" | "external-or-unresolved"; + +export interface CallGraphNode { + id: string; + path: string; + name: string; + line: number; + endLine: number; + kind: CallGraphNodeKind; +} + +export interface CallGraphEdge { + from: string; + callee: string; + line: number; + target?: string; + resolution: CallResolution; +} + +export interface CallGraph { + schemaVersion: 1; + nodes: CallGraphNode[]; + edges: CallGraphEdge[]; + resolvedEdgeCount: number; + unresolvedEdgeCount: number; + skippedFiles: Array<{ path: string; reason: string }>; + /** Regex/lexical evidence is useful for review prioritization, not proof of runtime reachability. */ + interpretation: "lexical-call-evidence-only"; +} + +export interface CallNeighborhood { + root: string; + maxDepth: number; + callees: Array<{ id: string; depth: number }>; + callers: Array<{ id: string; depth: number }>; + interpretation: "lexical-call-evidence-only"; +} + +const MAX_SOURCE_BYTES = 512_000; +const MAX_FILES = 5_000; +const MAX_FUNCTIONS_PER_FILE = 500; +const MAX_CALLS_PER_FUNCTION = 500; +const jsExtensions = new Set([".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"]); +const ignoredCalls = new Set([ + "if", "for", "while", "switch", "catch", "function", "return", "typeof", "new", "await", "import", + "require", "super", "this", "console", "Math", "JSON", "Object", "Array", "String", "Number", "Boolean", +]); + +interface ParsedFunction { + node: CallGraphNode; + bodyStart: number; + bodyEnd: number; +} + +function normalizedPath(path: string): string { + return path.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function nodeId(path: string, name: string, line: number): string { + return `${normalizedPath(path)}:${name}:${line}`; +} + +function braceDelta(line: string): number { + let delta = 0; + let quote: "'" | '"' | "`" | undefined; + let escaped = false; + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + const next = line[index + 1]; + if (escaped) { + escaped = false; + continue; + } + if (quote) { + if (char === "\\") escaped = true; + else if (char === quote) quote = undefined; + continue; + } + if (char === "'" || char === '"' || char === "`") { + quote = char; + continue; + } + if (char === "/" && next === "/") break; + if (char === "{") delta += 1; + else if (char === "}") delta -= 1; + } + return delta; +} + +function parseJavascriptFunctions(path: string, content: string): ParsedFunction[] { + const lines = content.split(/\r?\n/); + const functions: ParsedFunction[] = []; + let depth = 0; + + for (let index = 0; index < lines.length && functions.length < MAX_FUNCTIONS_PER_FILE; index += 1) { + const line = lines[index] ?? ""; + const declaration = line.match(/\b(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/); + const arrow = line.match(/\b(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/); + const name = declaration?.[1] ?? arrow?.[1]; + const delta = braceDelta(line); + + if (name) { + const startDepth = depth; + let runningDepth = depth + delta; + let endIndex = index; + const hasBlock = line.includes("{") || delta > 0; + + if (hasBlock) { + for (let cursor = index + 1; cursor < lines.length; cursor += 1) { + runningDepth += braceDelta(lines[cursor] ?? ""); + endIndex = cursor; + if (runningDepth <= startDepth) break; + } + } + + functions.push({ + node: { + id: nodeId(path, name, index + 1), + path: normalizedPath(path), + name, + line: index + 1, + endLine: endIndex + 1, + kind: declaration ? "function" : "arrow-function", + }, + bodyStart: index, + bodyEnd: endIndex, + }); + } + + depth += delta; + } + return functions; +} + +function leadingIndent(line: string): number { + const prefix = line.match(/^[ \t]*/)?.[0] ?? ""; + return [...prefix].reduce((total, char) => total + (char === "\t" ? 4 : 1), 0); +} + +function parsePythonFunctions(path: string, content: string): ParsedFunction[] { + const lines = content.split(/\r?\n/); + const functions: ParsedFunction[] = []; + + for (let index = 0; index < lines.length && functions.length < MAX_FUNCTIONS_PER_FILE; index += 1) { + const line = lines[index] ?? ""; + const declaration = line.match(/^\s*(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/); + const name = declaration?.[1]; + if (!name) continue; + + const indent = leadingIndent(line); + let endIndex = index; + for (let cursor = index + 1; cursor < lines.length; cursor += 1) { + const candidate = lines[cursor] ?? ""; + if (!candidate.trim() || candidate.trimStart().startsWith("#")) { + endIndex = cursor; + continue; + } + if (leadingIndent(candidate) <= indent) break; + endIndex = cursor; + } + + functions.push({ + node: { + id: nodeId(path, name, index + 1), + path: normalizedPath(path), + name, + line: index + 1, + endLine: endIndex + 1, + kind: "python-function", + }, + bodyStart: index, + bodyEnd: endIndex, + }); + } + return functions; +} + +function parseGoFunctions(path: string, content: string): ParsedFunction[] { + const lines = content.split(/\r?\n/); + const functions: ParsedFunction[] = []; + + for (let index = 0; index < lines.length && functions.length < MAX_FUNCTIONS_PER_FILE; index += 1) { + const line = lines[index] ?? ""; + const declaration = line.match(/^\s*func\s+(?:\([^)]*\)\s*)?([A-Za-z_][A-Za-z0-9_]*)\s*\(/); + const name = declaration?.[1]; + if (!name || !line.includes("{")) continue; + + let runningDepth = braceDelta(line); + if (runningDepth < 0) continue; + let endIndex = index; + if (runningDepth > 0) { + for (let cursor = index + 1; cursor < lines.length; cursor += 1) { + runningDepth += braceDelta(lines[cursor] ?? ""); + endIndex = cursor; + if (runningDepth <= 0) break; + } + if (runningDepth > 0) continue; + } + + functions.push({ + node: { + id: nodeId(path, name, index + 1), + path: normalizedPath(path), + name, + line: index + 1, + endLine: endIndex + 1, + kind: "go-function", + }, + bodyStart: index, + bodyEnd: endIndex, + }); + } + return functions; +} + +function collectCalls(lines: readonly string[], fn: ParsedFunction): Array<{ callee: string; line: number; direct: boolean }> { + const calls: Array<{ callee: string; line: number; direct: boolean }> = []; + const regex = /\b([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?)\s*\(/g; + + for (let index = fn.bodyStart; index <= fn.bodyEnd && calls.length < MAX_CALLS_PER_FUNCTION; index += 1) { + const line = lines[index] ?? ""; + regex.lastIndex = 0; + for (let match = regex.exec(line); match && calls.length < MAX_CALLS_PER_FUNCTION; match = regex.exec(line)) { + const callee = match[1]; + if (!callee || ignoredCalls.has(callee)) continue; + if (index === fn.bodyStart && callee === fn.node.name) continue; + calls.push({ callee, line: index + 1, direct: !callee.includes(".") }); + } + } + return calls; +} + +async function readBoundedSource(root: string, file: IndexFileInput): Promise<{ content?: string; reason?: string }> { + if (file.size > MAX_SOURCE_BYTES) return { reason: `source exceeds ${MAX_SOURCE_BYTES} bytes` }; + const absolute = resolve(root, file.path); + let fileStat; + try { + fileStat = await stat(absolute); + } catch { + return { reason: "source file is unavailable" }; + } + if (!fileStat.isFile()) return { reason: "path is not a regular file" }; + if (fileStat.size > MAX_SOURCE_BYTES) return { reason: `source exceeds ${MAX_SOURCE_BYTES} bytes` }; + return { content: await readFile(absolute, "utf8") }; +} + +export async function buildCallGraph(root: string, files: readonly IndexFileInput[]): Promise { + const selected = files.slice(0, MAX_FILES); + const nodes: CallGraphNode[] = []; + const edges: CallGraphEdge[] = []; + const skippedFiles: Array<{ path: string; reason: string }> = []; + + for (const file of selected) { + const extension = extname(file.path).toLowerCase(); + if (!jsExtensions.has(extension) && extension !== ".py" && extension !== ".go") continue; + + const source = await readBoundedSource(root, file); + if (!source.content) { + skippedFiles.push({ path: normalizedPath(file.path), reason: source.reason ?? "source unavailable" }); + continue; + } + + const parsed = extension === ".py" + ? parsePythonFunctions(file.path, source.content) + : extension === ".go" + ? parseGoFunctions(file.path, source.content) + : parseJavascriptFunctions(file.path, source.content); + const lines = source.content.split(/\r?\n/); + const sameFileByName = new Map(); + for (const fn of parsed) { + nodes.push(fn.node); + const bucket = sameFileByName.get(fn.node.name) ?? []; + bucket.push(fn.node); + sameFileByName.set(fn.node.name, bucket); + } + + for (const fn of parsed) { + for (const call of collectCalls(lines, fn)) { + const candidates = call.direct ? sameFileByName.get(call.callee) ?? [] : []; + const target = candidates.length === 1 ? candidates[0] : undefined; + edges.push({ + from: fn.node.id, + callee: call.callee, + line: call.line, + ...(target ? { target: target.id } : {}), + resolution: target ? "same-file-function" : "external-or-unresolved", + }); + } + } + } + + const resolvedEdgeCount = edges.filter((edge) => Boolean(edge.target)).length; + return { + schemaVersion: 1, + nodes: nodes.sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line || a.name.localeCompare(b.name)), + edges, + resolvedEdgeCount, + unresolvedEdgeCount: edges.length - resolvedEdgeCount, + skippedFiles, + interpretation: "lexical-call-evidence-only", + }; +} + +function traverse( + graph: CallGraph, + root: string, + direction: "callees" | "callers", + maxDepth: number, + maxNodes: number, +): Array<{ id: string; depth: number }> { + const boundedDepth = Math.max(0, maxDepth); + const boundedNodes = Math.max(1, maxNodes); + const queue: Array<{ id: string; depth: number }> = [{ id: root, depth: 0 }]; + const seen = new Set([root]); + const output: Array<{ id: string; depth: number }> = []; + + while (queue.length > 0 && output.length < boundedNodes) { + const current = queue.shift(); + if (!current || current.depth >= boundedDepth) continue; + const adjacent = graph.edges.flatMap((edge) => { + if (!edge.target) return []; + if (direction === "callees" && edge.from === current.id) return [edge.target]; + if (direction === "callers" && edge.target === current.id) return [edge.from]; + return []; + }); + + for (const id of [...new Set(adjacent)].sort()) { + if (seen.has(id)) continue; + seen.add(id); + const next = { id, depth: current.depth + 1 }; + output.push(next); + queue.push(next); + if (output.length >= boundedNodes) break; + } + } + return output; +} + +export function findCallNeighborhood( + graph: CallGraph, + root: string, + maxDepth = 3, + maxNodesPerDirection = 100, +): CallNeighborhood { + return { + root, + maxDepth: Math.max(0, maxDepth), + callees: traverse(graph, root, "callees", maxDepth, maxNodesPerDirection), + callers: traverse(graph, root, "callers", maxDepth, maxNodesPerDirection), + interpretation: "lexical-call-evidence-only", + }; +} diff --git a/packages/repository/src/coverage-context.ts b/packages/repository/src/coverage-context.ts new file mode 100644 index 00000000..9f8fe42c --- /dev/null +++ b/packages/repository/src/coverage-context.ts @@ -0,0 +1,167 @@ +import { isAbsolute, relative, resolve } from "node:path"; +import type { Finding } from "@synsec/core"; + +export interface CoverageLine { + line: number; + hits: number; +} + +export interface CoverageFile { + path: string; + lines: CoverageLine[]; +} + +export interface RepositoryCoverageIndex { + schemaVersion: 1; + format: "lcov"; + files: CoverageFile[]; + fileCount: number; + lineCount: number; + /** Coverage describes one supplied test run; it is not proof of production/runtime reachability. */ + interpretation: "observed-test-coverage-not-runtime-reachability"; +} + +export interface FindingCoverageContext { + path?: string; + line?: number; + status: "executed" | "not-executed" | "no-data"; + hits?: number; + interpretation: "observed-test-coverage-not-runtime-reachability"; +} + +export interface LcovParseOptions { + repositoryRoot?: string; +} + +const MAX_LCOV_BYTES = 16 * 1024 * 1024; +const MAX_COVERAGE_FILES = 10_000; +const MAX_COVERAGE_LINES = 1_000_000; +const MAX_PATH_LENGTH = 4096; +const MAX_LINE_NUMBER = 100_000_000; + +function normalizePath(value: string, repositoryRoot?: string): string | undefined { + const raw = value.trim(); + if (!raw || raw.length > MAX_PATH_LENGTH || raw.includes("\0")) return undefined; + let normalized = raw.replaceAll("\\", "/"); + + if (isAbsolute(raw)) { + if (!repositoryRoot) return undefined; + const root = resolve(repositoryRoot); + const rel = relative(root, resolve(raw)).replaceAll("\\", "/"); + if (!rel || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) return undefined; + normalized = rel; + } + + normalized = normalized.replace(/^\.\//, "").replace(/^\//, ""); + const pieces = normalized.split("/"); + if (pieces.some((piece) => piece === ".." || piece === "")) return undefined; + return normalized; +} + +function integer(value: string, label: string, max: number): number { + if (!/^\d+$/.test(value)) throw new Error(`LCOV ${label} must be a non-negative integer.`); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed > max) throw new Error(`LCOV ${label} exceeds its supported bound.`); + return parsed; +} + +/** + * Parse bounded LCOV text supplied by the operator or CI system. + * + * SynSec does not execute tests to create this data. Absolute source paths are accepted only when + * an explicit repository root is supplied and the path resolves inside it; escaping/unusable + * records are ignored rather than expanded outside repository scope. + */ +export function parseLcovCoverage(content: string, options: LcovParseOptions = {}): RepositoryCoverageIndex { + if (Buffer.byteLength(content, "utf8") > MAX_LCOV_BYTES) { + throw new Error(`LCOV input exceeds the ${MAX_LCOV_BYTES}-byte limit.`); + } + + const files = new Map>(); + let currentPath: string | undefined; + let lineCount = 0; + + for (const rawLine of content.split(/\r?\n/)) { + if (rawLine.startsWith("SF:")) { + currentPath = normalizePath(rawLine.slice(3), options.repositoryRoot); + if (currentPath && !files.has(currentPath.toLowerCase())) { + if (files.size >= MAX_COVERAGE_FILES) throw new Error(`LCOV input exceeds the ${MAX_COVERAGE_FILES}-file limit.`); + files.set(currentPath.toLowerCase(), new Map()); + } + continue; + } + if (rawLine === "end_of_record") { + currentPath = undefined; + continue; + } + if (!currentPath || !rawLine.startsWith("DA:")) continue; + + const [lineRaw, hitsRaw] = rawLine.slice(3).split(",", 3); + if (lineRaw === undefined || hitsRaw === undefined) continue; + const line = integer(lineRaw, "line number", MAX_LINE_NUMBER); + const hits = integer(hitsRaw, "hit count", Number.MAX_SAFE_INTEGER); + if (line <= 0) continue; + + const lines = files.get(currentPath.toLowerCase()); + if (!lines) continue; + if (!lines.has(line)) { + lineCount += 1; + if (lineCount > MAX_COVERAGE_LINES) throw new Error(`LCOV input exceeds the ${MAX_COVERAGE_LINES}-line limit.`); + } + const previous = lines.get(line) ?? 0; + lines.set(line, Math.min(Number.MAX_SAFE_INTEGER, previous + hits)); + } + + const normalizedFiles: CoverageFile[] = [...files.entries()] + .map(([pathKey, lines]) => ({ + path: pathKey, + lines: [...lines.entries()] + .map(([line, hits]) => ({ line, hits })) + .sort((a, b) => a.line - b.line), + })) + .sort((a, b) => a.path.localeCompare(b.path)); + + return { + schemaVersion: 1, + format: "lcov", + files: normalizedFiles, + fileCount: normalizedFiles.length, + lineCount, + interpretation: "observed-test-coverage-not-runtime-reachability", + }; +} + +export function findingCoverageContext( + coverage: RepositoryCoverageIndex, + finding: Finding, +): FindingCoverageContext { + const path = finding.location?.path ? normalizePath(finding.location.path) : undefined; + const line = finding.location?.startLine; + if (!path || !line || !Number.isSafeInteger(line) || line <= 0) { + return { + ...(path ? { path } : {}), + ...(line && Number.isSafeInteger(line) && line > 0 ? { line } : {}), + status: "no-data", + interpretation: "observed-test-coverage-not-runtime-reachability", + }; + } + + const file = coverage.files.find((item) => item.path.toLowerCase() === path.toLowerCase()); + const covered = file?.lines.find((item) => item.line === line); + if (!covered) { + return { + path, + line, + status: "no-data", + interpretation: "observed-test-coverage-not-runtime-reachability", + }; + } + + return { + path, + line, + status: covered.hits > 0 ? "executed" : "not-executed", + hits: covered.hits, + interpretation: "observed-test-coverage-not-runtime-reachability", + }; +} diff --git a/packages/repository/src/dependency-usage.ts b/packages/repository/src/dependency-usage.ts new file mode 100644 index 00000000..ece3b6f4 --- /dev/null +++ b/packages/repository/src/dependency-usage.ts @@ -0,0 +1,55 @@ +import type { DependencyUsage, ModuleEdge, RepositoryIndex } from "./analysis.js"; +import type { ModuleGraph, ResolvedModuleEdge } from "./module-graph.js"; + +export interface ExternalDependencyUsage extends DependencyUsage { + excludedRepositoryLocalImportCount: number; + /** Import syntax is useful triage evidence, but it does not establish runtime execution. */ + interpretation: "observed-import-evidence-not-runtime-reachability"; +} + +function moduleMatchesPackage(edge: ModuleEdge, packageName: string): boolean { + const specifier = edge.specifier.toLowerCase(); + const normalized = packageName.toLowerCase(); + const pythonNormalized = normalized.replaceAll("-", "_"); + if (specifier === normalized || specifier.startsWith(`${normalized}/`)) return true; + if (specifier === pythonNormalized || specifier.startsWith(`${pythonNormalized}.`)) return true; + return false; +} + +function edgeIdentity(edge: Pick): string { + return `${edge.from}\u0000${edge.kind}\u0000${edge.line}\u0000${edge.specifier}`; +} + +/** + * Report observed third-party package usage while excluding imports that the conservative module + * resolver proved refer to repository files. + * + * This remains import evidence only. It does not prove that an imported dependency executes at + * runtime, and unresolved imports remain eligible evidence rather than being guessed local. + */ +export function findExternalDependencyUsage( + index: RepositoryIndex, + graph: ModuleGraph, + packageName: string, + maxEvidence = 10, +): ExternalDependencyUsage { + const boundedEvidence = Number.isSafeInteger(maxEvidence) + ? Math.max(1, Math.min(100, maxEvidence)) + : 10; + const localEdges = new Set( + graph.edges + .filter((edge): edge is ResolvedModuleEdge & { target: string } => + edge.resolution === "repository-file" && typeof edge.target === "string") + .map(edgeIdentity), + ); + const matching = index.moduleEdges.filter((edge) => moduleMatchesPackage(edge, packageName)); + const externalEvidence = matching.filter((edge) => !localEdges.has(edgeIdentity(edge))); + const evidence = externalEvidence.slice(0, boundedEvidence); + return { + packageName, + status: evidence.length > 0 ? "observed-import" : "unknown", + evidence, + excludedRepositoryLocalImportCount: matching.length - externalEvidence.length, + interpretation: "observed-import-evidence-not-runtime-reachability", + }; +} diff --git a/packages/repository/src/django-route-handlers.ts b/packages/repository/src/django-route-handlers.ts new file mode 100644 index 00000000..afb2510b --- /dev/null +++ b/packages/repository/src/django-route-handlers.ts @@ -0,0 +1,165 @@ +import { lstat, readFile } from "node:fs/promises"; +import { isAbsolute, relative, resolve, sep } from "node:path"; +import type { IndexFileInput } from "./analysis.js"; +import { findCallNeighborhood, type CallGraph, type CallGraphNode } from "./call-graph.js"; +import type { ModuleGraph, ResolvedModuleEdge } from "./module-graph.js"; +import type { RouteEntrypoint } from "./route-entrypoints.js"; + +const MAX_SOURCE_BYTES = 512_000; + +interface PythonImportBinding { + localName: string; + importedName: string; + edge: ResolvedModuleEdge; +} + +function normalizePath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function insideRoot(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +async function safeReadSource(rootPath: string, file: IndexFileInput): Promise { + if (!file.path || file.path.includes("\0") || isAbsolute(file.path)) return undefined; + const root = resolve(rootPath); + const candidate = resolve(root, file.path); + if (!insideRoot(root, candidate)) return undefined; + const info = await lstat(candidate).catch(() => undefined); + if (!info?.isFile() || info.isSymbolicLink() || info.size > MAX_SOURCE_BYTES) return undefined; + return await readFile(candidate, "utf8").catch(() => undefined); +} + +function explicitDjangoViewName(line: string): string | undefined { + const match = line.match( + /\bpath\s*\(\s*["'][^"']+["']\s*,\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?:,|\))/, + ); + return match?.[1]; +} + +function parsePythonNamedImport(line: string, edge: ResolvedModuleEdge): PythonImportBinding[] { + const match = line.match(/^\s*from\s+([A-Za-z0-9_.]+)\s+import\s+(.+?)\s*(?:#.*)?$/); + if (!match?.[1] || match[1] !== edge.specifier || !match[2] || match[2].includes("(")) return []; + const output: PythonImportBinding[] = []; + for (const raw of match[2].split(",")) { + const part = raw.trim(); + if (!part || part === "*") continue; + const binding = part.match(/^([A-Za-z_][A-Za-z0-9_]*)(?:\s+as\s+([A-Za-z_][A-Za-z0-9_]*))?$/); + const importedName = binding?.[1]; + if (!importedName) continue; + output.push({ importedName, localName: binding?.[2] ?? importedName, edge }); + } + return output; +} + +function escapeIdentifier(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function bindingShadowedBeforeRoute(content: string, binding: PythonImportBinding, routeLine: number): boolean { + if (routeLine <= binding.edge.line) return true; + const escaped = escapeIdentifier(binding.localName); + const lines = content.split(/\r?\n/).slice(binding.edge.line, routeLine - 1); + const declaration = new RegExp(`^\\s*(?:async\\s+def|def|class)\\s+${escaped}\\b`); + const assignment = new RegExp(`^\\s*${escaped}\\s*(?::[^=]+)?=(?!=)`); + const loopBinding = new RegExp(`^\\s*(?:for|with)\\b[^:]*\\b${escaped}\\b`); + const importBinding = new RegExp(`^\\s*(?:from\\s+[^\\s]+\\s+import|import)\\b.*\\b${escaped}\\b`); + return lines.some((line) => declaration.test(line) || assignment.test(line) || loopBinding.test(line) || importBinding.test(line)); +} + +function uniqueNode(graph: CallGraph, path: string, name: string): CallGraphNode | undefined { + const matches = graph.nodes.filter( + (node) => normalizePath(node.path) === normalizePath(path) && node.name === name, + ); + return matches.length === 1 ? matches[0] : undefined; +} + +/** + * Resolve explicit Django URLConf function views without executing repository code. + * + * Supported forms are deliberately narrow: `path("route/", view_name)` where `view_name` is either + * one unique same-file Python function or one unshadowed `from module import name [as alias]` binding + * whose module graph edge resolves to exactly one repository file containing exactly one function + * with the imported name. Dotted members, class-based `as_view()`, lambdas, wrappers, wildcard or + * parenthesized imports, dynamic expressions, ambiguous targets, and shadowed bindings remain + * unresolved. Returned call neighborhoods are structural evidence only, not runtime reachability. + */ +export async function resolveDjangoRouteEntrypoints( + rootPath: string, + files: readonly IndexFileInput[], + moduleGraph: ModuleGraph, + graph: CallGraph, + entrypoints: readonly RouteEntrypoint[], + options: { maxCallDepth?: number; maxCallNodes?: number } = {}, +): Promise { + const fileByPath = new Map(files.map((file) => [normalizePath(file.path), file])); + const sourceCache = new Map(); + const maxCallDepth = Math.max(0, Math.min(20, options.maxCallDepth ?? 3)); + const maxCallNodes = Math.max(1, Math.min(1_000, options.maxCallNodes ?? 100)); + const output: RouteEntrypoint[] = []; + + async function sourceFor(path: string): Promise { + const normalized = normalizePath(path); + if (sourceCache.has(normalized)) return sourceCache.get(normalized); + const file = fileByPath.get(normalized); + const content = file ? await safeReadSource(rootPath, file) : undefined; + sourceCache.set(normalized, content); + return content; + } + + for (const entrypoint of entrypoints) { + const route = entrypoint.route; + if (entrypoint.resolution !== "unresolved" || route.frameworkHint !== "Django URLConf") { + output.push(entrypoint); + continue; + } + + const routePath = normalizePath(route.path); + const content = await sourceFor(routePath); + const routeLine = content?.split(/\r?\n/)[route.line - 1] ?? ""; + const localName = explicitDjangoViewName(routeLine); + if (!content || !localName) { + output.push(entrypoint); + continue; + } + + const candidates: CallGraphNode[] = []; + const sameFile = uniqueNode(graph, routePath, localName); + if (sameFile) candidates.push(sameFile); + + const lines = content.split(/\r?\n/); + for (const edge of moduleGraph.edges) { + if ( + normalizePath(edge.from) !== routePath || + edge.kind !== "python-import" || + edge.resolution !== "repository-file" || + !edge.target + ) continue; + const importLine = lines[edge.line - 1] ?? ""; + for (const binding of parsePythonNamedImport(importLine, edge)) { + if (binding.localName !== localName || bindingShadowedBeforeRoute(content, binding, route.line)) continue; + const target = uniqueNode(graph, edge.target, binding.importedName); + if (target) candidates.push(target); + } + } + + const distinct = [...new Map(candidates.map((node) => [node.id, node])).values()]; + const handler = distinct.length === 1 ? distinct[0] : undefined; + if (!handler) { + output.push(entrypoint); + continue; + } + + output.push({ + route, + resolution: handler.path === route.path ? "named-function" : "imported-named-function", + handler, + calls: findCallNeighborhood(graph, handler.id, maxCallDepth, maxCallNodes), + interpretation: "structural-route-call-evidence-only", + }); + } + + return output; +} diff --git a/packages/repository/src/django-urlconf-composition.ts b/packages/repository/src/django-urlconf-composition.ts new file mode 100644 index 00000000..8ad20a0d --- /dev/null +++ b/packages/repository/src/django-urlconf-composition.ts @@ -0,0 +1,201 @@ +import { lstat, readFile } from "node:fs/promises"; +import { isAbsolute, relative, resolve, sep } from "node:path"; +import type { IndexFileInput, RouteSignal } from "./analysis.js"; +import type { RouteEntrypoint } from "./route-entrypoints.js"; + +const MAX_SOURCE_BYTES = 512_000; +const DEFAULT_MAX_INCLUDE_DEPTH = 4; +const MAX_INCLUDE_DEPTH = 12; +const DEFAULT_MAX_COMPOSED_ROUTES = 1_000; +const MAX_COMPOSED_ROUTES = 5_000; + +interface DjangoIncludeEdge { + fromPath: string; + line: number; + prefix: string; + targetPath: string; +} + +function normalizePath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function insideRoot(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +async function safeReadSource(rootPath: string, file: IndexFileInput): Promise { + if (!file.path || file.path.includes("\0") || isAbsolute(file.path)) return undefined; + const root = resolve(rootPath); + const candidate = resolve(root, file.path); + if (!insideRoot(root, candidate)) return undefined; + const info = await lstat(candidate).catch(() => undefined); + if (!info?.isFile() || info.isSymbolicLink() || info.size > MAX_SOURCE_BYTES) return undefined; + return await readFile(candidate, "utf8").catch(() => undefined); +} + +function boundedInteger(value: number | undefined, fallback: number, maximum: number, label: string): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > maximum) { + throw new Error(`${label} must be an integer between 1 and ${maximum}.`); + } + return resolved; +} + +function explicitInclude(line: string): { prefix: string; module: string } | undefined { + const match = line.match( + /\bpath\s*\(\s*["']([^"']*)["']\s*,\s*include\s*\(\s*["']([A-Za-z_][A-Za-z0-9_.]*)["']\s*\)\s*(?:,|\))?/, + ); + if (match?.[1] === undefined || !match[2]) return undefined; + return { prefix: match[1], module: match[2] }; +} + +function resolveLiteralModule(moduleName: string, filePaths: ReadonlySet): string | undefined { + const stem = moduleName.split(".").join("/"); + const candidates = [`${stem}.py`, `${stem}/__init__.py`].filter((candidate) => filePaths.has(candidate)); + return candidates.length === 1 ? candidates[0] : undefined; +} + +function joinRoute(prefix: string, child: string): string { + const left = prefix.replace(/^\/+/, ""); + const right = child.replace(/^\/+/, ""); + if (!left) return right || "/"; + if (!right) return left; + if (left.endsWith("/")) return `${left}${right}`; + return `${left}/${right}`; +} + +function composedRoute(route: RouteSignal, prefix: string): RouteSignal { + return { + ...route, + route: joinRoute(prefix, route.route), + frameworkHint: "Django URLConf include", + }; +} + +function entrypointKey(entrypoint: RouteEntrypoint): string { + return [ + normalizePath(entrypoint.route.path), + entrypoint.route.line, + entrypoint.route.method, + entrypoint.route.route, + entrypoint.route.frameworkHint ?? "unknown-framework", + entrypoint.handler?.id ?? "unresolved", + ].join(":"); +} + +/** + * Add structural route identities for explicit Django `path("prefix/", include("module.urls"))` + * composition without importing or executing repository Python code. + * + * Only literal module strings that map to exactly one supplied repository file are followed. Include + * graphs are traversed from structurally root URLConfs (files not themselves targeted by another + * resolved include). Cycles, ambiguous module files, dynamic include expressions, tuple/list URLConfs, + * callable include targets, path converters built dynamically, and graphs without a structural root + * produce no composed evidence. Existing direct entrypoints are retained because static repository + * analysis cannot prove which URLConf is configured as Django's runtime ROOT_URLCONF. + * + * A composed route remains static structural evidence only. It does not prove ROOT_URLCONF selection, + * `include()` execution, namespace behavior, middleware execution, runtime reachability, attacker + * control, authorization, or exploitability. + */ +export async function composeDjangoIncludedRouteEntrypoints( + rootPath: string, + files: readonly IndexFileInput[], + entrypoints: readonly RouteEntrypoint[], + options: { maxIncludeDepth?: number; maxComposedRoutes?: number } = {}, +): Promise { + const maxIncludeDepth = boundedInteger( + options.maxIncludeDepth, + DEFAULT_MAX_INCLUDE_DEPTH, + MAX_INCLUDE_DEPTH, + "Django include max depth", + ); + const maxComposedRoutes = boundedInteger( + options.maxComposedRoutes, + DEFAULT_MAX_COMPOSED_ROUTES, + MAX_COMPOSED_ROUTES, + "Django include max composed routes", + ); + const pythonFiles = files.filter((file) => normalizePath(file.path).endsWith(".py")); + const filePaths = new Set(pythonFiles.map((file) => normalizePath(file.path))); + const edges: DjangoIncludeEdge[] = []; + + for (const file of pythonFiles) { + const fromPath = normalizePath(file.path); + const content = await safeReadSource(rootPath, file); + if (!content) continue; + const lines = content.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const include = explicitInclude(lines[index] ?? ""); + if (!include) continue; + const targetPath = resolveLiteralModule(include.module, filePaths); + if (!targetPath || targetPath === fromPath) continue; + edges.push({ fromPath, line: index + 1, prefix: include.prefix, targetPath }); + } + } + + if (edges.length === 0) return [...entrypoints]; + + const targetPaths = new Set(edges.map((edge) => edge.targetPath)); + const roots = [...new Set(edges.map((edge) => edge.fromPath).filter((path) => !targetPaths.has(path)))].sort(); + if (roots.length === 0) return [...entrypoints]; + + const edgesBySource = new Map(); + for (const edge of edges) { + const bucket = edgesBySource.get(edge.fromPath) ?? []; + bucket.push(edge); + bucket.sort((a, b) => a.line - b.line || a.prefix.localeCompare(b.prefix) || a.targetPath.localeCompare(b.targetPath)); + edgesBySource.set(edge.fromPath, bucket); + } + + const directByPath = new Map(); + for (const entrypoint of entrypoints) { + if (entrypoint.resolution === "unresolved" || !entrypoint.handler) continue; + if (entrypoint.route.frameworkHint !== "Django URLConf") continue; + const path = normalizePath(entrypoint.route.path); + const bucket = directByPath.get(path) ?? []; + bucket.push(entrypoint); + directByPath.set(path, bucket); + } + + const output = [...entrypoints]; + const seen = new Set(output.map(entrypointKey)); + let composedCount = 0; + + function addFrom( + sourcePath: string, + prefix: string, + depth: number, + ancestry: ReadonlySet, + ): void { + if (depth > maxIncludeDepth || composedCount >= maxComposedRoutes) return; + for (const edge of edgesBySource.get(sourcePath) ?? []) { + if (composedCount >= maxComposedRoutes || ancestry.has(edge.targetPath)) continue; + const nextPrefix = joinRoute(prefix, edge.prefix); + for (const child of directByPath.get(edge.targetPath) ?? []) { + if (composedCount >= maxComposedRoutes) break; + const candidate: RouteEntrypoint = { + ...child, + route: composedRoute(child.route, nextPrefix), + }; + const key = entrypointKey(candidate); + if (seen.has(key)) continue; + seen.add(key); + output.push(candidate); + composedCount += 1; + } + const nextAncestry = new Set(ancestry); + nextAncestry.add(edge.targetPath); + addFrom(edge.targetPath, nextPrefix, depth + 1, nextAncestry); + } + } + + for (const root of roots) { + addFrom(root, "", 1, new Set([root])); + if (composedCount >= maxComposedRoutes) break; + } + + return output; +} diff --git a/packages/repository/src/express-router-composition.ts b/packages/repository/src/express-router-composition.ts new file mode 100644 index 00000000..14bd13fc --- /dev/null +++ b/packages/repository/src/express-router-composition.ts @@ -0,0 +1,208 @@ +import { lstat, readFile } from "node:fs/promises"; +import { extname, isAbsolute, relative, resolve, sep } from "node:path"; +import type { IndexFileInput } from "./analysis.js"; +import type { ModuleGraph, ResolvedModuleEdge } from "./module-graph.js"; +import type { RouteEntrypoint } from "./route-entrypoints.js"; + +const MAX_SOURCE_BYTES = 512_000; +const JS_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"]); +const DEFAULT_MAX_MOUNT_DEPTH = 8; +const MAX_MOUNT_DEPTH = 32; +const DEFAULT_MAX_COMPOSED_ROUTES = 2_000; +const MAX_COMPOSED_ROUTES = 10_000; + +interface RouterNode { path: string; name: string; declarationLine: number; } +interface ImportedRouterBinding { localName: string; edge: ResolvedModuleEdge; } +interface RouterMountEdge { parent?: RouterNode; child: RouterNode; prefix: string; } + +export interface ExpressComposedRouteEntrypoint extends RouteEntrypoint { + composition: { rootPath: string; mountDepth: number; routerPath: string; routerName: string; prefixes: string[]; }; + compositionInterpretation: "structural-express-router-composition-not-runtime-reachability"; +} + +function normalizePath(value: string): string { return value.replaceAll("\\", "/").replace(/^\.\//, ""); } +function insideRoot(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} +function boundedInteger(value: number | undefined, fallback: number, maximum: number, label: string): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > maximum) throw new Error(`${label} must be an integer between 1 and ${maximum}.`); + return resolved; +} +function normalizePrefix(value: string): string { + if (!value || value === "/") return ""; + const segments = value.split("/").filter(Boolean); + return segments.length === 0 ? "" : `/${segments.join("/")}`; +} +function composeRoute(parts: readonly string[]): string { + const segments = parts.flatMap((part) => part.split("/").filter(Boolean)); + return segments.length === 0 ? "/" : `/${segments.join("/")}`; +} +function escapeIdentifier(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +async function safeReadSource(rootPath: string, file: IndexFileInput): Promise { + if (!file.path || file.path.includes("\0") || isAbsolute(file.path) || !JS_EXTENSIONS.has(extname(file.path).toLowerCase())) return undefined; + const root = resolve(rootPath); + const candidate = resolve(root, file.path); + if (!insideRoot(root, candidate)) return undefined; + const info = await lstat(candidate).catch(() => undefined); + if (!info?.isFile() || info.isSymbolicLink() || info.size > MAX_SOURCE_BYTES) return undefined; + const content = await readFile(candidate, "utf8").catch(() => undefined); + return content?.includes("\u0000") ? undefined : content; +} +function explicitExpressObjectBinding(content: string, beforeLine: number): boolean { + const lines = content.split(/\r?\n/).slice(0, Math.max(0, beforeLine - 1)); + return lines.some((line) => /^\s*import\s+express\s+from\s+["']express["']\s*;?\s*$/.test(line) + || /^\s*(?:const|let|var)\s+express\s*=\s*require\s*\(\s*["']express["']\s*\)\s*;?\s*$/.test(line)); +} +function explicitRouterFactoryBinding(content: string, beforeLine: number): boolean { + const lines = content.split(/\r?\n/).slice(0, Math.max(0, beforeLine - 1)); + return lines.some((line) => /^\s*import\s*\{\s*Router\s*\}\s*from\s*["']express["']\s*;?\s*$/.test(line) + || /^\s*(?:const|let|var)\s*\{\s*Router\s*\}\s*=\s*require\s*\(\s*["']express["']\s*\)\s*;?\s*$/.test(line)); +} +function parseRouterDeclaration(content: string, line: string, lineNumber: number): string | undefined { + const objectFactory = line.match(/^\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*express\.Router\s*\(\s*\)\s*;?\s*(?:\/\/.*)?$/); + if (objectFactory?.[1] && explicitExpressObjectBinding(content, lineNumber)) return objectFactory[1]; + const namedFactory = line.match(/^\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*Router\s*\(\s*\)\s*;?\s*(?:\/\/.*)?$/); + return namedFactory?.[1] && explicitRouterFactoryBinding(content, lineNumber) ? namedFactory[1] : undefined; +} +function parseAppDeclaration(content: string, line: string, lineNumber: number): string | undefined { + const match = line.match(/^\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*express\s*\(\s*\)\s*;?\s*(?:\/\/.*)?$/); + return match?.[1] && explicitExpressObjectBinding(content, lineNumber) ? match[1] : undefined; +} +function exportedAsDefault(content: string, routerName: string): boolean { + const escaped = escapeIdentifier(routerName); + return new RegExp(`^\\s*export\\s+default\\s+${escaped}\\s*;?\\s*$`, "m").test(content) + || new RegExp(`^\\s*module\\.exports\\s*=\\s*${escaped}\\s*;?\\s*$`, "m").test(content); +} +function parseImportedBinding(line: string, edge: ResolvedModuleEdge): ImportedRouterBinding | undefined { + if (edge.resolution !== "repository-file" || !edge.target) return undefined; + const specifier = escapeIdentifier(edge.specifier); + const esm = line.match(new RegExp(`^\\s*import\\s+([A-Za-z_$][\\w$]*)\\s+from\\s+["']${specifier}["']\\s*;?\\s*$`)); + if (esm?.[1]) return { localName: esm[1], edge }; + const cjs = line.match(new RegExp(`^\\s*(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*require\\s*\\(\\s*["']${specifier}["']\\s*\\)\\s*;?\\s*$`)); + return cjs?.[1] ? { localName: cjs[1], edge } : undefined; +} +function bindingShadowedBeforeUse(content: string, binding: ImportedRouterBinding, useLine: number): boolean { + if (useLine <= binding.edge.line) return true; + const escaped = escapeIdentifier(binding.localName); + const lines = content.split(/\r?\n/).slice(binding.edge.line, useLine - 1); + const declaration = new RegExp(`^\\s*(?:const|let|var|function|class)\\s+${escaped}\\b`); + const assignment = new RegExp(`^\\s*${escaped}\\s*=(?!=)`); + return lines.some((line) => declaration.test(line) || assignment.test(line)); +} +function parseMount(line: string): { parentName: string; childName: string; prefix: string } | undefined { + const match = line.match(/^\s*([A-Za-z_$][\w$]*)\.use\s*\(\s*(["'])([^"']*)\2\s*,\s*([A-Za-z_$][\w$]*)\s*\)\s*;?\s*(?:\/\/.*)?$/); + if (!match?.[1] || !match[4]) return undefined; + return { parentName: match[1], childName: match[4], prefix: normalizePrefix(match[3] ?? "") }; +} +function routerKey(path: string, name: string): string { return `${normalizePath(path)}\0${name}`; } +function routeKey(entrypoint: RouteEntrypoint): string { + return [normalizePath(entrypoint.route.path), entrypoint.route.line, entrypoint.route.method, entrypoint.route.route, entrypoint.handler?.id ?? ""].join("\0"); +} + +export async function composeExpressRouterEntrypoints( + rootPath: string, + files: readonly IndexFileInput[], + moduleGraph: ModuleGraph, + entrypoints: readonly RouteEntrypoint[], + options: { maxMountDepth?: number; maxComposedRoutes?: number } = {}, +): Promise { + const maxMountDepth = boundedInteger(options.maxMountDepth, DEFAULT_MAX_MOUNT_DEPTH, MAX_MOUNT_DEPTH, "Express router maxMountDepth"); + const maxComposedRoutes = boundedInteger(options.maxComposedRoutes, DEFAULT_MAX_COMPOSED_ROUTES, MAX_COMPOSED_ROUTES, "Express router maxComposedRoutes"); + const sourceByPath = new Map(); + for (const file of files) { const source = await safeReadSource(rootPath, file); if (source !== undefined) sourceByPath.set(normalizePath(file.path), source); } + const routers = new Map(); + const ambiguousRouters = new Set(); + const apps = new Map>(); + for (const [path, content] of sourceByPath) { + const lines = content.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const routerName = parseRouterDeclaration(content, lines[index] ?? "", index + 1); + if (routerName) { + const key = routerKey(path, routerName); + if (routers.has(key)) { routers.delete(key); ambiguousRouters.add(key); } + else if (!ambiguousRouters.has(key)) routers.set(key, { path, name: routerName, declarationLine: index + 1 }); + } + const appName = parseAppDeclaration(content, lines[index] ?? "", index + 1); + if (appName) apps.set(path, new Set([...(apps.get(path) ?? []), appName])); + } + } + const importsByPath = new Map(); + for (const edge of moduleGraph.edges) { + if ((edge.kind !== "import" && edge.kind !== "require") || edge.resolution !== "repository-file" || !edge.target) continue; + const path = normalizePath(edge.from); + const content = sourceByPath.get(path); + if (!content) continue; + const binding = parseImportedBinding(content.split(/\r?\n/)[edge.line - 1] ?? "", edge); + if (binding) importsByPath.set(path, [...(importsByPath.get(path) ?? []), binding]); + } + function resolveRouter(path: string, localName: string, useLine: number): RouterNode | undefined { + const normalized = normalizePath(path); + const sameFile = routers.get(routerKey(normalized, localName)); + if (sameFile && sameFile.declarationLine < useLine) return sameFile; + const content = sourceByPath.get(normalized); + if (!content) return undefined; + const candidates = (importsByPath.get(normalized) ?? []).filter((binding) => binding.localName === localName && !bindingShadowedBeforeUse(content, binding, useLine)); + if (candidates.length !== 1) return undefined; + const targetPath = candidates[0]?.edge.target; + const targetContent = targetPath ? sourceByPath.get(normalizePath(targetPath)) : undefined; + if (!targetPath || !targetContent) return undefined; + const exported = [...routers.values()].filter((router) => normalizePath(router.path) === normalizePath(targetPath) && exportedAsDefault(targetContent, router.name)); + return exported.length === 1 ? exported[0] : undefined; + } + const mounts: RouterMountEdge[] = []; + for (const [path, content] of sourceByPath) { + const lines = content.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const parsed = parseMount(lines[index] ?? ""); + if (!parsed) continue; + const line = index + 1; + const child = resolveRouter(path, parsed.childName, line); + if (!child) continue; + const isApp = apps.get(path)?.has(parsed.parentName) === true; + const parent = isApp ? undefined : resolveRouter(path, parsed.parentName, line); + if (isApp || parent) mounts.push({ ...(parent ? { parent } : {}), child, prefix: parsed.prefix }); + } + } + const routesByRouter = new Map(); + for (const entrypoint of entrypoints) { + if (entrypoint.route.frameworkHint !== "Node HTTP router" || entrypoint.route.method === "USE") continue; + const path = normalizePath(entrypoint.route.path); + const content = sourceByPath.get(path); + if (!content) continue; + const receiver = content.split(/\r?\n/)[entrypoint.route.line - 1]?.match(/^\s*([A-Za-z_$][\w$]*)\.(?:get|post|put|patch|delete|options|head)\s*\(/i)?.[1]; + if (!receiver) continue; + const router = routers.get(routerKey(path, receiver)); + if (!router || router.declarationLine >= entrypoint.route.line) continue; + routesByRouter.set(routerKey(router.path, router.name), [...(routesByRouter.get(routerKey(router.path, router.name)) ?? []), entrypoint]); + } + const output: RouteEntrypoint[] = [...entrypoints]; + const seen = new Set(output.map(routeKey)); + const walk = (router: RouterNode, prefixes: string[], depth: number, stack: Set, rootPath: string): void => { + if (depth > maxMountDepth || output.length - entrypoints.length >= maxComposedRoutes) return; + const key = routerKey(router.path, router.name); + if (stack.has(key)) return; + const nextStack = new Set(stack).add(key); + for (const entrypoint of routesByRouter.get(key) ?? []) { + const composed: ExpressComposedRouteEntrypoint = { + ...entrypoint, + route: { ...entrypoint.route, route: composeRoute([...prefixes, entrypoint.route.route]), frameworkHint: "Express composed router" }, + composition: { rootPath, mountDepth: depth, routerPath: router.path, routerName: router.name, prefixes: [...prefixes] }, + compositionInterpretation: "structural-express-router-composition-not-runtime-reachability", + }; + const keyValue = routeKey(composed); + if (!seen.has(keyValue)) { seen.add(keyValue); output.push(composed); } + if (output.length - entrypoints.length >= maxComposedRoutes) return; + } + if (depth >= maxMountDepth) return; + for (const mount of mounts) { + if (mount.parent && routerKey(mount.parent.path, mount.parent.name) === key) walk(mount.child, [...prefixes, mount.prefix], depth + 1, nextStack, rootPath); + } + }; + for (const root of mounts.filter((mount) => !mount.parent)) { + walk(root.child, [root.prefix], 1, new Set(), root.child.path); + if (output.length - entrypoints.length >= maxComposedRoutes) break; + } + return output; +} diff --git a/packages/repository/src/fastapi-route-dependencies.ts b/packages/repository/src/fastapi-route-dependencies.ts new file mode 100644 index 00000000..042731ed --- /dev/null +++ b/packages/repository/src/fastapi-route-dependencies.ts @@ -0,0 +1,395 @@ +import { lstat, readFile } from "node:fs/promises"; +import { isAbsolute, relative, resolve, sep } from "node:path"; +import type { AuthSignal, IndexFileInput, RepositoryIndex, RouteSignal } from "./analysis.js"; +import { findCallNeighborhood, type CallGraph, type CallGraphNode, type CallNeighborhood } from "./call-graph.js"; +import type { ModuleGraph, ResolvedModuleEdge } from "./module-graph.js"; +import type { RouteEntrypoint } from "./route-entrypoints.js"; + +const MAX_SOURCE_BYTES = 512_000; +const DEFAULT_MAX_ROUTES = 1_000; +const MAX_ROUTES = 5_000; +const DEFAULT_MAX_EVIDENCE = 100; +const MAX_EVIDENCE = 1_000; +const MAX_HANDLER_SIGNATURE_LENGTH = 16_384; +const MAX_HANDLER_DECLARATION_DISTANCE = 5; + +interface PythonImportBinding { + localName: string; + importedName: string; + edge: ResolvedModuleEdge; +} + +interface DependencyDeclaration { + name: string; + wrapper: "Depends" | "Security"; + source: "route-list" | "handler-parameter"; + useLine: number; + parameter?: string; +} + +export interface FastApiRouteDependency { + name: string; + wrapper: "Depends" | "Security"; + source: "route-list" | "handler-parameter"; + parameter?: string; + resolution: "same-file-function" | "imported-named-function" | "unresolved"; + node?: CallGraphNode; + calls?: CallNeighborhood; +} + +export interface FastApiRouteDependencyAuthEvidence { + path: string; + line: number; + kind: AuthSignal["kind"]; + dependency: string; + depth: number; +} + +export interface FastApiRouteDependencyContext { + route: RouteSignal; + handler?: string; + dependencies: FastApiRouteDependency[]; + authEvidence: FastApiRouteDependencyAuthEvidence[]; + status: "auth-signal-observed" | "no-auth-signal-observed"; + callScope: "dependency-and-bounded-callees"; + /** Dependency wiring and lexical auth signals are structural evidence, not proof of runtime protection. */ + interpretation: "structural-fastapi-dependency-evidence-not-runtime-protection"; +} + +function normalizePath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function insideRoot(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +function boundedInteger(value: number | undefined, fallback: number, maximum: number, label: string): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > maximum) { + throw new Error(`${label} must be an integer between 1 and ${maximum}.`); + } + return resolved; +} + +async function safeReadSource(rootPath: string, file: IndexFileInput): Promise { + if (!file.path || file.path.includes("\0") || isAbsolute(file.path)) return undefined; + const root = resolve(rootPath); + const candidate = resolve(root, file.path); + if (!insideRoot(root, candidate)) return undefined; + const info = await lstat(candidate).catch(() => undefined); + if (!info?.isFile() || info.isSymbolicLink() || info.size > MAX_SOURCE_BYTES) return undefined; + const content = await readFile(candidate, "utf8").catch(() => undefined); + return content?.includes("\u0000") ? undefined : content; +} + +function escapeIdentifier(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function explicitFastApiDecorator(line: string): boolean { + return /^\s*@(app|router)\.(get|post|put|patch|delete|options|head|route)\s*\(/i.test(line); +} + +function explicitFastApiWrappers(content: string, useLine: number): Set<"Depends" | "Security"> { + const wrappers = new Set<"Depends" | "Security">(); + const prefix = content.split(/\r?\n/).slice(0, Math.max(0, useLine - 1)); + for (const line of prefix) { + const match = line.match(/^\s*from\s+fastapi(?:\.[A-Za-z0-9_.]+)?\s+import\s+(.+?)\s*(?:#.*)?$/); + if (!match?.[1] || match[1].includes("(")) continue; + for (const raw of match[1].split(",")) { + const part = raw.trim(); + if (part === "Depends") wrappers.add("Depends"); + if (part === "Security") wrappers.add("Security"); + } + } + + for (const wrapper of [...wrappers]) { + const escaped = escapeIdentifier(wrapper); + const declaration = new RegExp(`^\\s*(?:async\\s+def|def|class)\\s+${escaped}\\b`); + const assignment = new RegExp(`^\\s*${escaped}\\s*(?::[^=]+)?=(?!=)`); + if (prefix.some((line) => declaration.test(line) || assignment.test(line))) wrappers.delete(wrapper); + } + return wrappers; +} + +function parseExplicitRouteDependencies( + line: string, + useLine: number, + allowedWrappers: ReadonlySet<"Depends" | "Security">, +): DependencyDeclaration[] | undefined { + const hasDependencies = /\bdependencies\s*=/.test(line); + const match = line.match(/\bdependencies\s*=\s*\[([^\]]*)\]/); + if (!match) return hasDependencies ? undefined : []; + const body = match[1]?.trim() ?? ""; + if (!body) return []; + const parts = body.split(",").map((value) => value.trim()).filter(Boolean); + const output: DependencyDeclaration[] = []; + for (const part of parts) { + const dependency = part.match(/^(Depends|Security)\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)$/); + const wrapper = dependency?.[1] as "Depends" | "Security" | undefined; + const name = dependency?.[2]; + if (!wrapper || !name || !allowedWrappers.has(wrapper)) return undefined; + output.push({ wrapper, name, source: "route-list", useLine }); + } + return output; +} + +function parseExplicitHandlerDependencies( + line: string, + useLine: number, + handlerName: string, + allowedWrappers: ReadonlySet<"Depends" | "Security">, +): DependencyDeclaration[] | undefined { + if (!line || line.length > MAX_HANDLER_SIGNATURE_LENGTH) return undefined; + const escapedHandler = escapeIdentifier(handlerName); + const signature = line.match(new RegExp(`^\\s*(?:async\\s+)?def\\s+${escapedHandler}\\s*\\((.*)\\)\\s*(?:->\\s*[^:]+)?\\s*:\\s*(?:#.*)?$`)); + if (!signature) return undefined; + const parameters = signature[1] ?? ""; + if (!/\b(?:Depends|Security)\s*\(/.test(parameters)) return []; + + const output: DependencyDeclaration[] = []; + const matcher = /(?:^|,)\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?::\s*[^=,]+)?=\s*(Depends|Security)\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)\s*(?=,|$)/g; + let match: RegExpExecArray | null; + while ((match = matcher.exec(parameters)) !== null) { + const parameter = match[1]; + const wrapper = match[2] as "Depends" | "Security" | undefined; + const name = match[3]; + if (!parameter || !wrapper || !name || !allowedWrappers.has(wrapper)) return undefined; + output.push({ parameter, wrapper, name, source: "handler-parameter", useLine }); + } + + const wrapperCount = [...parameters.matchAll(/\b(?:Depends|Security)\s*\(/g)].length; + return output.length === wrapperCount ? output : undefined; +} + +function parsePythonNamedImport(line: string, edge: ResolvedModuleEdge): PythonImportBinding[] { + const match = line.match(/^\s*from\s+([A-Za-z0-9_.]+)\s+import\s+(.+?)\s*(?:#.*)?$/); + if (!match?.[1] || match[1] !== edge.specifier || !match[2] || match[2].includes("(")) return []; + const output: PythonImportBinding[] = []; + for (const raw of match[2].split(",")) { + const part = raw.trim(); + if (!part || part === "*") continue; + const binding = part.match(/^([A-Za-z_][A-Za-z0-9_]*)(?:\s+as\s+([A-Za-z_][A-Za-z0-9_]*))?$/); + const importedName = binding?.[1]; + if (!importedName) continue; + output.push({ importedName, localName: binding?.[2] ?? importedName, edge }); + } + return output; +} + +function bindingShadowedBeforeUse(content: string, binding: PythonImportBinding, useLine: number): boolean { + if (useLine <= binding.edge.line) return true; + const escaped = escapeIdentifier(binding.localName); + const lines = content.split(/\r?\n/).slice(binding.edge.line, useLine - 1); + const declaration = new RegExp(`^\\s*(?:async\\s+def|def|class)\\s+${escaped}\\b`); + const assignment = new RegExp(`^\\s*${escaped}\\s*(?::[^=]+)?=(?!=)`); + const loopBinding = new RegExp(`^\\s*(?:for|with)\\b[^:]*\\b${escaped}\\b`); + const importBinding = new RegExp(`^\\s*(?:from\\s+[^\\s]+\\s+import|import)\\b.*\\b${escaped}\\b`); + return lines.some((line) => declaration.test(line) || assignment.test(line) || loopBinding.test(line) || importBinding.test(line)); +} + +function uniqueNode(graph: CallGraph, path: string, name: string): CallGraphNode | undefined { + const matches = graph.nodes.filter( + (node) => normalizePath(node.path) === normalizePath(path) && node.name === name, + ); + return matches.length === 1 ? matches[0] : undefined; +} + +function sameRoute(left: RouteSignal, right: RouteSignal): boolean { + return normalizePath(left.path) === normalizePath(right.path) + && left.line === right.line + && left.method === right.method + && left.route === right.route; +} + +function nearestFastApiHandler(graph: CallGraph, path: string, routeLine: number): CallGraphNode | undefined { + const candidates = graph.nodes + .filter((node) => ( + node.kind === "python-function" + && normalizePath(node.path) === normalizePath(path) + && node.line > routeLine + && node.line - routeLine <= MAX_HANDLER_DECLARATION_DISTANCE + )) + .sort((left, right) => left.line - right.line || left.name.localeCompare(right.name)); + const first = candidates[0]; + if (!first) return undefined; + const nearest = candidates.filter((candidate) => candidate.line === first.line); + return nearest.length === 1 ? first : undefined; +} + +function evidenceForDependency( + dependency: FastApiRouteDependency, + index: RepositoryIndex, + graph: CallGraph, + remaining: number, +): FastApiRouteDependencyAuthEvidence[] { + if (!dependency.node || remaining <= 0) return []; + const nodeById = new Map(graph.nodes.map((node) => [node.id, node])); + const scoped: Array<{ node: CallGraphNode; depth: number }> = [{ node: dependency.node, depth: 0 }]; + for (const callee of dependency.calls?.callees ?? []) { + const node = nodeById.get(callee.id); + if (node) scoped.push({ node, depth: callee.depth }); + } + + const output: FastApiRouteDependencyAuthEvidence[] = []; + for (const scope of scoped) { + for (const signal of index.authSignals) { + if ( + normalizePath(signal.path) !== normalizePath(scope.node.path) || + signal.line < scope.node.line || + signal.line > scope.node.endLine + ) continue; + output.push({ + path: signal.path, + line: signal.line, + kind: signal.kind, + dependency: dependency.name, + depth: scope.depth, + }); + if (output.length >= remaining) return output; + } + } + return output; +} + +/** + * Resolve explicit FastAPI route dependencies without importing or executing repository code. + * + * Supported forms are literal route-level `dependencies=[Depends(name), Security(name)]` entries and + * one-line handler parameters such as `user = Depends(require_user)`. Wrappers must be explicitly + * imported from FastAPI without aliasing and remain unshadowed at the use site. Dependency names + * resolve to one unique already-defined same-file Python function or one unshadowed explicit + * repository-local `from module import name [as alias]` binding. Factories, dotted members, dynamic + * lists, multiline signatures, nested expressions, wildcard/parenthesized imports, ambiguous targets, + * and shadowed names fail closed. Auth evidence is lexical evidence within the resolved dependency and + * bounded same-file callees; it is not proof the dependency executes, authorizes a request, or makes a + * route secure. The analyzer independently revalidates `@app` / `@router` decorator syntax and the + * nearest following Python function from bounded source/call-graph evidence instead of trusting a + * generic route framework hint to establish FastAPI identity or handler ownership. + */ +export async function buildFastApiRouteDependencyContexts( + rootPath: string, + files: readonly IndexFileInput[], + index: RepositoryIndex, + moduleGraph: ModuleGraph, + graph: CallGraph, + entrypoints: readonly RouteEntrypoint[], + options: { maxRoutes?: number; maxCallDepth?: number; maxCallNodes?: number; maxEvidence?: number } = {}, +): Promise { + const maxRoutes = boundedInteger(options.maxRoutes, DEFAULT_MAX_ROUTES, MAX_ROUTES, "FastAPI dependency maxRoutes"); + const maxCallDepth = Math.max(0, Math.min(20, options.maxCallDepth ?? 3)); + const maxCallNodes = Math.max(1, Math.min(1_000, options.maxCallNodes ?? 100)); + const maxEvidence = boundedInteger(options.maxEvidence, DEFAULT_MAX_EVIDENCE, MAX_EVIDENCE, "FastAPI dependency maxEvidence"); + const fileByPath = new Map(files.map((file) => [normalizePath(file.path), file])); + const sourceCache = new Map(); + const output: FastApiRouteDependencyContext[] = []; + + async function sourceFor(path: string): Promise { + const normalized = normalizePath(path); + if (sourceCache.has(normalized)) return sourceCache.get(normalized); + const file = fileByPath.get(normalized); + const source = file ? await safeReadSource(rootPath, file) : undefined; + sourceCache.set(normalized, source); + return source; + } + + for (const indexedRoute of index.routes) { + if (output.length >= maxRoutes) break; + const routePath = normalizePath(indexedRoute.path); + const content = await sourceFor(routePath); + if (!content) continue; + const lines = content.split(/\r?\n/); + const routeLine = lines[indexedRoute.line - 1] ?? ""; + if (!explicitFastApiDecorator(routeLine)) continue; + + const routeDeclarations = parseExplicitRouteDependencies( + routeLine, + indexedRoute.line, + explicitFastApiWrappers(content, indexedRoute.line), + ); + if (!routeDeclarations) continue; + + const entrypoint = entrypoints.find((candidate) => sameRoute(candidate.route, indexedRoute)); + const entrypointHandler = entrypoint?.handler; + const handler = entrypointHandler?.kind === "python-function" + && normalizePath(entrypointHandler.path) === routePath + ? entrypointHandler + : nearestFastApiHandler(graph, routePath, indexedRoute.line); + let handlerDeclarations: DependencyDeclaration[] = []; + if (handler) { + const handlerLine = lines[handler.line - 1] ?? ""; + const parsed = parseExplicitHandlerDependencies( + handlerLine, + handler.line, + handler.name, + explicitFastApiWrappers(content, handler.line), + ); + if (parsed === undefined && /\b(?:Depends|Security)\s*\(/.test(handlerLine)) continue; + handlerDeclarations = parsed ?? []; + } + + const declarations = [...routeDeclarations, ...handlerDeclarations]; + if (declarations.length === 0) continue; + const route: RouteSignal = { ...indexedRoute, frameworkHint: "FastAPI route decorator" }; + const dependencies: FastApiRouteDependency[] = []; + + for (const declaration of declarations) { + const candidates: Array<{ node: CallGraphNode; resolution: "same-file-function" | "imported-named-function" }> = []; + const sameFile = uniqueNode(graph, routePath, declaration.name); + if (sameFile && sameFile.line < declaration.useLine) { + candidates.push({ node: sameFile, resolution: "same-file-function" }); + } + + for (const edge of moduleGraph.edges) { + if ( + normalizePath(edge.from) !== routePath || + edge.kind !== "python-import" || + edge.resolution !== "repository-file" || + !edge.target + ) continue; + const importLine = lines[edge.line - 1] ?? ""; + for (const binding of parsePythonNamedImport(importLine, edge)) { + if ( + binding.localName !== declaration.name || + bindingShadowedBeforeUse(content, binding, declaration.useLine) + ) continue; + const target = uniqueNode(graph, edge.target, binding.importedName); + if (target) candidates.push({ node: target, resolution: "imported-named-function" }); + } + } + + const distinct = [...new Map(candidates.map((candidate) => [candidate.node.id, candidate])).values()]; + const resolved = distinct.length === 1 ? distinct[0] : undefined; + dependencies.push({ + name: declaration.name, + wrapper: declaration.wrapper, + source: declaration.source, + ...(declaration.parameter ? { parameter: declaration.parameter } : {}), + resolution: resolved?.resolution ?? "unresolved", + ...(resolved ? { + node: resolved.node, + calls: findCallNeighborhood(graph, resolved.node.id, maxCallDepth, maxCallNodes), + } : {}), + }); + } + + const authEvidence: FastApiRouteDependencyAuthEvidence[] = []; + for (const dependency of dependencies) { + authEvidence.push(...evidenceForDependency(dependency, index, graph, maxEvidence - authEvidence.length)); + if (authEvidence.length >= maxEvidence) break; + } + + output.push({ + route, + ...(handler ? { handler: handler.name } : {}), + dependencies, + authEvidence, + status: authEvidence.length > 0 ? "auth-signal-observed" : "no-auth-signal-observed", + callScope: "dependency-and-bounded-callees", + interpretation: "structural-fastapi-dependency-evidence-not-runtime-protection", + }); + } + return output; +} diff --git a/packages/repository/src/fastapi-router-composition.ts b/packages/repository/src/fastapi-router-composition.ts new file mode 100644 index 00000000..d9637fd3 --- /dev/null +++ b/packages/repository/src/fastapi-router-composition.ts @@ -0,0 +1,368 @@ +import { lstat, readFile } from "node:fs/promises"; +import { extname, isAbsolute, relative, resolve, sep } from "node:path"; +import type { IndexFileInput } from "./analysis.js"; +import { buildCallGraph, findCallNeighborhood, type CallGraph, type CallGraphNode } from "./call-graph.js"; +import type { ModuleGraph, ResolvedModuleEdge } from "./module-graph.js"; +import type { RouteEntrypoint } from "./route-entrypoints.js"; + +const MAX_SOURCE_BYTES = 512_000; +const DEFAULT_MAX_INCLUDE_DEPTH = 8; +const MAX_INCLUDE_DEPTH = 32; +const DEFAULT_MAX_COMPOSED_ROUTES = 2_000; +const MAX_COMPOSED_ROUTES = 10_000; +const DEFAULT_MAX_DECLARATION_DISTANCE = 5; + +interface RouterNode { + path: string; + name: string; + prefix: string; + declarationLine: number; +} + +interface ImportedRouterBinding { + localName: string; + importedName: string; + edge: ResolvedModuleEdge; +} + +interface RouterIncludeEdge { + parent?: RouterNode; + child: RouterNode; + prefix: string; +} + +export interface FastApiComposedRouteEntrypoint extends RouteEntrypoint { + composition: { + rootPath: string; + includeDepth: number; + routerPath: string; + routerName: string; + prefixes: string[]; + }; + /** Static router-prefix composition is not proof that FastAPI registers or serves the route. */ + compositionInterpretation: "structural-fastapi-router-composition-not-runtime-reachability"; +} + +function normalizePath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function insideRoot(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +function boundedInteger(value: number | undefined, fallback: number, maximum: number, label: string): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > maximum) { + throw new Error(`${label} must be an integer between 1 and ${maximum}.`); + } + return resolved; +} + +function normalizePrefix(value: string): string { + if (!value || value === "/") return ""; + const segments = value.split("/").filter(Boolean); + return segments.length === 0 ? "" : `/${segments.join("/")}`; +} + +function composeRoute(parts: readonly string[]): string { + const segments = parts.flatMap((part) => part.split("/").filter(Boolean)); + return segments.length === 0 ? "/" : `/${segments.join("/")}`; +} + +function escapeIdentifier(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +async function safeReadSource(rootPath: string, file: IndexFileInput): Promise { + if (!file.path || file.path.includes("\0") || isAbsolute(file.path) || extname(file.path).toLowerCase() !== ".py") { + return undefined; + } + const root = resolve(rootPath); + const candidate = resolve(root, file.path); + if (!insideRoot(root, candidate)) return undefined; + const info = await lstat(candidate).catch(() => undefined); + if (!info?.isFile() || info.isSymbolicLink() || info.size > MAX_SOURCE_BYTES) return undefined; + const content = await readFile(candidate, "utf8").catch(() => undefined); + return content?.includes("\u0000") ? undefined : content; +} + +function explicitFastApiRouterImport(content: string, beforeLine: number): boolean { + const lines = content.split(/\r?\n/).slice(0, Math.max(0, beforeLine - 1)); + let imported = false; + for (const line of lines) { + const match = line.match(/^\s*from\s+fastapi\s+import\s+(.+?)\s*(?:#.*)?$/); + if (!match?.[1] || match[1].includes("(")) continue; + for (const raw of match[1].split(",")) { + if (raw.trim() === "APIRouter") imported = true; + } + } + if (!imported) return false; + return !lines.some((line) => /^\s*(?:async\s+def|def|class)\s+APIRouter\b/.test(line) || /^\s*APIRouter\s*(?::[^=]+)?=(?!=)/.test(line)); +} + +function parseRouterDeclaration(line: string): { name: string; prefix: string } | undefined { + const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*APIRouter\s*\((.*)\)\s*(?:#.*)?$/); + const name = match?.[1]; + const args = match?.[2]?.trim(); + if (!name || args === undefined) return undefined; + if (!args) return { name, prefix: "" }; + const prefix = args.match(/^prefix\s*=\s*(["'])([^"']*)\1(?:\s*,\s*)?$/); + if (!prefix?.[2] && prefix?.[2] !== "") return undefined; + return { name, prefix: normalizePrefix(prefix[2]) }; +} + +function parseNamedImport(line: string, edge: ResolvedModuleEdge): ImportedRouterBinding[] { + const match = line.match(/^\s*from\s+([A-Za-z0-9_.]+)\s+import\s+(.+?)\s*(?:#.*)?$/); + if (!match?.[1] || match[1] !== edge.specifier || !match[2] || match[2].includes("(")) return []; + const output: ImportedRouterBinding[] = []; + for (const raw of match[2].split(",")) { + const part = raw.trim(); + if (!part || part === "*") continue; + const binding = part.match(/^([A-Za-z_][A-Za-z0-9_]*)(?:\s+as\s+([A-Za-z_][A-Za-z0-9_]*))?$/); + const importedName = binding?.[1]; + if (!importedName) continue; + output.push({ importedName, localName: binding?.[2] ?? importedName, edge }); + } + return output; +} + +function bindingShadowedBeforeUse(content: string, binding: ImportedRouterBinding, useLine: number): boolean { + if (useLine <= binding.edge.line) return true; + const escaped = escapeIdentifier(binding.localName); + const lines = content.split(/\r?\n/).slice(binding.edge.line, useLine - 1); + const declaration = new RegExp(`^\\s*(?:async\\s+def|def|class)\\s+${escaped}\\b`); + const assignment = new RegExp(`^\\s*${escaped}\\s*(?::[^=]+)?=(?!=)`); + const importBinding = new RegExp(`^\\s*(?:from\\s+[^\\s]+\\s+import|import)\\b.*\\b${escaped}\\b`); + return lines.some((line) => declaration.test(line) || assignment.test(line) || importBinding.test(line)); +} + +function parseInclude(line: string): { parentName?: string; childName: string; prefix: string } | undefined { + const match = line.match(/^\s*(app|[A-Za-z_][A-Za-z0-9_]*)\.include_router\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)(?:\s*,\s*prefix\s*=\s*(["'])([^"']*)\3)?\s*\)\s*(?:#.*)?$/); + const receiver = match?.[1]; + const childName = match?.[2]; + if (!receiver || !childName) return undefined; + return { + ...(receiver === "app" ? {} : { parentName: receiver }), + childName, + prefix: normalizePrefix(match?.[4] ?? ""), + }; +} + +function routerKey(path: string, name: string): string { + return `${normalizePath(path)}\0${name}`; +} + +function routeKey(entrypoint: RouteEntrypoint): string { + const handler = entrypoint.handler; + return [normalizePath(entrypoint.route.path), entrypoint.route.line, entrypoint.route.method, entrypoint.route.route, handler?.id ?? ""].join("\0"); +} + +function nearestFastApiHandler( + graph: CallGraph, + path: string, + routeLine: number, + maxDeclarationDistance: number, +): CallGraphNode | undefined { + const candidates = graph.nodes + .filter((node) => ( + node.kind === "python-function" + && normalizePath(node.path) === normalizePath(path) + && node.line > routeLine + && node.line - routeLine <= maxDeclarationDistance + )) + .sort((left, right) => left.line - right.line || left.name.localeCompare(right.name)); + const first = candidates[0]; + if (!first) return undefined; + const nearest = candidates.filter((candidate) => candidate.line === first.line); + return nearest.length === 1 ? first : undefined; +} + +/** + * Compose explicit FastAPI APIRouter prefixes across bounded include_router() relationships. + * + * Only one-line `name = APIRouter()` / `name = APIRouter(prefix="literal")` declarations with an + * explicit unaliased `from fastapi import APIRouter` are router nodes. Include edges must be exact + * `app.include_router(name[, prefix="literal"])` or `parent.include_router(name[, prefix="literal"])` + * calls. Imported child routers require an unshadowed repository-local named `from ... import ...` + * binding whose target contains exactly one matching APIRouter declaration. Dotted references, + * factories, dynamic prefixes, multiline expressions, wildcard imports, ambiguous declarations, + * and unresolved imports fail closed. Traversal starts only at explicit `app.include_router` roots, + * stops at repeated router nodes, and is bounded by depth/output limits. Because generic indexing can + * classify `@router.get(...)` as a Node-like route before FastAPI identity is known, this layer also + * independently revalidates the exact decorator and resolves only the unique nearest following Python + * function within a bounded declaration distance before carrying sink/call evidence into a composed + * route. The returned route identity is structural evidence; it is not proof that FastAPI imports, + * registers, or serves the route at runtime. + */ +export async function composeFastApiRouterEntrypoints( + rootPath: string, + files: readonly IndexFileInput[], + moduleGraph: ModuleGraph, + entrypoints: readonly RouteEntrypoint[], + options: { + maxIncludeDepth?: number; + maxComposedRoutes?: number; + maxDeclarationDistance?: number; + maxCallDepth?: number; + maxCallNodes?: number; + } = {}, +): Promise { + const maxIncludeDepth = boundedInteger(options.maxIncludeDepth, DEFAULT_MAX_INCLUDE_DEPTH, MAX_INCLUDE_DEPTH, "FastAPI router maxIncludeDepth"); + const maxComposedRoutes = boundedInteger(options.maxComposedRoutes, DEFAULT_MAX_COMPOSED_ROUTES, MAX_COMPOSED_ROUTES, "FastAPI router maxComposedRoutes"); + const maxDeclarationDistance = boundedInteger(options.maxDeclarationDistance, DEFAULT_MAX_DECLARATION_DISTANCE, 20, "FastAPI router maxDeclarationDistance"); + const maxCallDepth = Math.max(0, Math.min(20, options.maxCallDepth ?? 3)); + const maxCallNodes = Math.max(1, Math.min(1_000, options.maxCallNodes ?? 100)); + const graph = await buildCallGraph(rootPath, files); + const pythonFiles = files.filter((file) => extname(file.path).toLowerCase() === ".py"); + const sourceByPath = new Map(); + for (const file of pythonFiles) { + const source = await safeReadSource(rootPath, file); + if (source !== undefined) sourceByPath.set(normalizePath(file.path), source); + } + + const routers = new Map(); + const ambiguousRouters = new Set(); + for (const [path, content] of sourceByPath) { + const lines = content.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const parsed = parseRouterDeclaration(lines[index] ?? ""); + if (!parsed || !explicitFastApiRouterImport(content, index + 1)) continue; + const key = routerKey(path, parsed.name); + if (routers.has(key)) { + routers.delete(key); + ambiguousRouters.add(key); + continue; + } + if (!ambiguousRouters.has(key)) { + routers.set(key, { path, name: parsed.name, prefix: parsed.prefix, declarationLine: index + 1 }); + } + } + } + + const importBindingsByPath = new Map(); + for (const edge of moduleGraph.edges) { + if (edge.kind !== "python-import" || edge.resolution !== "repository-file" || !edge.target) continue; + const path = normalizePath(edge.from); + const content = sourceByPath.get(path); + if (!content) continue; + const line = content.split(/\r?\n/)[edge.line - 1] ?? ""; + const bindings = parseNamedImport(line, edge); + if (bindings.length === 0) continue; + importBindingsByPath.set(path, [...(importBindingsByPath.get(path) ?? []), ...bindings]); + } + + function resolveRouter(path: string, localName: string, useLine: number): RouterNode | undefined { + const normalized = normalizePath(path); + const sameFile = routers.get(routerKey(normalized, localName)); + if (sameFile && sameFile.declarationLine < useLine) return sameFile; + const content = sourceByPath.get(normalized); + if (!content) return undefined; + const candidates = (importBindingsByPath.get(normalized) ?? []).filter((binding) => ( + binding.localName === localName + && binding.edge.target + && !bindingShadowedBeforeUse(content, binding, useLine) + )); + if (candidates.length !== 1) return undefined; + const binding = candidates[0]; + return binding?.edge.target ? routers.get(routerKey(binding.edge.target, binding.importedName)) : undefined; + } + + const includeEdges: RouterIncludeEdge[] = []; + for (const [path, content] of sourceByPath) { + const lines = content.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const parsed = parseInclude(lines[index] ?? ""); + if (!parsed) continue; + const line = index + 1; + const child = resolveRouter(path, parsed.childName, line); + if (!child) continue; + const parent = parsed.parentName ? resolveRouter(path, parsed.parentName, line) : undefined; + if (parsed.parentName && !parent) continue; + includeEdges.push({ ...(parent ? { parent } : {}), child, prefix: parsed.prefix }); + } + } + + const routesByRouter = new Map(); + for (const entrypoint of entrypoints) { + const path = normalizePath(entrypoint.route.path); + const content = sourceByPath.get(path); + if (!content) continue; + const decorator = content.split(/\r?\n/)[entrypoint.route.line - 1] ?? ""; + const match = decorator.match(/^\s*@([A-Za-z_][A-Za-z0-9_]*)\.(?:get|post|put|patch|delete|options|head|route)\s*\(/i); + const routerName = match?.[1]; + if (!routerName || routerName === "app") continue; + const router = routers.get(routerKey(path, routerName)); + if (!router || router.declarationLine >= entrypoint.route.line) continue; + const resolvedHandler = entrypoint.handler ?? nearestFastApiHandler(graph, path, entrypoint.route.line, maxDeclarationDistance); + const resolvedEntrypoint: RouteEntrypoint = resolvedHandler + ? { + ...entrypoint, + resolution: entrypoint.handler ? entrypoint.resolution : "decorated-function", + handler: resolvedHandler, + calls: entrypoint.calls ?? findCallNeighborhood(graph, resolvedHandler.id, maxCallDepth, maxCallNodes), + } + : entrypoint; + const key = routerKey(router.path, router.name); + routesByRouter.set(key, [...(routesByRouter.get(key) ?? []), resolvedEntrypoint]); + } + + const roots = includeEdges.filter((edge) => edge.parent === undefined); + const childEdges = new Map(); + for (const edge of includeEdges) { + if (!edge.parent) continue; + const key = routerKey(edge.parent.path, edge.parent.name); + childEdges.set(key, [...(childEdges.get(key) ?? []), edge]); + } + + const composed: RouteEntrypoint[] = []; + const composedKeys = new Set(); + + function appendRoutes(router: RouterNode, prefixes: string[], depth: number, rootPath: string, seen: ReadonlySet): void { + if (composed.length >= maxComposedRoutes || depth > maxIncludeDepth) return; + const key = routerKey(router.path, router.name); + if (seen.has(key)) return; + const nextSeen = new Set(seen); + nextSeen.add(key); + const effectivePrefixes = [...prefixes, router.prefix].filter(Boolean); + + for (const entrypoint of routesByRouter.get(key) ?? []) { + if (composed.length >= maxComposedRoutes) return; + const route = composeRoute([...effectivePrefixes, entrypoint.route.route]); + const candidate: FastApiComposedRouteEntrypoint = { + ...entrypoint, + route: { + ...entrypoint.route, + route, + frameworkHint: "FastAPI composed router", + }, + composition: { + rootPath, + includeDepth: depth, + routerPath: router.path, + routerName: router.name, + prefixes: effectivePrefixes, + }, + compositionInterpretation: "structural-fastapi-router-composition-not-runtime-reachability", + }; + const candidateKey = routeKey(candidate); + if (!composedKeys.has(candidateKey)) { + composedKeys.add(candidateKey); + composed.push(candidate); + } + } + + for (const edge of childEdges.get(key) ?? []) { + appendRoutes(edge.child, [...effectivePrefixes, edge.prefix].filter(Boolean), depth + 1, rootPath, nextSeen); + } + } + + for (const root of roots) { + if (composed.length >= maxComposedRoutes) break; + appendRoutes(root.child, root.prefix ? [root.prefix] : [], 1, root.child.path, new Set()); + } + + const existingKeys = new Set(entrypoints.map(routeKey)); + return [...entrypoints, ...composed.filter((entrypoint) => !existingKeys.has(routeKey(entrypoint)))]; +} diff --git a/packages/repository/src/flask-blueprint-composition.ts b/packages/repository/src/flask-blueprint-composition.ts new file mode 100644 index 00000000..27a43d1d --- /dev/null +++ b/packages/repository/src/flask-blueprint-composition.ts @@ -0,0 +1,420 @@ +import { lstat, readFile } from "node:fs/promises"; +import { extname, isAbsolute, relative, resolve, sep } from "node:path"; +import type { IndexFileInput } from "./analysis.js"; +import { buildCallGraph, findCallNeighborhood, type CallGraph, type CallGraphNode } from "./call-graph.js"; +import type { ModuleGraph, ResolvedModuleEdge } from "./module-graph.js"; +import type { RouteEntrypoint } from "./route-entrypoints.js"; + +const MAX_SOURCE_BYTES = 512_000; +const DEFAULT_MAX_REGISTER_DEPTH = 8; +const MAX_REGISTER_DEPTH = 32; +const DEFAULT_MAX_COMPOSED_ROUTES = 2_000; +const MAX_COMPOSED_ROUTES = 10_000; +const DEFAULT_MAX_DECLARATION_DISTANCE = 5; + +interface FlaskAppNode { + path: string; + name: string; + declarationLine: number; +} + +interface FlaskBlueprintNode { + path: string; + name: string; + prefix: string; + declarationLine: number; +} + +interface ImportedBlueprintBinding { + localName: string; + importedName: string; + edge: ResolvedModuleEdge; +} + +interface BlueprintRegistrationEdge { + app?: FlaskAppNode; + parent?: FlaskBlueprintNode; + child: FlaskBlueprintNode; + prefix: string; +} + +interface BlueprintRoute { + blueprint: FlaskBlueprintNode; + entrypoint: RouteEntrypoint; +} + +export interface FlaskComposedRouteEntrypoint extends RouteEntrypoint { + composition: { + rootPath: string; + registerDepth: number; + blueprintPath: string; + blueprintName: string; + prefixes: string[]; + }; + /** Static blueprint composition is not proof that Flask imports, registers, or serves the route. */ + compositionInterpretation: "structural-flask-blueprint-composition-not-runtime-reachability"; +} + +function normalizePath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function insideRoot(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +function boundedInteger(value: number | undefined, fallback: number, maximum: number, label: string): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > maximum) { + throw new Error(`${label} must be an integer between 1 and ${maximum}.`); + } + return resolved; +} + +function normalizePrefix(value: string): string { + if (!value || value === "/") return ""; + const segments = value.split("/").filter(Boolean); + return segments.length === 0 ? "" : `/${segments.join("/")}`; +} + +function composeRoute(parts: readonly string[]): string { + const segments = parts.flatMap((part) => part.split("/").filter(Boolean)); + return segments.length === 0 ? "/" : `/${segments.join("/")}`; +} + +function escapeIdentifier(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +async function safeReadSource(rootPath: string, file: IndexFileInput): Promise { + if (!file.path || file.path.includes("\0") || isAbsolute(file.path) || extname(file.path).toLowerCase() !== ".py") { + return undefined; + } + const root = resolve(rootPath); + const candidate = resolve(root, file.path); + if (!insideRoot(root, candidate)) return undefined; + const info = await lstat(candidate).catch(() => undefined); + if (!info?.isFile() || info.isSymbolicLink() || info.size > MAX_SOURCE_BYTES) return undefined; + const content = await readFile(candidate, "utf8").catch(() => undefined); + return content?.includes("\u0000") ? undefined : content; +} + +function explicitFlaskImport(content: string, symbol: "Flask" | "Blueprint", beforeLine: number): boolean { + const lines = content.split(/\r?\n/).slice(0, Math.max(0, beforeLine - 1)); + let imported = false; + for (const line of lines) { + const match = line.match(/^\s*from\s+flask\s+import\s+(.+?)\s*(?:#.*)?$/); + if (!match?.[1] || match[1].includes("(")) continue; + for (const raw of match[1].split(",")) { + if (raw.trim() === symbol) imported = true; + } + } + if (!imported) return false; + const escaped = escapeIdentifier(symbol); + const declaration = new RegExp(`^\\s*(?:async\\s+def|def|class)\\s+${escaped}\\b`); + const assignment = new RegExp(`^\\s*${escaped}\\s*(?::[^=]+)?=(?!=)`); + return !lines.some((line) => declaration.test(line) || assignment.test(line)); +} + +function parseAppDeclaration(line: string): string | undefined { + const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*Flask\s*\(\s*__name__\s*\)\s*(?:#.*)?$/); + return match?.[1]; +} + +function parseBlueprintDeclaration(line: string): { name: string; prefix: string } | undefined { + const match = line.match( + /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*Blueprint\s*\(\s*(["'])([^"']+)\2\s*,\s*__name__(?:\s*,\s*url_prefix\s*=\s*(["'])([^"']*)\4)?\s*\)\s*(?:#.*)?$/, + ); + const name = match?.[1]; + if (!name) return undefined; + return { name, prefix: normalizePrefix(match?.[5] ?? "") }; +} + +function parseNamedImport(line: string, edge: ResolvedModuleEdge): ImportedBlueprintBinding[] { + const match = line.match(/^\s*from\s+([A-Za-z0-9_.]+)\s+import\s+(.+?)\s*(?:#.*)?$/); + if (!match?.[1] || match[1] !== edge.specifier || !match[2] || match[2].includes("(")) return []; + const output: ImportedBlueprintBinding[] = []; + for (const raw of match[2].split(",")) { + const part = raw.trim(); + if (!part || part === "*") continue; + const binding = part.match(/^([A-Za-z_][A-Za-z0-9_]*)(?:\s+as\s+([A-Za-z_][A-Za-z0-9_]*))?$/); + const importedName = binding?.[1]; + if (!importedName) continue; + output.push({ importedName, localName: binding?.[2] ?? importedName, edge }); + } + return output; +} + +function bindingShadowedBeforeUse(content: string, binding: ImportedBlueprintBinding, useLine: number): boolean { + if (useLine <= binding.edge.line) return true; + const escaped = escapeIdentifier(binding.localName); + const lines = content.split(/\r?\n/).slice(binding.edge.line, useLine - 1); + const declaration = new RegExp(`^\\s*(?:async\\s+def|def|class)\\s+${escaped}\\b`); + const assignment = new RegExp(`^\\s*${escaped}\\s*(?::[^=]+)?=(?!=)`); + const importBinding = new RegExp(`^\\s*(?:from\\s+[^\\s]+\\s+import|import)\\b.*\\b${escaped}\\b`); + return lines.some((line) => declaration.test(line) || assignment.test(line) || importBinding.test(line)); +} + +function parseRegistration(line: string): { receiver: string; childName: string; prefix: string } | undefined { + const match = line.match( + /^\s*([A-Za-z_][A-Za-z0-9_]*)\.register_blueprint\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)(?:\s*,\s*url_prefix\s*=\s*(["'])([^"']*)\3)?\s*\)\s*(?:#.*)?$/, + ); + const receiver = match?.[1]; + const childName = match?.[2]; + if (!receiver || !childName) return undefined; + return { receiver, childName, prefix: normalizePrefix(match?.[4] ?? "") }; +} + +function parseBlueprintRoute(line: string): { blueprintName: string; method: string; route: string } | undefined { + const match = line.match( + /^\s*@([A-Za-z_][A-Za-z0-9_]*)\.(get|post|put|patch|delete|options|head|route)\s*\(\s*(["'])([^"']+)\3(?:\s*,[^)]*)?\)\s*(?:#.*)?$/i, + ); + const blueprintName = match?.[1]; + const method = match?.[2]; + const route = match?.[4]; + if (!blueprintName || !method || route === undefined) return undefined; + return { blueprintName, method: method.toLowerCase() === "route" ? "ANY" : method.toUpperCase(), route }; +} + +function blueprintKey(path: string, name: string): string { + return `${normalizePath(path)}\0${name}`; +} + +function appKey(path: string, name: string): string { + return `${normalizePath(path)}\0${name}`; +} + +function routeKey(entrypoint: RouteEntrypoint): string { + const handler = entrypoint.handler; + return [normalizePath(entrypoint.route.path), entrypoint.route.line, entrypoint.route.method, entrypoint.route.route, handler?.id ?? ""].join("\0"); +} + +function nearestPythonHandler( + graph: CallGraph, + path: string, + routeLine: number, + maxDeclarationDistance: number, +): CallGraphNode | undefined { + const candidates = graph.nodes + .filter((node) => ( + node.kind === "python-function" + && normalizePath(node.path) === normalizePath(path) + && node.line > routeLine + && node.line - routeLine <= maxDeclarationDistance + )) + .sort((left, right) => left.line - right.line || left.name.localeCompare(right.name)); + const first = candidates[0]; + if (!first) return undefined; + const nearest = candidates.filter((candidate) => candidate.line === first.line); + return nearest.length === 1 ? first : undefined; +} + +/** + * Compose explicit Flask Blueprint url prefixes across bounded register_blueprint() relationships. + * + * This accepts only one-line, unaliased `from flask import Flask, Blueprint` imports, exact + * `app = Flask(__name__)` roots, literal `Blueprint("name", __name__[, url_prefix="..."])` + * declarations, and exact `register_blueprint(name[, url_prefix="..."])` calls. Imported blueprint + * bindings require one repository-local named Python import resolving to one matching declaration and + * must remain unshadowed before use. Route decorators are independently revalidated as exact named + * blueprint decorators and linked only to the unique nearest following Python function. Dynamic + * prefixes, factories, dotted blueprint references, imported Flask app roots, wildcard/parenthesized + * imports, ambiguous declarations, use-before-definition, cycles, and unresolved modules fail closed. + * Returned route identities are bounded structural evidence, not proof that Flask imports, registers, + * or serves a blueprint at runtime. + */ +export async function composeFlaskBlueprintEntrypoints( + rootPath: string, + files: readonly IndexFileInput[], + moduleGraph: ModuleGraph, + entrypoints: readonly RouteEntrypoint[], + options: { + maxRegisterDepth?: number; + maxComposedRoutes?: number; + maxDeclarationDistance?: number; + maxCallDepth?: number; + maxCallNodes?: number; + } = {}, +): Promise { + const maxRegisterDepth = boundedInteger(options.maxRegisterDepth, DEFAULT_MAX_REGISTER_DEPTH, MAX_REGISTER_DEPTH, "Flask blueprint maxRegisterDepth"); + const maxComposedRoutes = boundedInteger(options.maxComposedRoutes, DEFAULT_MAX_COMPOSED_ROUTES, MAX_COMPOSED_ROUTES, "Flask blueprint maxComposedRoutes"); + const maxDeclarationDistance = boundedInteger(options.maxDeclarationDistance, DEFAULT_MAX_DECLARATION_DISTANCE, 20, "Flask blueprint maxDeclarationDistance"); + const maxCallDepth = Math.max(0, Math.min(20, options.maxCallDepth ?? 3)); + const maxCallNodes = Math.max(1, Math.min(1_000, options.maxCallNodes ?? 100)); + const graph = await buildCallGraph(rootPath, files); + const sourceByPath = new Map(); + for (const file of files.filter((candidate) => extname(candidate.path).toLowerCase() === ".py")) { + const source = await safeReadSource(rootPath, file); + if (source !== undefined) sourceByPath.set(normalizePath(file.path), source); + } + + const apps = new Map(); + const blueprints = new Map(); + const ambiguousBlueprints = new Set(); + for (const [path, content] of sourceByPath) { + const lines = content.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? ""; + const appName = parseAppDeclaration(line); + if (appName && explicitFlaskImport(content, "Flask", index + 1)) { + const key = appKey(path, appName); + if (!apps.has(key)) apps.set(key, { path, name: appName, declarationLine: index + 1 }); + } + const parsedBlueprint = parseBlueprintDeclaration(line); + if (!parsedBlueprint || !explicitFlaskImport(content, "Blueprint", index + 1)) continue; + const key = blueprintKey(path, parsedBlueprint.name); + if (blueprints.has(key)) { + blueprints.delete(key); + ambiguousBlueprints.add(key); + continue; + } + if (!ambiguousBlueprints.has(key)) { + blueprints.set(key, { + path, + name: parsedBlueprint.name, + prefix: parsedBlueprint.prefix, + declarationLine: index + 1, + }); + } + } + } + + const importBindingsByPath = new Map(); + for (const edge of moduleGraph.edges) { + if (edge.kind !== "python-import" || edge.resolution !== "repository-file" || !edge.target) continue; + const path = normalizePath(edge.from); + const content = sourceByPath.get(path); + if (!content) continue; + const line = content.split(/\r?\n/)[edge.line - 1] ?? ""; + const bindings = parseNamedImport(line, edge); + if (bindings.length === 0) continue; + importBindingsByPath.set(path, [...(importBindingsByPath.get(path) ?? []), ...bindings]); + } + + function resolveBlueprint(path: string, localName: string, useLine: number): FlaskBlueprintNode | undefined { + const normalized = normalizePath(path); + const sameFile = blueprints.get(blueprintKey(normalized, localName)); + if (sameFile && sameFile.declarationLine < useLine) return sameFile; + const content = sourceByPath.get(normalized); + if (!content) return undefined; + const candidates = (importBindingsByPath.get(normalized) ?? []).filter((binding) => ( + binding.localName === localName + && binding.edge.target + && !bindingShadowedBeforeUse(content, binding, useLine) + )); + if (candidates.length !== 1) return undefined; + const binding = candidates[0]; + return binding?.edge.target ? blueprints.get(blueprintKey(binding.edge.target, binding.importedName)) : undefined; + } + + const registrations: BlueprintRegistrationEdge[] = []; + for (const [path, content] of sourceByPath) { + const lines = content.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const parsed = parseRegistration(lines[index] ?? ""); + if (!parsed) continue; + const line = index + 1; + const child = resolveBlueprint(path, parsed.childName, line); + if (!child) continue; + const app = apps.get(appKey(path, parsed.receiver)); + if (app && app.declarationLine < line) { + registrations.push({ app, child, prefix: parsed.prefix }); + continue; + } + const parent = resolveBlueprint(path, parsed.receiver, line); + if (parent) registrations.push({ parent, child, prefix: parsed.prefix }); + } + } + + const routesByBlueprint = new Map(); + for (const [path, content] of sourceByPath) { + const lines = content.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const parsed = parseBlueprintRoute(lines[index] ?? ""); + if (!parsed) continue; + const routeLine = index + 1; + const blueprint = blueprints.get(blueprintKey(path, parsed.blueprintName)); + if (!blueprint || blueprint.declarationLine >= routeLine) continue; + const handler = nearestPythonHandler(graph, path, routeLine, maxDeclarationDistance); + if (!handler) continue; + const entrypoint: RouteEntrypoint = { + route: { + path, + line: routeLine, + method: parsed.method, + route: parsed.route, + frameworkHint: "Flask blueprint", + }, + resolution: "decorated-function", + handler, + calls: findCallNeighborhood(graph, handler.id, maxCallDepth, maxCallNodes), + interpretation: "structural-route-call-evidence-only", + }; + const key = blueprintKey(blueprint.path, blueprint.name); + routesByBlueprint.set(key, [...(routesByBlueprint.get(key) ?? []), { blueprint, entrypoint }]); + } + } + + const childEdges = new Map(); + for (const edge of registrations) { + if (!edge.parent) continue; + const key = blueprintKey(edge.parent.path, edge.parent.name); + childEdges.set(key, [...(childEdges.get(key) ?? []), edge]); + } + + const composed: RouteEntrypoint[] = []; + const composedKeys = new Set(); + + function appendRoutes( + blueprint: FlaskBlueprintNode, + prefixes: string[], + depth: number, + rootPathValue: string, + seen: ReadonlySet, + ): void { + if (composed.length >= maxComposedRoutes || depth > maxRegisterDepth) return; + const key = blueprintKey(blueprint.path, blueprint.name); + if (seen.has(key)) return; + const nextSeen = new Set(seen); + nextSeen.add(key); + const effectivePrefixes = [...prefixes, blueprint.prefix].filter(Boolean); + + for (const route of routesByBlueprint.get(key) ?? []) { + if (composed.length >= maxComposedRoutes) return; + const candidate: FlaskComposedRouteEntrypoint = { + ...route.entrypoint, + route: { + ...route.entrypoint.route, + route: composeRoute([...effectivePrefixes, route.entrypoint.route.route]), + frameworkHint: "Flask composed blueprint", + }, + composition: { + rootPath: rootPathValue, + registerDepth: depth, + blueprintPath: blueprint.path, + blueprintName: blueprint.name, + prefixes: effectivePrefixes, + }, + compositionInterpretation: "structural-flask-blueprint-composition-not-runtime-reachability", + }; + const candidateKey = routeKey(candidate); + if (!composedKeys.has(candidateKey)) { + composedKeys.add(candidateKey); + composed.push(candidate); + } + } + + for (const edge of childEdges.get(key) ?? []) { + appendRoutes(edge.child, [...effectivePrefixes, edge.prefix].filter(Boolean), depth + 1, rootPathValue, nextSeen); + } + } + + for (const root of registrations.filter((edge) => edge.app !== undefined)) { + if (composed.length >= maxComposedRoutes) break; + appendRoutes(root.child, root.prefix ? [root.prefix] : [], 1, root.app?.path ?? root.child.path, new Set()); + } + + const existingKeys = new Set(entrypoints.map(routeKey)); + return [...entrypoints, ...composed.filter((entrypoint) => !existingKeys.has(routeKey(entrypoint)))]; +} diff --git a/packages/repository/src/gin-request-input-flow.ts b/packages/repository/src/gin-request-input-flow.ts new file mode 100644 index 00000000..3fc76f7a --- /dev/null +++ b/packages/repository/src/gin-request-input-flow.ts @@ -0,0 +1,268 @@ +import { lstat, readFile } from "node:fs/promises"; +import { isAbsolute, relative, resolve, sep } from "node:path"; +import type { SinkSignal } from "./analysis.js"; +import type { CallGraph, CallGraphNode } from "./call-graph.js"; +import type { RouteSinkFlowContext } from "./route-sink-flow.js"; + +export type GinRequestInputKind = "body" | "query" | "path" | "header" | "cookie"; + +export interface GinRequestInputEvidence { + source: { + path: string; + line: number; + kind: GinRequestInputKind; + access: string; + functionId: string; + functionName: string; + }; + sink: { + path: string; + line: number; + kind: SinkSignal["kind"]; + functionId: string; + functionName: string; + }; + callDistance: 0 | 1; +} + +export interface GinRouteRequestInputFlowContext { + route: RouteSinkFlowContext["route"]; + resolution: RouteSinkFlowContext["resolution"]; + handler: RouteSinkFlowContext["handler"]; + evidence: GinRequestInputEvidence[]; + sourceKinds: GinRequestInputKind[]; + sinkKinds: SinkSignal["kind"][]; + interpretation: "structural-gin-context-source-direct-call-sink-evidence-only"; +} + +export interface FindingGinRequestInputFlowEvidence { + method: string; + route: string; + frameworkHint?: string; + handler: string; + sourceKind: GinRequestInputKind; + sourceFunction: string; + sinkKind: SinkSignal["kind"]; + sinkFunction: string; + callDistance: 0 | 1; + interpretation: "structural-gin-context-source-direct-call-sink-evidence-only"; +} + +export interface GinRequestInputFlowOptions { + maxFiles?: number; + maxSourceBytes?: number; + maxEvidence?: number; + maxRoutes?: number; +} + +const DEFAULT_MAX_FILES = 5_000; +const MAX_FILES = 5_000; +const DEFAULT_MAX_SOURCE_BYTES = 512_000; +const MAX_SOURCE_BYTES = 2_000_000; +const DEFAULT_MAX_EVIDENCE = 12; +const MAX_EVIDENCE = 50; +const DEFAULT_MAX_ROUTES = 1_000; +const MAX_ROUTES = 5_000; + +function boundedInteger(value: number | undefined, fallback: number, maximum: number, label: string): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > maximum) { + throw new Error(`${label} must be an integer between 1 and ${maximum}.`); + } + return resolved; +} + +function normalizedPath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function comparisonPath(value: string): string { + return normalizedPath(value).replace(/^\//, "").toLowerCase(); +} + +function insideRoot(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +function ginContextParameter(node: CallGraphNode, lines: readonly string[]): string | undefined { + const declaration = lines[node.line - 1] ?? ""; + if (!/^\s*func\b/.test(declaration)) return undefined; + const contexts = [...declaration.matchAll(/\b([A-Za-z_][\w]*)\s+\*gin\.Context\b/g)].map((match) => match[1]); + return contexts.length === 1 ? contexts[0] : undefined; +} + +function requestAccesses(line: string, contextName: string): Array<{ kind: GinRequestInputKind; access: string }> { + const output: Array<{ kind: GinRequestInputKind; access: string }> = []; + const regex = /\b([A-Za-z_][\w]*)\.(Query|PostForm|Param|GetHeader|Cookie)\s*\(/g; + for (let match = regex.exec(line); match; match = regex.exec(line)) { + if (match[1] !== contextName) continue; + const member = match[2]; + if (member === "Query") output.push({ kind: "query", access: "gin.Context.Query" }); + else if (member === "PostForm") output.push({ kind: "body", access: "gin.Context.PostForm" }); + else if (member === "Param") output.push({ kind: "path", access: "gin.Context.Param" }); + else if (member === "GetHeader") output.push({ kind: "header", access: "gin.Context.GetHeader" }); + else if (member === "Cookie") output.push({ kind: "cookie", access: "gin.Context.Cookie" }); + } + return output; +} + +function hasIndependentSameLineSink(line: string, contextName: string, kind: SinkSignal["kind"]): boolean { + if (kind !== "database") return true; + const escaped = contextName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const withoutGinQuery = line.replace( + new RegExp(`\\b${escaped}\\.Query\\s*\\(`, "g"), + "__synsec_gin_request_query(", + ); + return /\b(query|execute|executemany|raw|rawQuery|createQueryRunner)\s*\(/i.test(withoutGinQuery); +} + +async function readSafeGoFiles( + rootPath: string, + graph: CallGraph, + options: GinRequestInputFlowOptions, +): Promise> { + const root = resolve(rootPath); + const maxFiles = boundedInteger(options.maxFiles, DEFAULT_MAX_FILES, MAX_FILES, "Gin request-flow maxFiles"); + const maxSourceBytes = boundedInteger( + options.maxSourceBytes, + DEFAULT_MAX_SOURCE_BYTES, + MAX_SOURCE_BYTES, + "Gin request-flow maxSourceBytes", + ); + const paths = [...new Set(graph.nodes.filter((node) => node.path.toLowerCase().endsWith(".go")).map((node) => normalizedPath(node.path)))].slice(0, maxFiles); + const output = new Map(); + + for (const path of paths) { + if (!path || path.includes("\0") || path.startsWith("../") || isAbsolute(path)) continue; + const absolute = resolve(root, path); + if (!insideRoot(root, absolute)) continue; + const info = await lstat(absolute).catch(() => undefined); + if (!info?.isFile() || info.isSymbolicLink() || info.size > maxSourceBytes) continue; + const source = await readFile(absolute, "utf8").catch(() => undefined); + if (!source || source.includes("\u0000")) continue; + output.set(comparisonPath(path), source.split(/\r?\n/)); + } + return output; +} + +function directTargets(graph: CallGraph, ownerId: string, line: number): string[] { + return [...new Set(graph.edges.flatMap((edge) => edge.from === ownerId && edge.line === line && edge.target ? [edge.target] : []))].sort(); +} + +/** + * Build deliberately narrow Gin request-source evidence from route flows already resolved by the + * strict Gin router composer. The source must be an explicit accessor on the exact `*gin.Context` + * parameter of a reachable Go function. The source line must either contain an independent exact + * sink itself or a direct call-graph edge to the sink-owning function. Generic lexical `Query(` sink + * matching is explicitly filtered when the apparent database sink is only `gin.Context.Query`. + * Bound-object APIs such as ShouldBind/BindJSON are intentionally excluded because proving the + * resulting variable flow would require a broader data-flow model. + */ +export async function buildGinRouteRequestInputFlowContexts( + rootPath: string, + routeFlows: readonly RouteSinkFlowContext[], + graph: CallGraph, + options: GinRequestInputFlowOptions = {}, +): Promise { + const maxEvidence = boundedInteger(options.maxEvidence, DEFAULT_MAX_EVIDENCE, MAX_EVIDENCE, "Gin request-flow maxEvidence"); + const maxRoutes = boundedInteger(options.maxRoutes, DEFAULT_MAX_ROUTES, MAX_ROUTES, "Gin request-flow maxRoutes"); + const files = await readSafeGoFiles(rootPath, graph, options); + const output: GinRouteRequestInputFlowContext[] = []; + + for (const routeFlow of routeFlows.slice(0, maxRoutes)) { + if (routeFlow.route.frameworkHint !== "Gin router") continue; + const reachableIds = new Set([routeFlow.handler.id, ...routeFlow.evidence.map((item) => item.functionId)]); + const evidence: GinRequestInputEvidence[] = []; + + for (const node of graph.nodes) { + if (!reachableIds.has(node.id) || node.kind !== "go-function") continue; + const lines = files.get(comparisonPath(node.path)); + if (!lines) continue; + const contextName = ginContextParameter(node, lines); + if (!contextName) continue; + + for (let lineNumber = node.line; lineNumber <= node.endLine && evidence.length < maxEvidence; lineNumber += 1) { + const line = lines[lineNumber - 1] ?? ""; + const accesses = requestAccesses(line, contextName); + if (accesses.length === 0) continue; + const targets = new Set(directTargets(graph, node.id, lineNumber)); + + for (const sink of routeFlow.evidence) { + const sameLineSink = sink.functionId === node.id && sink.line === lineNumber; + const distance: 0 | 1 | undefined = sameLineSink + ? hasIndependentSameLineSink(line, contextName, sink.kind) ? 0 : undefined + : targets.has(sink.functionId) ? 1 : undefined; + if (distance === undefined) continue; + for (const source of accesses) { + evidence.push({ + source: { + path: node.path, + line: lineNumber, + kind: source.kind, + access: source.access, + functionId: node.id, + functionName: node.name, + }, + sink: { + path: sink.path, + line: sink.line, + kind: sink.kind, + functionId: sink.functionId, + functionName: sink.functionName, + }, + callDistance: distance, + }); + if (evidence.length >= maxEvidence) break; + } + if (evidence.length >= maxEvidence) break; + } + } + if (evidence.length >= maxEvidence) break; + } + + if (evidence.length === 0) continue; + output.push({ + route: routeFlow.route, + resolution: routeFlow.resolution, + handler: routeFlow.handler, + evidence, + sourceKinds: [...new Set(evidence.map((item) => item.source.kind))], + sinkKinds: [...new Set(evidence.map((item) => item.sink.kind))], + interpretation: "structural-gin-context-source-direct-call-sink-evidence-only", + }); + } + return output; +} + +/** Return only aggregate structural evidence for an exact finding sink line. */ +export function findingGinRequestInputFlowEvidence( + contexts: readonly GinRouteRequestInputFlowContext[], + path: string, + line: number | undefined, + maxRoutes = 3, +): FindingGinRequestInputFlowEvidence[] { + if (!Number.isSafeInteger(line) || (line ?? 0) <= 0) return []; + const normalized = comparisonPath(path); + const limit = Math.max(1, Math.min(10, maxRoutes)); + const output: FindingGinRequestInputFlowEvidence[] = []; + for (const context of contexts) { + for (const item of context.evidence) { + if (comparisonPath(item.sink.path) !== normalized || item.sink.line !== line) continue; + output.push({ + method: context.route.method, + route: context.route.route, + ...(context.route.frameworkHint ? { frameworkHint: context.route.frameworkHint } : {}), + handler: context.handler.name, + sourceKind: item.source.kind, + sourceFunction: item.source.functionName, + sinkKind: item.sink.kind, + sinkFunction: item.sink.functionName, + callDistance: item.callDistance, + interpretation: "structural-gin-context-source-direct-call-sink-evidence-only", + }); + if (output.length >= limit) return output; + } + } + return output; +} diff --git a/packages/repository/src/gin-request-input-forwarding.ts b/packages/repository/src/gin-request-input-forwarding.ts new file mode 100644 index 00000000..71c9e9fe --- /dev/null +++ b/packages/repository/src/gin-request-input-forwarding.ts @@ -0,0 +1,301 @@ +import { lstat, readFile } from "node:fs/promises"; +import { isAbsolute, relative, resolve, sep } from "node:path"; +import type { SinkSignal } from "./analysis.js"; +import type { CallGraph, CallGraphNode } from "./call-graph.js"; +import type { GinRequestInputKind } from "./gin-request-input-flow.js"; +import type { RouteSinkFlowContext } from "./route-sink-flow.js"; + +export interface GinRequestInputForwardingEvidence { + source: { + path: string; + line: number; + kind: Exclude; + functionId: string; + functionName: string; + }; + binding: { + line: number; + useLine: number; + }; + sink: { + path: string; + line: number; + kind: SinkSignal["kind"]; + functionId: string; + functionName: string; + }; + callDistance: 0 | 1; +} + +export interface GinRouteRequestInputForwardingContext { + route: RouteSinkFlowContext["route"]; + resolution: RouteSinkFlowContext["resolution"]; + handler: RouteSinkFlowContext["handler"]; + evidence: GinRequestInputForwardingEvidence[]; + sourceKinds: Array>; + sinkKinds: SinkSignal["kind"][]; + interpretation: "structural-gin-context-source-single-use-local-call-sink-evidence-only"; +} + +export interface FindingGinRequestInputForwardingEvidence { + method: string; + route: string; + frameworkHint?: string; + handler: string; + sourceKind: Exclude; + sourceFunction: string; + sinkKind: SinkSignal["kind"]; + sinkFunction: string; + callDistance: 0 | 1; + bindingHops: 1; + interpretation: "structural-gin-context-source-single-use-local-call-sink-evidence-only"; +} + +export interface GinRequestInputForwardingOptions { + maxFiles?: number; + maxSourceBytes?: number; + maxEvidence?: number; + maxRoutes?: number; + maxForwardLines?: number; +} + +const DEFAULT_MAX_FILES = 5_000; +const MAX_FILES = 5_000; +const DEFAULT_MAX_SOURCE_BYTES = 512_000; +const MAX_SOURCE_BYTES = 2_000_000; +const DEFAULT_MAX_EVIDENCE = 12; +const MAX_EVIDENCE = 50; +const DEFAULT_MAX_ROUTES = 1_000; +const MAX_ROUTES = 5_000; +const DEFAULT_MAX_FORWARD_LINES = 8; +const MAX_FORWARD_LINES = 50; + +function boundedInteger(value: number | undefined, fallback: number, maximum: number, label: string): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > maximum) { + throw new Error(`${label} must be an integer between 1 and ${maximum}.`); + } + return resolved; +} + +function normalizedPath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function comparisonPath(value: string): string { + return normalizedPath(value).replace(/^\//, "").toLowerCase(); +} + +function insideRoot(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function ginContextParameter(node: CallGraphNode, lines: readonly string[]): string | undefined { + const declaration = lines[node.line - 1] ?? ""; + if (!/^\s*func\b/.test(declaration)) return undefined; + const contexts = [...declaration.matchAll(/\b([A-Za-z_][\w]*)\s+\*gin\.Context\b/g)].map((match) => match[1]); + return contexts.length === 1 ? contexts[0] : undefined; +} + +function sourceBinding( + line: string, + contextName: string, +): { name: string; kind: Exclude } | undefined { + const context = escapeRegex(contextName); + const match = new RegExp( + `^\\s*([A-Za-z_][\\w]*)\\s*:=\\s*${context}\\.(Query|PostForm|Param|GetHeader)\\s*\\([^\\r\\n]*\\)\\s*$`, + ).exec(line); + if (!match) return undefined; + const member = match[2]; + const kind: Exclude = member === "PostForm" + ? "body" + : member === "Param" + ? "path" + : member === "GetHeader" + ? "header" + : "query"; + return { name: match[1]!, kind }; +} + +function exactSingleArgumentCall(line: string, binding: string): boolean { + const value = escapeRegex(binding); + return new RegExp( + `^\\s*[A-Za-z_][\\w]*(?:\\.[A-Za-z_][\\w]*)?\\s*\\(\\s*${value}\\s*\\)\\s*$`, + ).test(line); +} + +function wordOccurrences(lines: readonly string[], startLine: number, endLine: number, name: string): number[] { + const output: number[] = []; + const pattern = new RegExp(`\\b${escapeRegex(name)}\\b`, "g"); + for (let lineNumber = startLine; lineNumber <= endLine; lineNumber += 1) { + const line = lines[lineNumber - 1] ?? ""; + pattern.lastIndex = 0; + let count = 0; + while (pattern.exec(line)) count += 1; + for (let index = 0; index < count; index += 1) output.push(lineNumber); + } + return output; +} + +async function readSafeGoFiles( + rootPath: string, + graph: CallGraph, + options: GinRequestInputForwardingOptions, +): Promise> { + const root = resolve(rootPath); + const maxFiles = boundedInteger(options.maxFiles, DEFAULT_MAX_FILES, MAX_FILES, "Gin request-forwarding maxFiles"); + const maxSourceBytes = boundedInteger( + options.maxSourceBytes, + DEFAULT_MAX_SOURCE_BYTES, + MAX_SOURCE_BYTES, + "Gin request-forwarding maxSourceBytes", + ); + const paths = [...new Set(graph.nodes.filter((node) => node.path.toLowerCase().endsWith(".go")).map((node) => normalizedPath(node.path)))].slice(0, maxFiles); + const output = new Map(); + for (const path of paths) { + if (!path || path.includes("\0") || path.startsWith("../") || isAbsolute(path)) continue; + const absolute = resolve(root, path); + if (!insideRoot(root, absolute)) continue; + const info = await lstat(absolute).catch(() => undefined); + if (!info?.isFile() || info.isSymbolicLink() || info.size > maxSourceBytes) continue; + const source = await readFile(absolute, "utf8").catch(() => undefined); + if (!source || source.includes("\u0000")) continue; + output.set(comparisonPath(path), source.split(/\r?\n/)); + } + return output; +} + +function directTargets(graph: CallGraph, ownerId: string, line: number): string[] { + return [...new Set(graph.edges.flatMap((edge) => edge.from === ownerId && edge.line === line && edge.target ? [edge.target] : []))].sort(); +} + +/** + * Recognize one deliberately narrow Go forwarding shape: + * + * value := c.Query("key") + * sink(value) + * + * The binding must have exactly one occurrence after its declaration anywhere in the containing + * function, that use must remain within maxForwardLines, and the use line must be exactly one + * single-argument call. This excludes reassignment, multiple use, transformation, aliasing and + * wider propagation without pretending Go locals are immutable by language semantics. + */ +export async function buildGinRouteRequestInputForwardingContexts( + rootPath: string, + routeFlows: readonly RouteSinkFlowContext[], + graph: CallGraph, + options: GinRequestInputForwardingOptions = {}, +): Promise { + const maxEvidence = boundedInteger(options.maxEvidence, DEFAULT_MAX_EVIDENCE, MAX_EVIDENCE, "Gin request-forwarding maxEvidence"); + const maxRoutes = boundedInteger(options.maxRoutes, DEFAULT_MAX_ROUTES, MAX_ROUTES, "Gin request-forwarding maxRoutes"); + const maxForwardLines = boundedInteger( + options.maxForwardLines, + DEFAULT_MAX_FORWARD_LINES, + MAX_FORWARD_LINES, + "Gin request-forwarding maxForwardLines", + ); + const files = await readSafeGoFiles(rootPath, graph, options); + const output: GinRouteRequestInputForwardingContext[] = []; + + for (const routeFlow of routeFlows.slice(0, maxRoutes)) { + if (routeFlow.route.frameworkHint !== "Gin router") continue; + const reachableIds = new Set([routeFlow.handler.id, ...routeFlow.evidence.map((item) => item.functionId)]); + const evidence: GinRequestInputForwardingEvidence[] = []; + + for (const node of graph.nodes) { + if (!reachableIds.has(node.id) || node.kind !== "go-function") continue; + const lines = files.get(comparisonPath(node.path)); + if (!lines) continue; + const contextName = ginContextParameter(node, lines); + if (!contextName) continue; + + for (let sourceLine = node.line + 1; sourceLine <= node.endLine && evidence.length < maxEvidence; sourceLine += 1) { + const binding = sourceBinding(lines[sourceLine - 1] ?? "", contextName); + if (!binding) continue; + const occurrences = wordOccurrences(lines, sourceLine + 1, node.endLine, binding.name); + if (occurrences.length !== 1) continue; + const useLine = occurrences[0]!; + if (useLine - sourceLine > maxForwardLines) continue; + const useText = lines[useLine - 1] ?? ""; + if (!exactSingleArgumentCall(useText, binding.name)) continue; + const targets = new Set(directTargets(graph, node.id, useLine)); + + for (const sink of routeFlow.evidence) { + const sameLineSink = sink.functionId === node.id && sink.line === useLine; + const distance: 0 | 1 | undefined = sameLineSink ? 0 : targets.has(sink.functionId) ? 1 : undefined; + if (distance === undefined) continue; + evidence.push({ + source: { + path: node.path, + line: sourceLine, + kind: binding.kind, + functionId: node.id, + functionName: node.name, + }, + binding: { line: sourceLine, useLine }, + sink: { + path: sink.path, + line: sink.line, + kind: sink.kind, + functionId: sink.functionId, + functionName: sink.functionName, + }, + callDistance: distance, + }); + break; + } + } + if (evidence.length >= maxEvidence) break; + } + + if (evidence.length === 0) continue; + output.push({ + route: routeFlow.route, + resolution: routeFlow.resolution, + handler: routeFlow.handler, + evidence, + sourceKinds: [...new Set(evidence.map((item) => item.source.kind))], + sinkKinds: [...new Set(evidence.map((item) => item.sink.kind))], + interpretation: "structural-gin-context-source-single-use-local-call-sink-evidence-only", + }); + } + return output; +} + +/** Return aggregate structural evidence for an exact finding sink line without source keys/values. */ +export function findingGinRequestInputForwardingEvidence( + contexts: readonly GinRouteRequestInputForwardingContext[], + path: string, + line: number | undefined, + maxRoutes = 3, +): FindingGinRequestInputForwardingEvidence[] { + if (!Number.isSafeInteger(line) || (line ?? 0) <= 0) return []; + const normalized = comparisonPath(path); + const limit = Math.max(1, Math.min(10, maxRoutes)); + const output: FindingGinRequestInputForwardingEvidence[] = []; + for (const context of contexts) { + for (const item of context.evidence) { + if (comparisonPath(item.sink.path) !== normalized || item.sink.line !== line) continue; + output.push({ + method: context.route.method, + route: context.route.route, + ...(context.route.frameworkHint ? { frameworkHint: context.route.frameworkHint } : {}), + handler: context.handler.name, + sourceKind: item.source.kind, + sourceFunction: item.source.functionName, + sinkKind: item.sink.kind, + sinkFunction: item.sink.functionName, + callDistance: item.callDistance, + bindingHops: 1, + interpretation: "structural-gin-context-source-single-use-local-call-sink-evidence-only", + }); + if (output.length >= limit) return output; + } + } + return output; +} diff --git a/packages/repository/src/gin-router-composition.ts b/packages/repository/src/gin-router-composition.ts new file mode 100644 index 00000000..30ffa48d --- /dev/null +++ b/packages/repository/src/gin-router-composition.ts @@ -0,0 +1,273 @@ +import { lstat, readFile } from "node:fs/promises"; +import { dirname, extname, isAbsolute, relative, resolve, sep } from "node:path"; +import type { IndexFileInput, RouteSignal } from "./analysis.js"; +import { findCallNeighborhood, type CallGraph, type CallGraphNode } from "./call-graph.js"; +import type { RouteEntrypoint } from "./route-entrypoints.js"; + +const MAX_SOURCE_BYTES = 512_000; +const DEFAULT_MAX_ROUTES = 2_000; +const MAX_ROUTES = 10_000; +const MAX_GROUP_DEPTH = 12; + +const HTTP_METHODS = new Map([ + ["GET", "GET"], + ["POST", "POST"], + ["PUT", "PUT"], + ["PATCH", "PATCH"], + ["DELETE", "DELETE"], + ["OPTIONS", "OPTIONS"], + ["HEAD", "HEAD"], +]); + +interface GinScope { + name: string; + line: number; + prefix: string; + middleware: string[]; + depth: number; +} + +export interface GinRouteMiddlewareContext { + route: RouteSignal; + scope: { + name: string; + line: number; + prefix: string; + depth: number; + }; + handler: string; + middleware: Array<{ name: string; source: "group" | "route"; line: number }>; + /** Syntax-level Gin attachment only; this does not prove middleware executes or protects a request. */ + interpretation: "structural-gin-route-middleware-attachment-not-runtime-protection"; +} + +export interface GinRouterCompositionResult { + entrypoints: RouteEntrypoint[]; + middlewareContexts: GinRouteMiddlewareContext[]; +} + +function normalizePath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function insideRoot(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +function boundedInteger(value: number | undefined, fallback: number, maximum: number, label: string): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > maximum) { + throw new Error(`${label} must be an integer between 1 and ${maximum}.`); + } + return resolved; +} + +function normalizeRoute(value: string): string { + const segments = value.split("/").filter(Boolean); + return segments.length === 0 ? "" : `/${segments.join("/")}`; +} + +function composeRoute(prefix: string, child: string): string { + const segments = [prefix, child].flatMap((value) => value.split("/").filter(Boolean)); + return segments.length === 0 ? "/" : `/${segments.join("/")}`; +} + +async function safeReadSource(rootPath: string, file: IndexFileInput): Promise { + if (extname(file.path).toLowerCase() !== ".go") return undefined; + if (!file.path || file.path.includes("\0") || isAbsolute(file.path)) return undefined; + const root = resolve(rootPath); + const candidate = resolve(root, file.path); + if (!insideRoot(root, candidate)) return undefined; + const info = await lstat(candidate).catch(() => undefined); + if (!info?.isFile() || info.isSymbolicLink() || info.size > MAX_SOURCE_BYTES) return undefined; + const content = await readFile(candidate, "utf8").catch(() => undefined); + return content?.includes("\u0000") ? undefined : content; +} + +function hasUnaliasedGinImport(lines: readonly string[]): boolean { + let inImportBlock = false; + let imports = 0; + for (const line of lines) { + if (/^\s*import\s*\(\s*$/.test(line)) { + inImportBlock = true; + continue; + } + if (inImportBlock && /^\s*\)\s*$/.test(line)) { + inImportBlock = false; + continue; + } + if (/^\s*import\s+"github\.com\/gin-gonic\/gin"\s*$/.test(line)) { + imports += 1; + continue; + } + if (inImportBlock && /^\s*"github\.com\/gin-gonic\/gin"\s*$/.test(line)) { + imports += 1; + continue; + } + if (/github\.com\/gin-gonic\/gin/.test(line)) return false; + } + return imports === 1; +} + +function parsePlainArguments(value: string): string[] | undefined { + if (!value.trim()) return []; + const args = value.split(",").map((part) => part.trim()); + if (args.some((arg) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(arg))) return undefined; + return args; +} + +function samePackageHandler(graph: CallGraph, path: string, name: string): CallGraphNode | undefined { + const directory = dirname(path).replaceAll("\\", "/"); + const matches = graph.nodes.filter((node) => { + if (node.kind !== "go-function" || node.name !== name) return false; + return dirname(normalizePath(node.path)).replaceAll("\\", "/") === directory; + }); + return matches.length === 1 ? matches[0] : undefined; +} + +function routeKey(entrypoint: RouteEntrypoint): string { + return [ + normalizePath(entrypoint.route.path), + entrypoint.route.line, + entrypoint.route.method, + entrypoint.route.route, + entrypoint.route.frameworkHint ?? "", + entrypoint.route.handler ?? "", + entrypoint.handler?.id ?? "", + ].join("\0"); +} + +function bindingReassigned(lines: readonly string[], name: string, declarationLine: number, useLine: number): boolean { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const assignment = new RegExp(`^\\s*${escaped}\\s*(?::=|=(?!=))`); + for (let index = declarationLine; index < useLine - 1; index += 1) { + if (assignment.test(lines[index] ?? "")) return true; + } + return false; +} + +/** + * Resolve a deliberately narrow subset of Gin router/group registrations into structural route + * entrypoints. Accepted syntax requires one unaliased gin import, a direct `name := gin.Default()` + * or `gin.New()` root, literal `Group("/prefix", plainMiddleware...)` composition, and one-line HTTP + * registrations whose callbacks are plain identifiers. The final callback is the handler; preceding + * callbacks plus inherited group callbacks are retained only as review-level middleware attachment. + * + * Handler resolution is limited to one unique Go function in the same package directory. Dynamic + * prefixes, aliases, factories, member-expression handlers, transformed middleware, reassigned scope + * bindings, ambiguous functions, unsafe files, and over-deep group composition fail closed. This does + * not prove Gin registers or serves the route, middleware executes, input is attacker-controlled, or + * a finding is exploitable. + */ +export async function composeGinRouterEntrypoints( + rootPath: string, + files: readonly IndexFileInput[], + graph: CallGraph, + entrypoints: readonly RouteEntrypoint[], + options: { maxRoutes?: number; maxCallDepth?: number; maxCallNodes?: number } = {}, +): Promise { + const maxRoutes = boundedInteger(options.maxRoutes, DEFAULT_MAX_ROUTES, MAX_ROUTES, "Gin maxRoutes"); + const maxCallDepth = Math.max(0, Math.min(20, options.maxCallDepth ?? 3)); + const maxCallNodes = Math.max(1, Math.min(1_000, options.maxCallNodes ?? 100)); + const output = [...entrypoints]; + const existing = new Set(output.map(routeKey)); + const middlewareContexts: GinRouteMiddlewareContext[] = []; + let produced = 0; + + for (const file of files) { + if (produced >= maxRoutes) break; + const content = await safeReadSource(rootPath, file); + if (content === undefined) continue; + const lines = content.split(/\r?\n/); + if (!hasUnaliasedGinImport(lines)) continue; + const path = normalizePath(file.path); + const scopes = new Map(); + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? ""; + const rootMatch = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:=\s*gin\.(?:Default|New)\s*\(\s*\)\s*$/); + if (rootMatch?.[1]) { + if (scopes.has(rootMatch[1])) scopes.delete(rootMatch[1]); + else scopes.set(rootMatch[1], { name: rootMatch[1], line: index + 1, prefix: "", middleware: [], depth: 0 }); + continue; + } + + const groupMatch = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:=\s*([A-Za-z_][A-Za-z0-9_]*)\.Group\s*\(\s*"([^"]*)"\s*(?:,\s*(.*?))?\s*\)\s*$/); + if (!groupMatch?.[1] || !groupMatch[2] || groupMatch[3] === undefined) continue; + const parent = scopes.get(groupMatch[2]); + if (!parent || parent.line >= index + 1 || parent.depth >= MAX_GROUP_DEPTH || bindingReassigned(lines, parent.name, parent.line, index + 1)) continue; + const middleware = parsePlainArguments(groupMatch[4] ?? ""); + if (!middleware) continue; + if (scopes.has(groupMatch[1])) { + scopes.delete(groupMatch[1]); + continue; + } + scopes.set(groupMatch[1], { + name: groupMatch[1], + line: index + 1, + prefix: composeRoute(parent.prefix, normalizeRoute(groupMatch[3])), + middleware: [...parent.middleware, ...middleware], + depth: parent.depth + 1, + }); + } + + for (let index = 0; index < lines.length && produced < maxRoutes; index += 1) { + const line = lines[index] ?? ""; + const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\.(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s*\(\s*"([^"]*)"\s*,\s*(.*?)\s*\)\s*$/); + if (!match?.[1] || !match[2] || match[3] === undefined || match[4] === undefined) continue; + const scope = scopes.get(match[1]); + if (!scope || scope.line >= index + 1 || bindingReassigned(lines, scope.name, scope.line, index + 1)) continue; + const method = HTTP_METHODS.get(match[2]); + if (!method) continue; + const callbacks = parsePlainArguments(match[4]); + if (!callbacks || callbacks.length === 0) continue; + const handlerName = callbacks.at(-1); + if (!handlerName) continue; + const routeMiddleware = callbacks.slice(0, -1); + const route: RouteSignal = { + path, + line: index + 1, + method, + route: composeRoute(scope.prefix, normalizeRoute(match[3])), + frameworkHint: "Gin router", + handler: handlerName, + }; + const handler = samePackageHandler(graph, path, handlerName); + const entrypoint: RouteEntrypoint = handler + ? { + route, + resolution: "named-function", + handler, + calls: findCallNeighborhood(graph, handler.id, maxCallDepth, maxCallNodes), + interpretation: "structural-route-call-evidence-only", + } + : { + route, + resolution: "unresolved", + interpretation: "structural-route-call-evidence-only", + }; + const key = routeKey(entrypoint); + if (existing.has(key)) continue; + existing.add(key); + output.push(entrypoint); + produced += 1; + + const middleware = [ + ...scope.middleware.map((name) => ({ name, source: "group" as const, line: scope.line })), + ...routeMiddleware.map((name) => ({ name, source: "route" as const, line: index + 1 })), + ]; + if (middleware.length > 0) { + middlewareContexts.push({ + route, + scope: { name: scope.name, line: scope.line, prefix: scope.prefix, depth: scope.depth }, + handler: handlerName, + middleware, + interpretation: "structural-gin-route-middleware-attachment-not-runtime-protection", + }); + } + } + } + + return { entrypoints: output, middlewareContexts }; +} diff --git a/packages/repository/src/import-call-links.ts b/packages/repository/src/import-call-links.ts new file mode 100644 index 00000000..fc81175f --- /dev/null +++ b/packages/repository/src/import-call-links.ts @@ -0,0 +1,338 @@ +import { lstat, readFile } from "node:fs/promises"; +import { extname, isAbsolute, relative, resolve, sep } from "node:path"; +import type { IndexFileInput } from "./analysis.js"; +import type { CallGraph } from "./call-graph.js"; +import type { ModuleGraph } from "./module-graph.js"; + +export type ImportCallBindingKind = + | "javascript-named-import" + | "javascript-namespace-import" + | "commonjs-destructured-require" + | "python-from-import" + | "python-module-import"; + +export interface ImportCallLink { + from: string; + line: number; + callee: string; + target: string; + targetPath: string; + importedName: string; + bindingKind: ImportCallBindingKind; + /** + * This link exists only because an explicit import binding resolved to one + * repository-local module and exactly one lexical function with that name. + */ + evidence: "explicit-import-binding-to-unique-local-function"; +} + +export interface ImportCallLinkGraph { + schemaVersion: 1; + links: ImportCallLink[]; + linkedCallCount: number; + /** Import/call syntax is structural review evidence, not runtime data flow. */ + interpretation: "cross-module-import-call-evidence-only"; +} + +interface ImportBinding { + fromPath: string; + line: number; + targetPath: string; + localName: string; + importedName?: string; + namespace: boolean; + kind: ImportCallBindingKind; +} + +const MAX_SOURCE_BYTES = 512_000; +const MAX_FILES = 5_000; +const MAX_BINDINGS_PER_FILE = 500; +const MAX_LINKS = 10_000; +const jsExtensions = new Set([".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"]); + +function normalizedPath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function insideRoot(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +async function readBoundedSource(root: string, file: IndexFileInput): Promise { + if (file.size > MAX_SOURCE_BYTES) return undefined; + const normalized = normalizedPath(file.path); + if (!normalized || normalized.startsWith("../") || isAbsolute(file.path)) return undefined; + const absolute = resolve(root, normalized); + if (!insideRoot(root, absolute)) return undefined; + const info = await lstat(absolute).catch(() => undefined); + if (!info?.isFile() || info.isSymbolicLink() || info.size > MAX_SOURCE_BYTES) return undefined; + const content = await readFile(absolute, "utf8").catch(() => undefined); + if (content === undefined || content.includes("\u0000")) return undefined; + return content; +} + +function resolvedTargetForLine(moduleGraph: ModuleGraph, fromPath: string, line: number): string | undefined { + const candidates = moduleGraph.edges + .filter((edge) => edge.target && normalizedPath(edge.from) === fromPath && edge.line === line) + .map((edge) => edge.target as string); + const unique = [...new Set(candidates)]; + return unique.length === 1 ? unique[0] : undefined; +} + +function parseJsNamedList(value: string): Array<{ localName: string; importedName: string }> { + const output: Array<{ localName: string; importedName: string }> = []; + for (const rawPart of value.split(",")) { + const part = rawPart.trim().replace(/^type\s+/, ""); + if (!part) continue; + const match = part.match(/^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/); + if (!match?.[1]) continue; + output.push({ importedName: match[1], localName: match[2] ?? match[1] }); + } + return output; +} + +function parseCommonJsNamedList(value: string): Array<{ localName: string; importedName: string }> { + const output: Array<{ localName: string; importedName: string }> = []; + for (const rawPart of value.split(",")) { + const part = rawPart.trim(); + if (!part) continue; + const match = part.match(/^([A-Za-z_$][\w$]*)(?:\s*:\s*([A-Za-z_$][\w$]*))?$/); + if (!match?.[1]) continue; + output.push({ importedName: match[1], localName: match[2] ?? match[1] }); + } + return output; +} + +function parsePythonNamedList(value: string): Array<{ localName: string; importedName: string }> { + const output: Array<{ localName: string; importedName: string }> = []; + for (const rawPart of value.replace(/^\(/, "").replace(/\)$/, "").split(",")) { + const part = rawPart.trim(); + if (!part || part === "*") continue; + const match = part.match(/^([A-Za-z_][A-Za-z0-9_]*)(?:\s+as\s+([A-Za-z_][A-Za-z0-9_]*))?$/); + if (!match?.[1]) continue; + output.push({ importedName: match[1], localName: match[2] ?? match[1] }); + } + return output; +} + +function collectBindingsForLine( + fromPath: string, + lineNumber: number, + line: string, + extension: string, + targetPath: string, +): ImportBinding[] { + if (jsExtensions.has(extension)) { + const namedImport = line.match(/^\s*import\s*{([^}]+)}\s*from\s*["'][^"']+["']/); + if (namedImport?.[1]) { + return parseJsNamedList(namedImport[1]).map((binding) => ({ + fromPath, + line: lineNumber, + targetPath, + ...binding, + namespace: false, + kind: "javascript-named-import" as const, + })); + } + + const namespaceImport = line.match(/^\s*import\s*\*\s*as\s*([A-Za-z_$][\w$]*)\s*from\s*["'][^"']+["']/); + if (namespaceImport?.[1]) { + return [{ + fromPath, + line: lineNumber, + targetPath, + localName: namespaceImport[1], + namespace: true, + kind: "javascript-namespace-import", + }]; + } + + const destructuredRequire = line.match(/^\s*(?:const|let|var)\s*{([^}]+)}\s*=\s*require\s*\(\s*["'][^"']+["']\s*\)/); + if (destructuredRequire?.[1]) { + return parseCommonJsNamedList(destructuredRequire[1]).map((binding) => ({ + fromPath, + line: lineNumber, + targetPath, + ...binding, + namespace: false, + kind: "commonjs-destructured-require" as const, + })); + } + return []; + } + + if (extension === ".py") { + const fromImport = line.match(/^\s*from\s+[A-Za-z0-9_.]+\s+import\s+(.+?)\s*(?:#.*)?$/); + if (fromImport?.[1]) { + return parsePythonNamedList(fromImport[1]).map((binding) => ({ + fromPath, + line: lineNumber, + targetPath, + ...binding, + namespace: false, + kind: "python-from-import" as const, + })); + } + + const moduleImport = line.match(/^\s*import\s+([A-Za-z0-9_.]+)(?:\s+as\s+([A-Za-z_][A-Za-z0-9_]*))?\s*(?:#.*)?$/); + if (moduleImport?.[1]) { + const localName = moduleImport[2] ?? moduleImport[1].split(".")[0]; + if (!localName) return []; + return [{ + fromPath, + line: lineNumber, + targetPath, + localName, + namespace: true, + kind: "python-module-import", + }]; + } + } + + return []; +} + +async function collectImportBindings( + root: string, + files: readonly IndexFileInput[], + moduleGraph: ModuleGraph, +): Promise { + const bindings: ImportBinding[] = []; + for (const file of files.slice(0, MAX_FILES)) { + const extension = extname(file.path).toLowerCase(); + if (!jsExtensions.has(extension) && extension !== ".py") continue; + const fromPath = normalizedPath(file.path); + const content = await readBoundedSource(root, file); + if (!content) continue; + const lines = content.split(/\r?\n/); + let fileBindingCount = 0; + for (let index = 0; index < lines.length && fileBindingCount < MAX_BINDINGS_PER_FILE; index += 1) { + const targetPath = resolvedTargetForLine(moduleGraph, fromPath, index + 1); + if (!targetPath) continue; + const parsed = collectBindingsForLine(fromPath, index + 1, lines[index] ?? "", extension, targetPath); + for (const binding of parsed) { + bindings.push(binding); + fileBindingCount += 1; + if (fileBindingCount >= MAX_BINDINGS_PER_FILE) break; + } + } + } + return bindings; +} + +function bindingMatch(binding: ImportBinding, callee: string): string | undefined { + if (binding.namespace) { + const prefix = `${binding.localName}.`; + if (!callee.startsWith(prefix)) return undefined; + const member = callee.slice(prefix.length); + return /^[A-Za-z_$][\w$]*$/.test(member) ? member : undefined; + } + return callee === binding.localName ? binding.importedName : undefined; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function callerShadowsBinding( + source: string, + callerLine: number, + callLine: number, + localName: string, +): boolean { + if (!Number.isSafeInteger(callerLine) || !Number.isSafeInteger(callLine) || callerLine < 1 || callLine < callerLine) { + return true; + } + const escaped = escapeRegExp(localName); + const variableDeclaration = new RegExp(`\\b(?:const|let|var)\\s+${escaped}\\b`); + const namedDeclaration = new RegExp(`\\b(?:class|function|def)\\s+${escaped}\\b`); + const bindingDeclaration = new RegExp(`\\b(?:for|catch|except)\\b[^;\\n]*\\b${escaped}\\b`); + const assignment = new RegExp(`^\\s*${escaped}\\s*=`); + const parameter = new RegExp(`\\([^)]*\\b${escaped}\\b[^)]*\\)`); + const lines = source.split(/\r?\n/); + const start = Math.max(0, callerLine - 1); + const end = Math.min(lines.length, callLine); + + for (let index = start; index < end; index += 1) { + const line = lines[index] ?? ""; + if (variableDeclaration.test(line) || namedDeclaration.test(line) || bindingDeclaration.test(line) || assignment.test(line)) { + return true; + } + if (index === start && parameter.test(line)) return true; + } + return false; +} + +async function sourceForPath( + root: string, + path: string, + cache: Map, +): Promise { + const normalized = normalizedPath(path); + if (cache.has(normalized)) return cache.get(normalized); + const source = await readBoundedSource(root, { path: normalized, size: 0 }); + cache.set(normalized, source); + return source; +} + +export async function buildImportCallLinkGraph( + rootPath: string, + files: readonly IndexFileInput[], + moduleGraph: ModuleGraph, + callGraph: CallGraph, +): Promise { + const root = resolve(rootPath); + const bindings = await collectImportBindings(root, files, moduleGraph); + const nodeById = new Map(callGraph.nodes.map((node) => [node.id, node])); + const functionsByPathAndName = new Map(); + const sourceCache = new Map(); + for (const node of callGraph.nodes) { + const key = `${normalizedPath(node.path)}\u0000${node.name}`; + const bucket = functionsByPathAndName.get(key) ?? []; + bucket.push(node.id); + functionsByPathAndName.set(key, bucket); + } + + const links: ImportCallLink[] = []; + for (const edge of callGraph.edges) { + if (edge.target || links.length >= MAX_LINKS) continue; + const caller = nodeById.get(edge.from); + if (!caller) continue; + const callerPath = normalizedPath(caller.path); + const matches = bindings.flatMap((binding) => { + if (binding.fromPath !== callerPath) return []; + const importedName = bindingMatch(binding, edge.callee); + return importedName ? [{ binding, importedName }] : []; + }); + if (matches.length !== 1) continue; + const match = matches[0]; + if (!match) continue; + + const source = await sourceForPath(root, callerPath, sourceCache); + if (source === undefined || callerShadowsBinding(source, caller.line, edge.line, match.binding.localName)) continue; + + const targets = functionsByPathAndName.get(`${normalizedPath(match.binding.targetPath)}\u0000${match.importedName}`) ?? []; + if (targets.length !== 1) continue; + const target = targets[0]; + if (!target) continue; + links.push({ + from: edge.from, + line: edge.line, + callee: edge.callee, + target, + targetPath: normalizedPath(match.binding.targetPath), + importedName: match.importedName, + bindingKind: match.binding.kind, + evidence: "explicit-import-binding-to-unique-local-function", + }); + } + + links.sort((a, b) => a.from.localeCompare(b.from) || a.line - b.line || a.callee.localeCompare(b.callee)); + return { + schemaVersion: 1, + links, + linkedCallCount: links.length, + interpretation: "cross-module-import-call-evidence-only", + }; +} diff --git a/packages/repository/src/import-route-handlers.ts b/packages/repository/src/import-route-handlers.ts new file mode 100644 index 00000000..a2d657a6 --- /dev/null +++ b/packages/repository/src/import-route-handlers.ts @@ -0,0 +1,210 @@ +import { lstat, readFile } from "node:fs/promises"; +import { isAbsolute, relative, resolve, sep } from "node:path"; +import type { IndexFileInput } from "./analysis.js"; +import { findCallNeighborhood, type CallGraph, type CallGraphNode } from "./call-graph.js"; +import type { ModuleGraph, ResolvedModuleEdge } from "./module-graph.js"; +import type { RouteEntrypoint } from "./route-entrypoints.js"; + +const MAX_SOURCE_BYTES = 512_000; +const IMPORT_RESOLVABLE_NODE_ROUTE_HINTS = new Set(["Node HTTP router", "Koa router"]); + +interface ImportBinding { + localName: string; + importedName: string; + edge: ResolvedModuleEdge; +} + +function normalizePath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function insideRoot(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +async function safeReadSource(rootPath: string, file: IndexFileInput): Promise { + if (!file.path || file.path.includes("\0") || isAbsolute(file.path)) return undefined; + const root = resolve(rootPath); + const candidate = resolve(root, file.path); + if (!insideRoot(root, candidate)) return undefined; + const info = await lstat(candidate).catch(() => undefined); + if (!info?.isFile() || info.isSymbolicLink() || info.size > MAX_SOURCE_BYTES) return undefined; + return await readFile(candidate, "utf8").catch(() => undefined); +} + +function parseNamedImport(line: string, edge: ResolvedModuleEdge): ImportBinding[] { + const match = line.match(/^\s*import\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/); + if (!match?.[1] || match[2] !== edge.specifier) return []; + const output: ImportBinding[] = []; + for (const raw of match[1].split(",")) { + const part = raw.trim().replace(/^type\s+/, ""); + if (!part) continue; + const binding = part.match(/^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/); + const importedName = binding?.[1]; + if (!importedName) continue; + output.push({ importedName, localName: binding?.[2] ?? importedName, edge }); + } + return output; +} + +function parseDestructuredRequire(line: string, edge: ResolvedModuleEdge): ImportBinding[] { + const match = line.match( + /^\s*(?:const|let|var)\s*\{([^}]*)\}\s*=\s*require\s*\(\s*["']([^"']+)["']\s*\)/, + ); + if (!match?.[1] || match[2] !== edge.specifier) return []; + const output: ImportBinding[] = []; + for (const raw of match[1].split(",")) { + const part = raw.trim(); + if (!part) continue; + const binding = part.match(/^([A-Za-z_$][\w$]*)(?:\s*:\s*([A-Za-z_$][\w$]*))?$/); + const importedName = binding?.[1]; + if (!importedName) continue; + output.push({ importedName, localName: binding?.[2] ?? importedName, edge }); + } + return output; +} + +function escapeIdentifier(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function bindingShadowedBeforeRoute(content: string, binding: ImportBinding, routeLine: number): boolean { + if (routeLine <= binding.edge.line) return true; + const escaped = escapeIdentifier(binding.localName); + const lines = content.split(/\r?\n/).slice(binding.edge.line, routeLine - 1); + const declaration = new RegExp(`\\b(?:const|let|var|function|class)\\s+${escaped}\\b`); + const assignment = new RegExp(`(^|[^.\\w$])${escaped}\\s*=(?!=)`); + const parameter = new RegExp(`\\([^)]*\\b${escaped}\\b[^)]*\\)\\s*(?:=>|\\{)`); + return lines.some((line) => declaration.test(line) || assignment.test(line) || parameter.test(line)); +} + +function targetNode(graph: CallGraph, binding: ImportBinding): CallGraphNode | undefined { + const targetPath = binding.edge.target; + if (!targetPath) return undefined; + const matches = graph.nodes.filter( + (node) => normalizePath(node.path) === normalizePath(targetPath) && node.name === binding.importedName, + ); + return matches.length === 1 ? matches[0] : undefined; +} + +function hasNamedExportEvidence(content: string, binding: ImportBinding): boolean { + const escaped = escapeIdentifier(binding.importedName); + if (binding.edge.kind === "import") { + const directFunction = new RegExp(`(^|\\n)\\s*export\\s+(?:async\\s+)?function\\s+${escaped}\\b`); + const directValue = new RegExp(`(^|\\n)\\s*export\\s+(?:const|let|var|class)\\s+${escaped}\\b`); + const exportList = new RegExp(`(^|\\n)\\s*export\\s*\\{[^}]*\\b${escaped}\\b(?:\\s*,|\\s*\\})`); + return directFunction.test(content) || directValue.test(content) || exportList.test(content); + } + + if (binding.edge.kind === "require") { + const propertyAssignment = new RegExp( + `(^|\\n)\\s*(?:module\\.)?exports\\.${escaped}\\s*=\\s*${escaped}\\b`, + ); + const objectAssignment = new RegExp( + `(^|\\n)\\s*module\\.exports\\s*=\\s*\\{[^}]*\\b${escaped}\\b(?:\\s*[:,}]|\\s*,)`, + ); + return propertyAssignment.test(content) || objectAssignment.test(content); + } + + return false; +} + +/** + * Resolve unresolved Node-family router handlers through explicit repository-local named imports. + * + * Only route families whose strict composer/index has already established a supported Node route + * identity are eligible. ES named imports and destructured CommonJS require bindings are supported. + * Default imports, namespace members, re-exports, dynamic expressions, shadowed bindings, missing + * export evidence, ambiguous module targets, and ambiguous target functions remain unresolved. This + * is structural evidence only and preserves the route's existing framework identity. + */ +export async function resolveImportedNodeRouteEntrypoints( + rootPath: string, + files: readonly IndexFileInput[], + moduleGraph: ModuleGraph, + graph: CallGraph, + entrypoints: readonly RouteEntrypoint[], + options: { maxCallDepth?: number; maxCallNodes?: number } = {}, +): Promise { + const fileByPath = new Map(files.map((file) => [normalizePath(file.path), file])); + const sourceCache = new Map(); + const maxCallDepth = options.maxCallDepth ?? 3; + const maxCallNodes = Math.max(1, Math.min(1_000, options.maxCallNodes ?? 100)); + const output: RouteEntrypoint[] = []; + + async function sourceFor(path: string): Promise { + const normalized = normalizePath(path); + if (sourceCache.has(normalized)) return sourceCache.get(normalized); + const file = fileByPath.get(normalized); + const content = file ? await safeReadSource(rootPath, file) : undefined; + sourceCache.set(normalized, content); + return content; + } + + for (const entrypoint of entrypoints) { + const route = entrypoint.route; + if ( + entrypoint.resolution !== "unresolved" || + !route.frameworkHint || + !IMPORT_RESOLVABLE_NODE_ROUTE_HINTS.has(route.frameworkHint) || + !route.handler + ) { + output.push(entrypoint); + continue; + } + + const routePath = normalizePath(route.path); + const content = await sourceFor(routePath); + if (content === undefined) { + output.push(entrypoint); + continue; + } + + const lines = content.split(/\r?\n/); + const bindings: ImportBinding[] = []; + for (const edge of moduleGraph.edges) { + if ( + normalizePath(edge.from) !== routePath || + edge.resolution !== "repository-file" || + !edge.target || + (edge.kind !== "import" && edge.kind !== "require") + ) continue; + const line = lines[edge.line - 1] ?? ""; + const parsed = edge.kind === "import" ? parseNamedImport(line, edge) : parseDestructuredRequire(line, edge); + for (const binding of parsed) { + if (binding.localName === route.handler && !bindingShadowedBeforeRoute(content, binding, route.line)) { + bindings.push(binding); + } + } + } + + const eligibleTargets: CallGraphNode[] = []; + for (const binding of bindings) { + const node = targetNode(graph, binding); + const targetPath = binding.edge.target; + if (!node || !targetPath) continue; + const targetSource = await sourceFor(targetPath); + if (targetSource !== undefined && hasNamedExportEvidence(targetSource, binding)) { + eligibleTargets.push(node); + } + } + + const distinct = [...new Map(eligibleTargets.map((node) => [node.id, node])).values()]; + const handler = distinct.length === 1 ? distinct[0] : undefined; + if (!handler) { + output.push(entrypoint); + continue; + } + + output.push({ + route, + resolution: "imported-named-function", + handler, + calls: findCallNeighborhood(graph, handler.id, maxCallDepth, maxCallNodes), + interpretation: "structural-route-call-evidence-only", + }); + } + + return output; +} diff --git a/packages/repository/src/incremental-plan.ts b/packages/repository/src/incremental-plan.ts new file mode 100644 index 00000000..2aa1dbd1 --- /dev/null +++ b/packages/repository/src/incremental-plan.ts @@ -0,0 +1,197 @@ +import { posix } from "node:path"; +import { findModuleNeighborhood, type ModuleGraph } from "./module-graph.js"; + +export type IncrementalScanPlanReason = + | "targeted-with-bounded-dependents" + | "no-changes" + | "invalid-changed-path" + | "too-many-changed-files" + | "high-impact-file-changed" + | "changed-source-not-indexed" + | "dependent-expansion-exceeded-bound"; + +export interface IncrementalScanPlan { + mode: "targeted" | "full-repository"; + reason: IncrementalScanPlanReason; + changedFiles: string[]; + selectedFiles: string[]; + dependentFiles: Array<{ path: string; depth: number; triggeredBy: string }>; + maxDependentDepth: number; + /** This plan is a coverage heuristic and never asserts that unselected files are safe. */ + interpretation: "coverage-heuristic-not-proof-of-unaffected-code"; +} + +export interface IncrementalScanPlanOptions { + maxChangedFiles?: number; + maxDependentDepth?: number; + maxDependents?: number; +} + +const DEFAULT_MAX_CHANGED_FILES = 500; +const DEFAULT_MAX_DEPENDENT_DEPTH = 2; +const DEFAULT_MAX_DEPENDENTS = 200; + +const sourceExtensions = new Set([ + ".js", ".mjs", ".cjs", ".jsx", + ".ts", ".mts", ".cts", ".tsx", + ".py", +]); + +const exactHighImpactFiles = new Set([ + "package.json", + "package-lock.json", + "npm-shrinkwrap.json", + "pnpm-lock.yaml", + "yarn.lock", + "bun.lock", + "bun.lockb", + "pyproject.toml", + "poetry.lock", + "requirements.txt", + "pipfile", + "pipfile.lock", + "go.mod", + "go.sum", + "cargo.toml", + "cargo.lock", + "composer.json", + "composer.lock", + "gemfile", + "gemfile.lock", + "dockerfile", + "docker-compose.yml", + "docker-compose.yaml", + "compose.yml", + "compose.yaml", + "synsec.config.json", +]); + +function boundedInteger(value: number | undefined, fallback: number, min: number, max: number, label: string): number { + const normalized = value ?? fallback; + if (!Number.isSafeInteger(normalized) || normalized < min || normalized > max) { + throw new Error(`${label} must be an integer between ${min} and ${max}.`); + } + return normalized; +} + +function normalizeRepositoryPath(value: string): string | undefined { + if (typeof value !== "string" || value.includes("\0")) return undefined; + const replaced = value.trim().replaceAll("\\", "/").replace(/^\.\//, ""); + if (!replaced || replaced.startsWith("/") || /^[A-Za-z]:\//.test(replaced)) return undefined; + const normalized = posix.normalize(replaced); + if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) return undefined; + return normalized; +} + +function isHighImpactFile(path: string): boolean { + const normalized = path.toLowerCase(); + const basename = posix.basename(normalized); + if (exactHighImpactFiles.has(normalized) || exactHighImpactFiles.has(basename)) return true; + if (normalized.startsWith(".github/workflows/")) return true; + if (normalized.startsWith(".gitlab/")) return true; + if (normalized.endsWith(".tf") || normalized.endsWith(".tfvars")) return true; + if (/(^|\/)(tsconfig|jsconfig)(\.[^/]+)?\.json$/.test(normalized)) return true; + return /(^|\/)(security|auth|permissions?|policy|policies)\.(json|ya?ml|toml)$/.test(normalized); +} + +function isIndexedSource(path: string, nodes: ReadonlySet): boolean { + const extension = posix.extname(path).toLowerCase(); + return !sourceExtensions.has(extension) || nodes.has(path.toLowerCase()); +} + +function fullPlan( + reason: Exclude, + changedFiles: string[], + maxDependentDepth: number, +): IncrementalScanPlan { + return { + mode: "full-repository", + reason, + changedFiles, + selectedFiles: [], + dependentFiles: [], + maxDependentDepth, + interpretation: "coverage-heuristic-not-proof-of-unaffected-code", + }; +} + +/** + * Build a conservative incremental-scan scope from changed files and structural module evidence. + * + * Direct changes are always selected. Known local dependents may be added to catch effects that + * cross import boundaries, but this is only a coverage heuristic. Ambiguous/high-impact conditions + * fail over to a full repository scan rather than treating unselected code as safe. + */ +export function buildIncrementalScanPlan( + graph: ModuleGraph, + changedFiles: readonly string[], + options: IncrementalScanPlanOptions = {}, +): IncrementalScanPlan { + const maxChangedFiles = boundedInteger(options.maxChangedFiles, DEFAULT_MAX_CHANGED_FILES, 1, 10_000, "maxChangedFiles"); + const maxDependentDepth = boundedInteger(options.maxDependentDepth, DEFAULT_MAX_DEPENDENT_DEPTH, 0, 10, "maxDependentDepth"); + const maxDependents = boundedInteger(options.maxDependents, DEFAULT_MAX_DEPENDENTS, 1, 10_000, "maxDependents"); + + const normalized: string[] = []; + for (const value of changedFiles) { + const path = normalizeRepositoryPath(value); + if (!path) return fullPlan("invalid-changed-path", [...normalized], maxDependentDepth); + normalized.push(path); + } + const changed = [...new Map(normalized.map((path) => [path.toLowerCase(), path])).values()].sort(); + + if (changed.length === 0) { + return { + mode: "targeted", + reason: "no-changes", + changedFiles: [], + selectedFiles: [], + dependentFiles: [], + maxDependentDepth, + interpretation: "coverage-heuristic-not-proof-of-unaffected-code", + }; + } + if (changed.length > maxChangedFiles) return fullPlan("too-many-changed-files", changed, maxDependentDepth); + if (changed.some(isHighImpactFile)) return fullPlan("high-impact-file-changed", changed, maxDependentDepth); + + const nodes = new Set(graph.nodes.map((path) => path.toLowerCase())); + if (changed.some((path) => !isIndexedSource(path, nodes))) { + return fullPlan("changed-source-not-indexed", changed, maxDependentDepth); + } + + const dependents = new Map(); + for (const path of changed) { + if (!nodes.has(path.toLowerCase()) || maxDependentDepth === 0) continue; + const neighborhood = findModuleNeighborhood(graph, path, maxDependentDepth, maxDependents + 1); + if (neighborhood.dependents.length > maxDependents) { + return fullPlan("dependent-expansion-exceeded-bound", changed, maxDependentDepth); + } + for (const dependent of neighborhood.dependents) { + const key = dependent.path.toLowerCase(); + const previous = dependents.get(key); + if (!previous || dependent.depth < previous.depth || (dependent.depth === previous.depth && path < previous.triggeredBy)) { + dependents.set(key, { ...dependent, triggeredBy: path }); + } + } + } + + if (dependents.size > maxDependents) { + return fullPlan("dependent-expansion-exceeded-bound", changed, maxDependentDepth); + } + + const dependentFiles = [...dependents.values()] + .filter((item) => !changed.some((path) => path.toLowerCase() === item.path.toLowerCase())) + .sort((a, b) => a.depth - b.depth || a.path.localeCompare(b.path) || a.triggeredBy.localeCompare(b.triggeredBy)); + const selectedFiles = [...new Map( + [...changed, ...dependentFiles.map((item) => item.path)].map((path) => [path.toLowerCase(), path]), + ).values()].sort(); + + return { + mode: "targeted", + reason: "targeted-with-bounded-dependents", + changedFiles: changed, + selectedFiles, + dependentFiles, + maxDependentDepth, + interpretation: "coverage-heuristic-not-proof-of-unaffected-code", + }; +} diff --git a/packages/repository/src/index.ts b/packages/repository/src/index.ts new file mode 100644 index 00000000..a39a60a1 --- /dev/null +++ b/packages/repository/src/index.ts @@ -0,0 +1,242 @@ +import { lstat, readFile, readdir } from "node:fs/promises"; +import { extname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import type { Finding } from "@synsec/core"; +import type { RepositoryMetadata } from "@synsec/report"; + +const ignoredDirectories = new Set([ + ".git", + ".hg", + ".svn", + ".idea", + ".vscode", + "node_modules", + "vendor", + "dist", + "build", + "coverage", + ".next", + ".nuxt", + ".venv", + "venv", + "target", + "bin", + "obj", + ".synsec", +]); + +const languageByExtension: Record = { + ".js": "JavaScript", + ".mjs": "JavaScript", + ".cjs": "JavaScript", + ".jsx": "JavaScript", + ".ts": "TypeScript", + ".mts": "TypeScript", + ".cts": "TypeScript", + ".tsx": "TypeScript", + ".py": "Python", + ".go": "Go", + ".rs": "Rust", + ".java": "Java", + ".kt": "Kotlin", + ".kts": "Kotlin", + ".rb": "Ruby", + ".php": "PHP", + ".cs": "C#", + ".c": "C", + ".h": "C/C++ Header", + ".cc": "C++", + ".cpp": "C++", + ".cxx": "C++", + ".swift": "Swift", + ".scala": "Scala", + ".sh": "Shell", + ".bash": "Shell", + ".ps1": "PowerShell", + ".tf": "Terraform", + ".sol": "Solidity", + ".ex": "Elixir", + ".exs": "Elixir", + ".dart": "Dart", + ".lua": "Lua", + ".r": "R", + ".R": "R", +}; + +export interface RepositoryFile { + path: string; + size: number; +} + +export interface RepositoryInventory { + metadata: RepositoryMetadata; + files: RepositoryFile[]; +} + +export interface FindingContext { + path: string; + startLine: number; + endLine: number; + excerpt: string; + truncated: boolean; +} + +function insideRoot(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +async function walk(root: string, maxFiles: number): Promise { + const output: RepositoryFile[] = []; + const stack = [root]; + + while (stack.length > 0 && output.length < maxFiles) { + const current = stack.pop(); + if (!current) break; + let entries; + try { + entries = await readdir(current, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (output.length >= maxFiles) break; + if (entry.isSymbolicLink()) continue; + const absolute = join(current, entry.name); + if (!insideRoot(root, absolute)) continue; + + if (entry.isDirectory()) { + if (!ignoredDirectories.has(entry.name)) stack.push(absolute); + continue; + } + if (!entry.isFile()) continue; + + const stat = await lstat(absolute).catch(() => undefined); + if (!stat?.isFile()) continue; + output.push({ + path: relative(root, absolute).replaceAll(sep, "/"), + size: stat.size, + }); + } + } + return output; +} + +function addFramework(frameworks: Set, name: string): void { + frameworks.add(name); +} + +async function detectNodeFrameworks(root: string, frameworks: Set): Promise { + const path = join(root, "package.json"); + const content = await readFile(path, "utf8").catch(() => undefined); + if (!content) return; + let parsed: Record; + try { + parsed = JSON.parse(content) as Record; + } catch { + return; + } + const dependencyMaps = [parsed.dependencies, parsed.devDependencies]; + const dependencies = new Set(); + for (const value of dependencyMaps) { + if (typeof value !== "object" || value === null || Array.isArray(value)) continue; + for (const key of Object.keys(value as Record)) dependencies.add(key); + } + const mappings: Array<[string, string]> = [ + ["next", "Next.js"], + ["react", "React"], + ["express", "Express"], + ["fastify", "Fastify"], + ["@nestjs/core", "NestJS"], + ["h3", "H3"], + ["nuxt", "Nuxt"], + ["svelte", "Svelte"], + ["@sveltejs/kit", "SvelteKit"], + ["vue", "Vue"], + ["astro", "Astro"], + ["koa", "Koa"], + ["hono", "Hono"], + ]; + for (const [pkg, framework] of mappings) if (dependencies.has(pkg)) addFramework(frameworks, framework); +} + +async function detectPythonFrameworks(root: string, frameworks: Set): Promise { + const candidates = ["requirements.txt", "pyproject.toml", "Pipfile"]; + const text = (await Promise.all(candidates.map((name) => readFile(join(root, name), "utf8").catch(() => "")))).join("\n").toLowerCase(); + if (/\bdjango\b/.test(text)) addFramework(frameworks, "Django"); + if (/\bfastapi\b/.test(text)) addFramework(frameworks, "FastAPI"); + if (/\bflask\b/.test(text)) addFramework(frameworks, "Flask"); + if (/\bstarlette\b/.test(text)) addFramework(frameworks, "Starlette"); +} + +async function detectOtherFrameworks(root: string, frameworks: Set): Promise { + const goMod = await readFile(join(root, "go.mod"), "utf8").catch(() => ""); + if (goMod.includes("github.com/gin-gonic/gin")) addFramework(frameworks, "Gin"); + if (goMod.includes("github.com/gofiber/fiber")) addFramework(frameworks, "Fiber"); + if (goMod.includes("github.com/labstack/echo")) addFramework(frameworks, "Echo"); + + const composer = await readFile(join(root, "composer.json"), "utf8").catch(() => ""); + if (composer.includes("laravel/framework")) addFramework(frameworks, "Laravel"); + if (composer.includes("symfony/")) addFramework(frameworks, "Symfony"); + + const pom = await readFile(join(root, "pom.xml"), "utf8").catch(() => ""); + if (pom.includes("spring-boot")) addFramework(frameworks, "Spring Boot"); +} + +export async function inventoryRepository(rootPath: string, maxFiles = 20_000): Promise { + const root = resolve(rootPath); + const files = await walk(root, maxFiles); + const languages: Record = {}; + for (const file of files) { + const language = languageByExtension[extname(file.path)]; + if (!language) continue; + languages[language] = (languages[language] ?? 0) + 1; + } + + const frameworks = new Set(); + await Promise.all([ + detectNodeFrameworks(root, frameworks), + detectPythonFrameworks(root, frameworks), + detectOtherFrameworks(root, frameworks), + ]); + + return { + metadata: { + languages, + frameworks: [...frameworks].sort(), + fileCount: files.length, + }, + files, + }; +} + +export async function getFindingContext(rootPath: string, finding: Finding, radius = 20): Promise { + const location = finding.location; + if (!location?.path) return undefined; + const root = resolve(rootPath); + const normalizedRelative = location.path.replaceAll("/", sep).replaceAll("\\", sep); + const candidate = resolve(root, normalizedRelative); + if (!insideRoot(root, candidate)) return undefined; + + const stat = await lstat(candidate).catch(() => undefined); + if (!stat?.isFile() || stat.size > 1_000_000) return undefined; + const content = await readFile(candidate, "utf8").catch(() => undefined); + if (content === undefined || content.includes("\u0000")) return undefined; + + const lines = content.split(/\r?\n/); + const focus = Math.max(1, location.startLine ?? 1); + const startLine = Math.max(1, focus - radius); + const endLine = Math.min(lines.length, (location.endLine ?? focus) + radius); + const excerpt = lines + .slice(startLine - 1, endLine) + .map((line, index) => `${String(startLine + index).padStart(5)} | ${line}`) + .join("\n"); + + return { + path: relative(root, candidate).replaceAll(sep, "/"), + startLine, + endLine, + excerpt, + truncated: startLine > 1 || endLine < lines.length, + }; +} diff --git a/packages/repository/src/koa-request-input-flow.ts b/packages/repository/src/koa-request-input-flow.ts new file mode 100644 index 00000000..af3b421a --- /dev/null +++ b/packages/repository/src/koa-request-input-flow.ts @@ -0,0 +1,286 @@ +import { lstat, readFile } from "node:fs/promises"; +import { isAbsolute, relative, resolve, sep } from "node:path"; +import type { SinkSignal } from "./analysis.js"; +import type { CallGraph, CallGraphNode } from "./call-graph.js"; +import type { RouteSinkFlowContext } from "./route-sink-flow.js"; + +export type KoaRequestInputKind = "body" | "query" | "path" | "header" | "cookie"; + +export interface KoaRequestInputEvidence { + source: { + path: string; + line: number; + kind: KoaRequestInputKind; + access: string; + functionId: string; + functionName: string; + }; + sink: { + path: string; + line: number; + kind: SinkSignal["kind"]; + functionId: string; + functionName: string; + }; + callDistance: 0 | 1; +} + +export interface KoaRouteRequestInputFlowContext { + route: RouteSinkFlowContext["route"]; + resolution: RouteSinkFlowContext["resolution"]; + handler: RouteSinkFlowContext["handler"]; + evidence: KoaRequestInputEvidence[]; + sourceKinds: KoaRequestInputKind[]; + sinkKinds: SinkSignal["kind"][]; + interpretation: "structural-koa-context-source-direct-call-sink-evidence-only"; +} + +export interface FindingKoaRequestInputFlowEvidence { + method: string; + route: string; + frameworkHint?: string; + handler: string; + sourceKind: KoaRequestInputKind; + sourceFunction: string; + sinkKind: SinkSignal["kind"]; + sinkFunction: string; + callDistance: 0 | 1; + interpretation: "structural-koa-context-source-direct-call-sink-evidence-only"; +} + +export interface KoaRequestInputFlowOptions { + maxFiles?: number; + maxSourceBytes?: number; + maxEvidence?: number; + maxRoutes?: number; +} + +const DEFAULT_MAX_FILES = 5_000; +const MAX_FILES = 5_000; +const DEFAULT_MAX_SOURCE_BYTES = 512_000; +const MAX_SOURCE_BYTES = 2_000_000; +const DEFAULT_MAX_EVIDENCE = 12; +const MAX_EVIDENCE = 50; +const DEFAULT_MAX_ROUTES = 1_000; +const MAX_ROUTES = 5_000; + +function boundedInteger(value: number | undefined, fallback: number, maximum: number, label: string): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > maximum) { + throw new Error(`${label} must be an integer between 1 and ${maximum}.`); + } + return resolved; +} + +function normalizedPath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function comparisonPath(value: string): string { + return normalizedPath(value).replace(/^\//, "").toLowerCase(); +} + +function insideRoot(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +function handlerContextParameter(node: CallGraphNode, lines: readonly string[]): string | undefined { + if (node.kind !== "function" && node.kind !== "arrow-function") return undefined; + const declaration = lines[node.line - 1] ?? ""; + let parameters: string | undefined; + if (node.kind === "function") { + const match = declaration.match(/\b(?:async\s+)?function\s+[A-Za-z_$][\w$]*\s*\(([^)]*)\)/); + parameters = match?.[1]; + } else { + const parenthesized = declaration.match(/\b(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*(?:async\s*)?\(([^)]*)\)\s*=>/); + if (parenthesized) parameters = parenthesized[1]; + else { + const single = declaration.match(/\b(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*(?:async\s*)?([A-Za-z_$][\w$]*)\s*=>/); + parameters = single?.[1]; + } + } + if (parameters === undefined) return undefined; + const first = parameters.split(",")[0]?.trim(); + return first && /^[A-Za-z_$][\w$]*$/.test(first) ? first : undefined; +} + +function requestAccesses(line: string, contextName: string): Array<{ kind: KoaRequestInputKind; access: string }> { + const escaped = contextName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const output: Array<{ kind: KoaRequestInputKind; access: string }> = []; + const candidates: Array<[KoaRequestInputKind, string, RegExp]> = [ + ["body", "koa.Context.request.body", new RegExp(`\\b${escaped}\\.request\\.body\\b`)], + ["query", "koa.Context.query", new RegExp(`\\b${escaped}\\.query\\b`)], + ["query", "koa.Context.request.query", new RegExp(`\\b${escaped}\\.request\\.query\\b`)], + ["path", "koa.Context.params", new RegExp(`\\b${escaped}\\.params\\b`)], + ["header", "koa.Context.headers", new RegExp(`\\b${escaped}\\.headers\\b`)], + ["header", "koa.Context.request.headers", new RegExp(`\\b${escaped}\\.request\\.headers\\b`)], + ["header", "koa.Context.get", new RegExp(`\\b${escaped}\\.get\\s*\\(`)], + ["cookie", "koa.Context.cookies.get", new RegExp(`\\b${escaped}\\.cookies\\.get\\s*\\(`)], + ]; + for (const [kind, access, pattern] of candidates) { + if (pattern.test(line)) output.push({ kind, access }); + } + return output; +} + +async function readSafeJavascriptFiles( + rootPath: string, + graph: CallGraph, + options: KoaRequestInputFlowOptions, +): Promise> { + const root = resolve(rootPath); + const maxFiles = boundedInteger(options.maxFiles, DEFAULT_MAX_FILES, MAX_FILES, "Koa request-flow maxFiles"); + const maxSourceBytes = boundedInteger( + options.maxSourceBytes, + DEFAULT_MAX_SOURCE_BYTES, + MAX_SOURCE_BYTES, + "Koa request-flow maxSourceBytes", + ); + const extensions = [".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"]; + const paths = [...new Set(graph.nodes + .filter((node) => extensions.some((extension) => node.path.toLowerCase().endsWith(extension))) + .map((node) => normalizedPath(node.path)))].slice(0, maxFiles); + const output = new Map(); + + for (const path of paths) { + if (!path || path.includes("\0") || path.startsWith("../") || isAbsolute(path)) continue; + const absolute = resolve(root, path); + if (!insideRoot(root, absolute)) continue; + const info = await lstat(absolute).catch(() => undefined); + if (!info?.isFile() || info.isSymbolicLink() || info.size > maxSourceBytes) continue; + const source = await readFile(absolute, "utf8").catch(() => undefined); + if (!source || source.includes("\u0000")) continue; + output.set(comparisonPath(path), source.split(/\r?\n/)); + } + return output; +} + +function directTargets(graph: CallGraph, ownerId: string, line: number): string[] { + return [...new Set(graph.edges.flatMap((edge) => edge.from === ownerId && edge.line === line && edge.target ? [edge.target] : []))].sort(); +} + +function isDefensibleKoaSink( + sink: RouteSinkFlowContext["evidence"][number], + files: ReadonlyMap, +): boolean { + if (sink.kind !== "database") return true; + const line = files.get(comparisonPath(sink.path))?.[sink.line - 1] ?? ""; + // The generic repository sink index intentionally treats bare query/execute-like calls as + // database-looking lexical evidence. Koa directional flow is stricter: database correlation is + // retained only for a member-qualified database-style call. This rejects local helpers named + // execute/query and their function declarations instead of upgrading those lexical collisions. + return /\.\s*(?:query|execute|executemany|raw|rawQuery|createQueryRunner)\s*\(/i.test(line); +} + +/** + * Build deliberately narrow Koa request-source evidence from routes produced by the strict Koa + * router composer. Only the resolved route handler's first plain identifier parameter is treated as + * the structural Koa context. A recognized access must occur on the exact sink line or on the same + * line as one direct call-graph edge to the sink-owning function. `ctx.body` is intentionally not a + * request source because Koa uses it as the response body. Generic bare execute/query calls are also + * excluded from database flow unless the sink line is member-qualified. Locals, aliasing, + * transformations, destructuring, middleware propagation, and deeper argument flow are excluded. + */ +export async function buildKoaRouteRequestInputFlowContexts( + rootPath: string, + routeFlows: readonly RouteSinkFlowContext[], + graph: CallGraph, + options: KoaRequestInputFlowOptions = {}, +): Promise { + const maxEvidence = boundedInteger(options.maxEvidence, DEFAULT_MAX_EVIDENCE, MAX_EVIDENCE, "Koa request-flow maxEvidence"); + const maxRoutes = boundedInteger(options.maxRoutes, DEFAULT_MAX_ROUTES, MAX_ROUTES, "Koa request-flow maxRoutes"); + const files = await readSafeJavascriptFiles(rootPath, graph, options); + const output: KoaRouteRequestInputFlowContext[] = []; + + for (const routeFlow of routeFlows.slice(0, maxRoutes)) { + if (routeFlow.route.frameworkHint !== "Koa router") continue; + const node = graph.nodes.find((candidate) => candidate.id === routeFlow.handler.id); + if (!node || (node.kind !== "function" && node.kind !== "arrow-function")) continue; + const lines = files.get(comparisonPath(node.path)); + if (!lines) continue; + const contextName = handlerContextParameter(node, lines); + if (!contextName) continue; + const evidence: KoaRequestInputEvidence[] = []; + + for (let lineNumber = node.line; lineNumber <= node.endLine && evidence.length < maxEvidence; lineNumber += 1) { + const line = lines[lineNumber - 1] ?? ""; + const accesses = requestAccesses(line, contextName); + if (accesses.length === 0) continue; + const targets = new Set(directTargets(graph, node.id, lineNumber)); + + for (const sink of routeFlow.evidence) { + if (!isDefensibleKoaSink(sink, files)) continue; + const sameLineSink = sink.functionId === node.id && sink.line === lineNumber; + const distance: 0 | 1 | undefined = sameLineSink ? 0 : targets.has(sink.functionId) ? 1 : undefined; + if (distance === undefined) continue; + for (const source of accesses) { + evidence.push({ + source: { + path: node.path, + line: lineNumber, + kind: source.kind, + access: source.access, + functionId: node.id, + functionName: node.name, + }, + sink: { + path: sink.path, + line: sink.line, + kind: sink.kind, + functionId: sink.functionId, + functionName: sink.functionName, + }, + callDistance: distance, + }); + if (evidence.length >= maxEvidence) break; + } + if (evidence.length >= maxEvidence) break; + } + } + + if (evidence.length === 0) continue; + output.push({ + route: routeFlow.route, + resolution: routeFlow.resolution, + handler: routeFlow.handler, + evidence, + sourceKinds: [...new Set(evidence.map((item) => item.source.kind))], + sinkKinds: [...new Set(evidence.map((item) => item.sink.kind))], + interpretation: "structural-koa-context-source-direct-call-sink-evidence-only", + }); + } + return output; +} + +/** Return only aggregate structural evidence for an exact finding sink line. */ +export function findingKoaRequestInputFlowEvidence( + contexts: readonly KoaRouteRequestInputFlowContext[], + path: string, + line: number | undefined, + maxRoutes = 3, +): FindingKoaRequestInputFlowEvidence[] { + if (!Number.isSafeInteger(line) || (line ?? 0) <= 0) return []; + const normalized = comparisonPath(path); + const limit = Math.max(1, Math.min(10, maxRoutes)); + const output: FindingKoaRequestInputFlowEvidence[] = []; + for (const context of contexts) { + for (const item of context.evidence) { + if (comparisonPath(item.sink.path) !== normalized || item.sink.line !== line) continue; + output.push({ + method: context.route.method, + route: context.route.route, + ...(context.route.frameworkHint ? { frameworkHint: context.route.frameworkHint } : {}), + handler: context.handler.name, + sourceKind: item.source.kind, + sourceFunction: item.source.functionName, + sinkKind: item.sink.kind, + sinkFunction: item.sink.functionName, + callDistance: item.callDistance, + interpretation: "structural-koa-context-source-direct-call-sink-evidence-only", + }); + if (output.length >= limit) return output; + } + } + return output; +} diff --git a/packages/repository/src/koa-request-input-forwarding.ts b/packages/repository/src/koa-request-input-forwarding.ts new file mode 100644 index 00000000..7de24ce6 --- /dev/null +++ b/packages/repository/src/koa-request-input-forwarding.ts @@ -0,0 +1,325 @@ +import { lstat, readFile } from "node:fs/promises"; +import { isAbsolute, relative, resolve, sep } from "node:path"; +import type { SinkSignal } from "./analysis.js"; +import type { CallGraph, CallGraphNode } from "./call-graph.js"; +import type { KoaRequestInputKind } from "./koa-request-input-flow.js"; +import type { RouteSinkFlowContext } from "./route-sink-flow.js"; + +export interface KoaRequestInputForwardingEvidence { + source: { + path: string; + line: number; + kind: KoaRequestInputKind; + functionId: string; + functionName: string; + }; + binding: { + line: number; + useLine: number; + }; + sink: { + path: string; + line: number; + kind: SinkSignal["kind"]; + functionId: string; + functionName: string; + }; + callDistance: 0 | 1; +} + +export interface KoaRouteRequestInputForwardingContext { + route: RouteSinkFlowContext["route"]; + resolution: RouteSinkFlowContext["resolution"]; + handler: RouteSinkFlowContext["handler"]; + evidence: KoaRequestInputForwardingEvidence[]; + sourceKinds: KoaRequestInputKind[]; + sinkKinds: SinkSignal["kind"][]; + interpretation: "structural-koa-context-source-single-use-local-call-sink-evidence-only"; +} + +export interface FindingKoaRequestInputForwardingEvidence { + method: string; + route: string; + frameworkHint?: string; + handler: string; + sourceKind: KoaRequestInputKind; + sourceFunction: string; + sinkKind: SinkSignal["kind"]; + sinkFunction: string; + callDistance: 0 | 1; + bindingHops: 1; + interpretation: "structural-koa-context-source-single-use-local-call-sink-evidence-only"; +} + +export interface KoaRequestInputForwardingOptions { + maxFiles?: number; + maxSourceBytes?: number; + maxEvidence?: number; + maxRoutes?: number; + maxForwardLines?: number; +} + +const DEFAULT_MAX_FILES = 5_000; +const MAX_FILES = 5_000; +const DEFAULT_MAX_SOURCE_BYTES = 512_000; +const MAX_SOURCE_BYTES = 2_000_000; +const DEFAULT_MAX_EVIDENCE = 12; +const MAX_EVIDENCE = 50; +const DEFAULT_MAX_ROUTES = 1_000; +const MAX_ROUTES = 5_000; +const DEFAULT_MAX_FORWARD_LINES = 8; +const MAX_FORWARD_LINES = 50; + +function boundedInteger(value: number | undefined, fallback: number, maximum: number, label: string): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > maximum) { + throw new Error(`${label} must be an integer between 1 and ${maximum}.`); + } + return resolved; +} + +function normalizedPath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function comparisonPath(value: string): string { + return normalizedPath(value).replace(/^\//, "").toLowerCase(); +} + +function insideRoot(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function handlerContextParameter(node: CallGraphNode, lines: readonly string[]): string | undefined { + if (node.kind !== "function" && node.kind !== "arrow-function") return undefined; + const declaration = lines[node.line - 1] ?? ""; + let parameters: string | undefined; + if (node.kind === "function") { + const match = declaration.match(/\b(?:async\s+)?function\s+[A-Za-z_$][\w$]*\s*\(([^)]*)\)/); + parameters = match?.[1]; + } else { + const parenthesized = declaration.match(/\b(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*(?:async\s*)?\(([^)]*)\)\s*=>/); + if (parenthesized) parameters = parenthesized[1]; + else { + const single = declaration.match(/\b(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*(?:async\s*)?([A-Za-z_$][\w$]*)\s*=>/); + parameters = single?.[1]; + } + } + if (parameters === undefined) return undefined; + const first = parameters.split(",")[0]?.trim(); + return first && /^[A-Za-z_$][\w$]*$/.test(first) ? first : undefined; +} + +function sourceBinding(line: string, contextName: string): { name: string; kind: KoaRequestInputKind } | undefined { + const context = escapeRegex(contextName); + const prefix = "^\\s*const\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*"; + const suffix = "\\s*;?\\s*$"; + const candidates: Array<[KoaRequestInputKind, RegExp]> = [ + ["body", new RegExp(`${prefix}${context}\\.request\\.body(?:\\.[A-Za-z_$][\\w$]*|\\[[^\\r\\n]+\\])?${suffix}`)], + ["query", new RegExp(`${prefix}${context}\\.query(?:\\.[A-Za-z_$][\\w$]*|\\[[^\\r\\n]+\\])?${suffix}`)], + ["query", new RegExp(`${prefix}${context}\\.request\\.query(?:\\.[A-Za-z_$][\\w$]*|\\[[^\\r\\n]+\\])?${suffix}`)], + ["path", new RegExp(`${prefix}${context}\\.params(?:\\.[A-Za-z_$][\\w$]*|\\[[^\\r\\n]+\\])?${suffix}`)], + ["header", new RegExp(`${prefix}${context}\\.headers(?:\\.[A-Za-z_$][\\w$]*|\\[[^\\r\\n]+\\])?${suffix}`)], + ["header", new RegExp(`${prefix}${context}\\.request\\.headers(?:\\.[A-Za-z_$][\\w$]*|\\[[^\\r\\n]+\\])?${suffix}`)], + ["header", new RegExp(`${prefix}${context}\\.get\\s*\\([^\\r\\n]*\\)${suffix}`)], + ["cookie", new RegExp(`${prefix}${context}\\.cookies\\.get\\s*\\([^\\r\\n]*\\)${suffix}`)], + ]; + for (const [kind, pattern] of candidates) { + const match = pattern.exec(line); + if (match) return { name: match[1]!, kind }; + } + return undefined; +} + +function exactSingleArgumentCall(line: string, binding: string): boolean { + const value = escapeRegex(binding); + return new RegExp( + `^\\s*(?:(?:return|await|void)\\s+)?[A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$][\\w$]*)*\\s*\\(\\s*${value}\\s*\\)\\s*;?\\s*$`, + ).test(line); +} + +function wordOccurrences(lines: readonly string[], startLine: number, endLine: number, name: string): number[] { + const output: number[] = []; + const pattern = new RegExp(`\\b${escapeRegex(name)}\\b`, "g"); + for (let lineNumber = startLine; lineNumber <= endLine; lineNumber += 1) { + const line = lines[lineNumber - 1] ?? ""; + pattern.lastIndex = 0; + let count = 0; + while (pattern.exec(line)) count += 1; + for (let index = 0; index < count; index += 1) output.push(lineNumber); + } + return output; +} + +async function readSafeJavascriptFiles( + rootPath: string, + graph: CallGraph, + options: KoaRequestInputForwardingOptions, +): Promise> { + const root = resolve(rootPath); + const maxFiles = boundedInteger(options.maxFiles, DEFAULT_MAX_FILES, MAX_FILES, "Koa request-forwarding maxFiles"); + const maxSourceBytes = boundedInteger( + options.maxSourceBytes, + DEFAULT_MAX_SOURCE_BYTES, + MAX_SOURCE_BYTES, + "Koa request-forwarding maxSourceBytes", + ); + const extensions = [".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"]; + const paths = [...new Set(graph.nodes + .filter((node) => extensions.some((extension) => node.path.toLowerCase().endsWith(extension))) + .map((node) => normalizedPath(node.path)))].slice(0, maxFiles); + const output = new Map(); + for (const path of paths) { + if (!path || path.includes("\0") || path.startsWith("../") || isAbsolute(path)) continue; + const absolute = resolve(root, path); + if (!insideRoot(root, absolute)) continue; + const info = await lstat(absolute).catch(() => undefined); + if (!info?.isFile() || info.isSymbolicLink() || info.size > maxSourceBytes) continue; + const source = await readFile(absolute, "utf8").catch(() => undefined); + if (!source || source.includes("\u0000")) continue; + output.set(comparisonPath(path), source.split(/\r?\n/)); + } + return output; +} + +function directTargets(graph: CallGraph, ownerId: string, line: number): string[] { + return [...new Set(graph.edges.flatMap((edge) => edge.from === ownerId && edge.line === line && edge.target ? [edge.target] : []))].sort(); +} + +function isDefensibleKoaSink( + sink: RouteSinkFlowContext["evidence"][number], + files: ReadonlyMap, +): boolean { + if (sink.kind !== "database") return true; + const line = files.get(comparisonPath(sink.path))?.[sink.line - 1] ?? ""; + return /\.\s*(?:query|execute|executemany|raw|rawQuery|createQueryRunner)\s*\(/i.test(line); +} + +/** + * Recognize one deliberately narrow Koa forwarding shape: + * + * const value = ctx.query.term; + * await sink(value); + * + * The source must be an exact assignment from the resolved handler's first plain context parameter. + * The local must have exactly one later occurrence in the handler, stay within maxForwardLines, and + * be passed unchanged as the sole argument of one exact call. Reassignment, multiple use, + * destructuring, transformations, aliasing, object spreading, middleware propagation and deeper + * call chains fail closed. This is structural repository evidence, not a taint or runtime model. + */ +export async function buildKoaRouteRequestInputForwardingContexts( + rootPath: string, + routeFlows: readonly RouteSinkFlowContext[], + graph: CallGraph, + options: KoaRequestInputForwardingOptions = {}, +): Promise { + const maxEvidence = boundedInteger(options.maxEvidence, DEFAULT_MAX_EVIDENCE, MAX_EVIDENCE, "Koa request-forwarding maxEvidence"); + const maxRoutes = boundedInteger(options.maxRoutes, DEFAULT_MAX_ROUTES, MAX_ROUTES, "Koa request-forwarding maxRoutes"); + const maxForwardLines = boundedInteger( + options.maxForwardLines, + DEFAULT_MAX_FORWARD_LINES, + MAX_FORWARD_LINES, + "Koa request-forwarding maxForwardLines", + ); + const files = await readSafeJavascriptFiles(rootPath, graph, options); + const output: KoaRouteRequestInputForwardingContext[] = []; + + for (const routeFlow of routeFlows.slice(0, maxRoutes)) { + if (routeFlow.route.frameworkHint !== "Koa router") continue; + const node = graph.nodes.find((candidate) => candidate.id === routeFlow.handler.id); + if (!node || (node.kind !== "function" && node.kind !== "arrow-function")) continue; + const lines = files.get(comparisonPath(node.path)); + if (!lines) continue; + const contextName = handlerContextParameter(node, lines); + if (!contextName) continue; + const evidence: KoaRequestInputForwardingEvidence[] = []; + + for (let sourceLine = node.line; sourceLine <= node.endLine && evidence.length < maxEvidence; sourceLine += 1) { + const binding = sourceBinding(lines[sourceLine - 1] ?? "", contextName); + if (!binding) continue; + const occurrences = wordOccurrences(lines, sourceLine + 1, node.endLine, binding.name); + if (occurrences.length !== 1) continue; + const useLine = occurrences[0]!; + if (useLine - sourceLine > maxForwardLines) continue; + const useText = lines[useLine - 1] ?? ""; + if (!exactSingleArgumentCall(useText, binding.name)) continue; + const targets = new Set(directTargets(graph, node.id, useLine)); + + for (const sink of routeFlow.evidence) { + if (!isDefensibleKoaSink(sink, files)) continue; + const sameLineSink = sink.functionId === node.id && sink.line === useLine; + const distance: 0 | 1 | undefined = sameLineSink ? 0 : targets.has(sink.functionId) ? 1 : undefined; + if (distance === undefined) continue; + evidence.push({ + source: { + path: node.path, + line: sourceLine, + kind: binding.kind, + functionId: node.id, + functionName: node.name, + }, + binding: { line: sourceLine, useLine }, + sink: { + path: sink.path, + line: sink.line, + kind: sink.kind, + functionId: sink.functionId, + functionName: sink.functionName, + }, + callDistance: distance, + }); + break; + } + } + + if (evidence.length === 0) continue; + output.push({ + route: routeFlow.route, + resolution: routeFlow.resolution, + handler: routeFlow.handler, + evidence, + sourceKinds: [...new Set(evidence.map((item) => item.source.kind))], + sinkKinds: [...new Set(evidence.map((item) => item.sink.kind))], + interpretation: "structural-koa-context-source-single-use-local-call-sink-evidence-only", + }); + } + return output; +} + +/** Return aggregate structural evidence for an exact finding sink line without request keys/values. */ +export function findingKoaRequestInputForwardingEvidence( + contexts: readonly KoaRouteRequestInputForwardingContext[], + path: string, + line: number | undefined, + maxRoutes = 3, +): FindingKoaRequestInputForwardingEvidence[] { + if (!Number.isSafeInteger(line) || (line ?? 0) <= 0) return []; + const normalized = comparisonPath(path); + const limit = Math.max(1, Math.min(10, maxRoutes)); + const output: FindingKoaRequestInputForwardingEvidence[] = []; + for (const context of contexts) { + for (const item of context.evidence) { + if (comparisonPath(item.sink.path) !== normalized || item.sink.line !== line) continue; + output.push({ + method: context.route.method, + route: context.route.route, + ...(context.route.frameworkHint ? { frameworkHint: context.route.frameworkHint } : {}), + handler: context.handler.name, + sourceKind: item.source.kind, + sourceFunction: item.source.functionName, + sinkKind: item.sink.kind, + sinkFunction: item.sink.functionName, + callDistance: item.callDistance, + bindingHops: 1, + interpretation: "structural-koa-context-source-single-use-local-call-sink-evidence-only", + }); + if (output.length >= limit) return output; + } + } + return output; +} diff --git a/packages/repository/src/koa-router-composition.ts b/packages/repository/src/koa-router-composition.ts new file mode 100644 index 00000000..db147163 --- /dev/null +++ b/packages/repository/src/koa-router-composition.ts @@ -0,0 +1,242 @@ +import { lstat, readFile } from "node:fs/promises"; +import { extname, isAbsolute, relative, resolve, sep } from "node:path"; +import type { IndexFileInput, RouteSignal } from "./analysis.js"; +import { findCallNeighborhood, type CallGraph, type CallGraphNode } from "./call-graph.js"; +import type { RouteEntrypoint } from "./route-entrypoints.js"; + +const MAX_SOURCE_BYTES = 512_000; +const DEFAULT_MAX_ROUTES = 2_000; +const MAX_ROUTES = 10_000; + +const HTTP_METHODS = new Map([ + ["get", "GET"], + ["post", "POST"], + ["put", "PUT"], + ["patch", "PATCH"], + ["delete", "DELETE"], + ["options", "OPTIONS"], + ["head", "HEAD"], +]); + +interface RouterDeclaration { + name: string; + line: number; + prefix: string; +} + +export interface KoaRouteMiddlewareContext { + route: RouteSignal; + router: { + name: string; + line: number; + prefix: string; + }; + handler: string; + middleware: Array<{ name: string; line: number }>; + /** Syntax-level router attachment only; this does not prove middleware executes or protects a request. */ + interpretation: "structural-koa-route-middleware-attachment-not-runtime-protection"; +} + +export interface KoaRouterCompositionResult { + entrypoints: RouteEntrypoint[]; + middlewareContexts: KoaRouteMiddlewareContext[]; +} + +function normalizePath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function insideRoot(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +function boundedInteger(value: number | undefined, fallback: number, maximum: number, label: string): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > maximum) { + throw new Error(`${label} must be an integer between 1 and ${maximum}.`); + } + return resolved; +} + +function normalizeRoute(value: string): string { + const segments = value.split("/").filter(Boolean); + return segments.length === 0 ? "" : `/${segments.join("/")}`; +} + +function composeRoute(prefix: string, child: string): string { + const segments = [prefix, child].flatMap((value) => value.split("/").filter(Boolean)); + return segments.length === 0 ? "/" : `/${segments.join("/")}`; +} + +async function safeReadSource(rootPath: string, file: IndexFileInput): Promise { + const extension = extname(file.path).toLowerCase(); + if (![".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"].includes(extension)) return undefined; + if (!file.path || file.path.includes("\0") || isAbsolute(file.path)) return undefined; + const root = resolve(rootPath); + const candidate = resolve(root, file.path); + if (!insideRoot(root, candidate)) return undefined; + const info = await lstat(candidate).catch(() => undefined); + if (!info?.isFile() || info.isSymbolicLink() || info.size > MAX_SOURCE_BYTES) return undefined; + const content = await readFile(candidate, "utf8").catch(() => undefined); + return content?.includes("\u0000") ? undefined : content; +} + +function escapeIdentifier(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function routerImportLine(line: string): boolean { + return /^\s*import\s+Router\s+from\s+["'](?:@koa\/router|koa-router)["']\s*;?\s*$/.test(line) + || /^\s*const\s+Router\s*=\s*require\s*\(\s*["'](?:@koa\/router|koa-router)["']\s*\)\s*;?\s*$/.test(line); +} + +function hasSingleUnshadowedRouterImport(lines: readonly string[], useLine: number): boolean { + const imports: number[] = []; + for (let index = 0; index < Math.min(lines.length, useLine - 1); index += 1) { + if (routerImportLine(lines[index] ?? "")) imports.push(index + 1); + } + if (imports.length !== 1) return false; + const importLine = imports[0]; + if (!importLine) return false; + for (let index = importLine; index < useLine - 1; index += 1) { + const line = lines[index] ?? ""; + if (/^\s*(?:const|let|var|function|class)\s+Router\b/.test(line) || /^\s*Router\s*=(?!=)/.test(line)) return false; + } + return true; +} + +function parseRouterDeclaration(line: string, lineNumber: number): RouterDeclaration | undefined { + const empty = line.match(/^\s*const\s+([A-Za-z_$][\w$]*)\s*=\s*new\s+Router\s*\(\s*\)\s*;?\s*$/); + if (empty?.[1]) return { name: empty[1], line: lineNumber, prefix: "" }; + const prefixed = line.match(/^\s*const\s+([A-Za-z_$][\w$]*)\s*=\s*new\s+Router\s*\(\s*\{\s*prefix\s*:\s*(["'])([^"']*)\2\s*\}\s*\)\s*;?\s*$/); + if (!prefixed?.[1] || prefixed[3] === undefined) return undefined; + return { name: prefixed[1], line: lineNumber, prefix: normalizeRoute(prefixed[3]) }; +} + +function routerBindingStillValid(lines: readonly string[], declaration: RouterDeclaration, useLine: number): boolean { + const escaped = escapeIdentifier(declaration.name); + const declarationPattern = new RegExp(`^\\s*(?:const|let|var|function|class)\\s+${escaped}\\b`); + const assignmentPattern = new RegExp(`^\\s*${escaped}\\s*=(?!=)`); + for (let index = declaration.line; index < useLine - 1; index += 1) { + const line = lines[index] ?? ""; + if (declarationPattern.test(line) || assignmentPattern.test(line)) return false; + } + return true; +} + +function parsePlainArguments(value: string): string[] | undefined { + const args = value.split(",").map((part) => part.trim()); + if (args.length === 0 || args.some((arg) => !/^[A-Za-z_$][\w$]*$/.test(arg))) return undefined; + return args; +} + +function localHandler(graph: CallGraph, path: string, name: string): CallGraphNode | undefined { + const matches = graph.nodes.filter((node) => normalizePath(node.path) === path && node.name === name); + return matches.length === 1 ? matches[0] : undefined; +} + +function routeKey(entrypoint: RouteEntrypoint): string { + return [normalizePath(entrypoint.route.path), entrypoint.route.line, entrypoint.route.method, entrypoint.route.route, entrypoint.route.frameworkHint ?? "", entrypoint.route.handler ?? "", entrypoint.handler?.id ?? ""].join("\0"); +} + +/** + * Resolve a deliberately narrow subset of Koa router registrations into structural route entrypoints. + * + * Accepted syntax requires exactly one unaliased `Router` import/require from `@koa/router` or the + * legacy `koa-router` package, a `const router = new Router()` declaration with an optional literal + * `{ prefix: "..." }`, and a one-line HTTP registration whose callback arguments are all plain + * identifiers. The final identifier is treated as the handler and preceding identifiers are retained + * as review-only middleware attachment evidence. Same-file handlers resolve immediately; unresolved + * named handlers remain eligible for SynSec's existing repository-local named-import resolver. + * + * Dynamic prefixes, router factories, member-expression callbacks, inline callbacks, transformed + * middleware, reassigned router bindings, ambiguous handlers, unsafe files, and unsupported syntax + * fail closed. This does not prove Koa mounts the router, middleware executes, a route is externally + * reachable, input is attacker-controlled, or a finding is exploitable. + */ +export async function composeKoaRouterEntrypoints( + rootPath: string, + files: readonly IndexFileInput[], + graph: CallGraph, + entrypoints: readonly RouteEntrypoint[], + options: { maxRoutes?: number; maxCallDepth?: number; maxCallNodes?: number } = {}, +): Promise { + const maxRoutes = boundedInteger(options.maxRoutes, DEFAULT_MAX_ROUTES, MAX_ROUTES, "Koa maxRoutes"); + const maxCallDepth = Math.max(0, Math.min(20, options.maxCallDepth ?? 3)); + const maxCallNodes = Math.max(1, Math.min(1_000, options.maxCallNodes ?? 100)); + const output = [...entrypoints]; + const existing = new Set(output.map(routeKey)); + const middlewareContexts: KoaRouteMiddlewareContext[] = []; + let produced = 0; + + for (const file of files) { + if (produced >= maxRoutes) break; + const content = await safeReadSource(rootPath, file); + if (content === undefined) continue; + const path = normalizePath(file.path); + const lines = content.split(/\r?\n/); + const routers = new Map(); + + for (let index = 0; index < lines.length; index += 1) { + const declaration = parseRouterDeclaration(lines[index] ?? "", index + 1); + if (!declaration || !hasSingleUnshadowedRouterImport(lines, declaration.line)) continue; + if (routers.has(declaration.name)) routers.delete(declaration.name); + else routers.set(declaration.name, declaration); + } + + for (let index = 0; index < lines.length && produced < maxRoutes; index += 1) { + const line = lines[index] ?? ""; + const match = line.match(/^\s*([A-Za-z_$][\w$]*)\.(get|post|put|patch|delete|options|head)\s*\(\s*(["'])([^"']*)\3\s*,\s*(.*?)\s*\)\s*;?\s*$/i); + if (!match?.[1] || !match[2] || match[4] === undefined || match[5] === undefined) continue; + const router = routers.get(match[1]); + if (!router || router.line >= index + 1 || !routerBindingStillValid(lines, router, index + 1)) continue; + const httpMethod = HTTP_METHODS.get(match[2].toLowerCase()); + if (!httpMethod) continue; + const callbacks = parsePlainArguments(match[5]); + if (!callbacks || callbacks.length === 0) continue; + const handlerName = callbacks.at(-1); + if (!handlerName) continue; + const middlewareNames = callbacks.slice(0, -1); + const route: RouteSignal = { + path, + line: index + 1, + method: httpMethod, + route: composeRoute(router.prefix, normalizeRoute(match[4])), + frameworkHint: "Koa router", + handler: handlerName, + }; + const handler = localHandler(graph, path, handlerName); + const entrypoint: RouteEntrypoint = handler + ? { + route, + resolution: "named-function", + handler, + calls: findCallNeighborhood(graph, handler.id, maxCallDepth, maxCallNodes), + interpretation: "structural-route-call-evidence-only", + } + : { + route, + resolution: "unresolved", + interpretation: "structural-route-call-evidence-only", + }; + const key = routeKey(entrypoint); + if (existing.has(key)) continue; + existing.add(key); + output.push(entrypoint); + produced += 1; + + if (middlewareNames.length > 0) { + middlewareContexts.push({ + route, + router: { name: router.name, line: router.line, prefix: router.prefix }, + handler: handlerName, + middleware: middlewareNames.map((name) => ({ name, line: index + 1 })), + interpretation: "structural-koa-route-middleware-attachment-not-runtime-protection", + }); + } + } + } + + return { entrypoints: output, middlewareContexts }; +} diff --git a/packages/repository/src/module-graph.ts b/packages/repository/src/module-graph.ts new file mode 100644 index 00000000..ca526ec7 --- /dev/null +++ b/packages/repository/src/module-graph.ts @@ -0,0 +1,217 @@ +import { posix } from "node:path"; +import type { IndexFileInput, ModuleEdge, RepositoryIndex } from "./analysis.js"; + +export type ModuleResolution = "repository-file" | "external-or-unresolved"; +export type ModuleResolutionEvidence = "relative-import" | "repository-root-python-package"; + +export interface ResolvedModuleEdge extends ModuleEdge { + target?: string; + resolution: ModuleResolution; + /** Why SynSec considered this edge repository-local. Omitted for unresolved/external edges. */ + resolutionEvidence?: ModuleResolutionEvidence; +} + +export interface ModuleGraph { + schemaVersion: 1; + nodes: string[]; + edges: ResolvedModuleEdge[]; + resolvedEdgeCount: number; + unresolvedEdgeCount: number; +} + +export interface ModuleNeighborhood { + root: string; + maxDepth: number; + dependencies: Array<{ path: string; depth: number }>; + dependents: Array<{ path: string; depth: number }>; + /** Module-level import reachability is structural evidence, not function-level data flow. */ + interpretation: "module-import-reachability-only"; +} + +const jsExtensions = [ + ".js", ".mjs", ".cjs", ".jsx", + ".ts", ".mts", ".cts", ".tsx", +]; + +function normalizeRepositoryPath(value: string): string { + const normalized = posix.normalize(value.replaceAll("\\", "/").replace(/^\.\//, "")); + return normalized === "." ? "" : normalized.replace(/^\//, ""); +} + +function candidateLookup(files: readonly IndexFileInput[]): Map { + const lookup = new Map(); + for (const file of files) { + const normalized = normalizeRepositoryPath(file.path); + if (!normalized || normalized.startsWith("../")) continue; + lookup.set(normalized.toLowerCase(), normalized); + } + return lookup; +} + +function addJsCandidates(candidates: string[], base: string): void { + candidates.push(base); + const extension = posix.extname(base).toLowerCase(); + if (!extension) { + for (const ext of jsExtensions) candidates.push(`${base}${ext}`); + for (const ext of jsExtensions) candidates.push(posix.join(base, `index${ext}`)); + return; + } + + const sourceExtensionMap: Record = { + ".js": [".ts", ".tsx"], + ".mjs": [".mts"], + ".cjs": [".cts"], + ".jsx": [".tsx"], + }; + const stem = base.slice(0, -extension.length); + for (const ext of sourceExtensionMap[extension] ?? []) candidates.push(`${stem}${ext}`); +} + +function resolveJavascriptEdge(edge: ModuleEdge, lookup: Map): string | undefined { + if (!edge.specifier.startsWith(".")) return undefined; + const base = normalizeRepositoryPath(posix.join(posix.dirname(normalizeRepositoryPath(edge.from)), edge.specifier)); + if (!base || base.startsWith("../")) return undefined; + const candidates: string[] = []; + addJsCandidates(candidates, base); + for (const candidate of candidates) { + const found = lookup.get(candidate.toLowerCase()); + if (found) return found; + } + return undefined; +} + +function uniquePythonTarget(base: string, lookup: Map): string | undefined { + const matches = [`${base}.py`, posix.join(base, "__init__.py")] + .map((candidate) => lookup.get(candidate.toLowerCase())) + .filter((candidate): candidate is string => candidate !== undefined); + return matches.length === 1 ? matches[0] : undefined; +} + +function hasTopLevelPythonPackage(specifier: string, lookup: Map): boolean { + const firstSegment = specifier.split(".", 1)[0]; + if (!firstSegment || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(firstSegment)) return false; + return lookup.has(`${firstSegment.toLowerCase()}/__init__.py`); +} + +function resolvePythonEdge( + edge: ModuleEdge, + lookup: Map, +): { target: string; evidence: ModuleResolutionEvidence } | undefined { + if (edge.specifier.startsWith(".")) { + const leadingDots = edge.specifier.match(/^\.+/)?.[0].length ?? 0; + let directory = posix.dirname(normalizeRepositoryPath(edge.from)); + for (let level = 1; level < leadingDots; level += 1) directory = posix.dirname(directory); + const remainder = edge.specifier.slice(leadingDots).replaceAll(".", "/"); + const base = normalizeRepositoryPath(remainder ? posix.join(directory, remainder) : directory); + if (!base || base.startsWith("../")) return undefined; + const target = uniquePythonTarget(base, lookup); + return target ? { target, evidence: "relative-import" } : undefined; + } + + // Absolute Python imports are only treated as repository-local when their first + // segment is an explicit top-level Python package in the indexed repository. + // This avoids guessing that an import such as `requests` refers to a same-named + // repository file rather than an installed dependency. + if (!/^[A-Za-z_][A-Za-z0-9_.]*$/.test(edge.specifier) || !hasTopLevelPythonPackage(edge.specifier, lookup)) { + return undefined; + } + const base = normalizeRepositoryPath(edge.specifier.replaceAll(".", "/")); + const target = uniquePythonTarget(base, lookup); + return target ? { target, evidence: "repository-root-python-package" } : undefined; +} + +function resolveEdge( + edge: ModuleEdge, + lookup: Map, +): { target: string; evidence: ModuleResolutionEvidence } | undefined { + if (edge.kind === "python-import") return resolvePythonEdge(edge, lookup); + if (edge.kind === "import" || edge.kind === "require" || edge.kind === "dynamic-import") { + const target = resolveJavascriptEdge(edge, lookup); + return target ? { target, evidence: "relative-import" } : undefined; + } + return undefined; +} + +export function buildModuleGraph(index: RepositoryIndex, files: readonly IndexFileInput[]): ModuleGraph { + const lookup = candidateLookup(files); + const nodes = [...lookup.values()].sort(); + let resolvedEdgeCount = 0; + const edges = index.moduleEdges.map((edge): ResolvedModuleEdge => { + const resolved = resolveEdge(edge, lookup); + if (resolved) { + resolvedEdgeCount += 1; + return { + ...edge, + target: resolved.target, + resolution: "repository-file", + resolutionEvidence: resolved.evidence, + }; + } + return { ...edge, resolution: "external-or-unresolved" }; + }); + + return { + schemaVersion: 1, + nodes, + edges, + resolvedEdgeCount, + unresolvedEdgeCount: edges.length - resolvedEdgeCount, + }; +} + +function traverse( + graph: ModuleGraph, + root: string, + direction: "dependencies" | "dependents", + maxDepth: number, + maxNodes: number, +): Array<{ path: string; depth: number }> { + const normalizedRoot = normalizeRepositoryPath(root); + const boundedDepth = Math.max(0, maxDepth); + const boundedNodes = Math.max(1, maxNodes); + const queue: Array<{ path: string; depth: number }> = [{ path: normalizedRoot, depth: 0 }]; + const seen = new Set([normalizedRoot.toLowerCase()]); + const output: Array<{ path: string; depth: number }> = []; + + while (queue.length > 0 && output.length < boundedNodes) { + const current = queue.shift(); + if (!current || current.depth >= boundedDepth) continue; + + const adjacent = graph.edges.flatMap((edge) => { + if (!edge.target) return []; + const from = normalizeRepositoryPath(edge.from); + const target = normalizeRepositoryPath(edge.target); + if (direction === "dependencies" && from.toLowerCase() === current.path.toLowerCase()) return [target]; + if (direction === "dependents" && target.toLowerCase() === current.path.toLowerCase()) return [from]; + return []; + }); + + for (const path of adjacent.sort()) { + const key = path.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + const next = { path, depth: current.depth + 1 }; + output.push(next); + queue.push(next); + if (output.length >= boundedNodes) break; + } + } + + return output; +} + +export function findModuleNeighborhood( + graph: ModuleGraph, + root: string, + maxDepth = 3, + maxNodesPerDirection = 100, +): ModuleNeighborhood { + const normalizedRoot = normalizeRepositoryPath(root); + return { + root: normalizedRoot, + maxDepth: Math.max(0, maxDepth), + dependencies: traverse(graph, normalizedRoot, "dependencies", maxDepth, maxNodesPerDirection), + dependents: traverse(graph, normalizedRoot, "dependents", maxDepth, maxNodesPerDirection), + interpretation: "module-import-reachability-only", + }; +} diff --git a/packages/repository/src/nestjs-controller-composition.ts b/packages/repository/src/nestjs-controller-composition.ts new file mode 100644 index 00000000..b079c2eb --- /dev/null +++ b/packages/repository/src/nestjs-controller-composition.ts @@ -0,0 +1,394 @@ +import { lstat, readFile } from "node:fs/promises"; +import { extname, isAbsolute, relative, resolve, sep } from "node:path"; +import type { IndexFileInput, RouteSignal } from "./analysis.js"; +import { findCallNeighborhood, type CallGraph, type CallGraphNode } from "./call-graph.js"; +import type { RouteEntrypoint } from "./route-entrypoints.js"; + +const MAX_SOURCE_BYTES = 512_000; +const DEFAULT_MAX_DECORATOR_DISTANCE = 8; +const MAX_DECORATOR_DISTANCE = 20; +const DEFAULT_MAX_ROUTES = 2_000; +const MAX_ROUTES = 10_000; + +const HTTP_DECORATORS = new Map([ + ["Get", "GET"], + ["Post", "POST"], + ["Put", "PUT"], + ["Patch", "PATCH"], + ["Delete", "DELETE"], + ["Options", "OPTIONS"], + ["Head", "HEAD"], +]); + +interface ClassSpan { + path: string; + name: string; + line: number; + endLine: number; + prefix: string; + controllerDecoratorLine: number; + controllerGuards: GuardAttachment[]; +} + +interface MethodSpan { + name: string; + line: number; + endLine: number; +} + +interface GuardAttachment { + name: string; + line: number; + scope: "controller" | "method"; +} + +export interface NestJsGuardContext { + route: RouteSignal; + controller: { + path: string; + name: string; + line: number; + }; + handler: { + name: string; + line: number; + }; + guards: GuardAttachment[]; + /** Decorator attachment is structural evidence only; it is not proof that a guard executes or authorizes a request. */ + interpretation: "structural-nestjs-guard-attachment-not-runtime-protection"; +} + +export interface NestJsControllerCompositionResult { + entrypoints: RouteEntrypoint[]; + guardContexts: NestJsGuardContext[]; +} + +function normalizePath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function insideRoot(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +function boundedInteger(value: number | undefined, fallback: number, maximum: number, label: string): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > maximum) { + throw new Error(`${label} must be an integer between 1 and ${maximum}.`); + } + return resolved; +} + +function normalizeRoute(value: string): string { + const segments = value.split("/").filter(Boolean); + return segments.length === 0 ? "" : `/${segments.join("/")}`; +} + +function composeRoute(prefix: string, child: string): string { + const segments = [prefix, child].flatMap((value) => value.split("/").filter(Boolean)); + return segments.length === 0 ? "/" : `/${segments.join("/")}`; +} + +function braceDelta(line: string): number { + let delta = 0; + let quote: "'" | '"' | "`" | undefined; + let escaped = false; + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + const next = line[index + 1]; + if (escaped) { + escaped = false; + continue; + } + if (quote) { + if (char === "\\") escaped = true; + else if (char === quote) quote = undefined; + continue; + } + if (char === "'" || char === '"' || char === "`") { + quote = char; + continue; + } + if (char === "/" && next === "/") break; + if (char === "{") delta += 1; + else if (char === "}") delta -= 1; + } + return delta; +} + +async function safeReadSource(rootPath: string, file: IndexFileInput): Promise { + const extension = extname(file.path).toLowerCase(); + if (![".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"].includes(extension)) return undefined; + if (!file.path || file.path.includes("\0") || isAbsolute(file.path)) return undefined; + const root = resolve(rootPath); + const candidate = resolve(root, file.path); + if (!insideRoot(root, candidate)) return undefined; + const info = await lstat(candidate).catch(() => undefined); + if (!info?.isFile() || info.isSymbolicLink() || info.size > MAX_SOURCE_BYTES) return undefined; + const content = await readFile(candidate, "utf8").catch(() => undefined); + return content?.includes("\u0000") ? undefined : content; +} + +function escapeIdentifier(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function hasUnshadowedNestImport(content: string, symbol: string, useLine: number): boolean { + const lines = content.split(/\r?\n/); + const imports: number[] = []; + for (let index = 0; index < Math.min(lines.length, useLine - 1); index += 1) { + const match = (lines[index] ?? "").match(/^\s*import\s*\{([^}]+)\}\s*from\s*["']@nestjs\/common["']\s*;?\s*$/); + if (!match?.[1]) continue; + const parts = match[1].split(",").map((part) => part.trim()); + if (parts.some((part) => part === symbol)) imports.push(index + 1); + if (parts.some((part) => new RegExp(`^${escapeIdentifier(symbol)}\\s+as\\s+`).test(part))) return false; + } + if (imports.length !== 1) return false; + const importLine = imports[0]; + if (!importLine) return false; + const escaped = escapeIdentifier(symbol); + const declaration = new RegExp(`^\\s*(?:class|function|const|let|var)\\s+${escaped}\\b`); + const assignment = new RegExp(`^\\s*${escaped}\\s*=(?!=)`); + for (let index = importLine; index < useLine - 1; index += 1) { + const line = lines[index] ?? ""; + if (declaration.test(line) || assignment.test(line)) return false; + } + return true; +} + +function decoratorBlock(lines: readonly string[], declarationLine: number, maxDistance: number): Array<{ line: number; text: string }> { + const output: Array<{ line: number; text: string }> = []; + let cursor = declarationLine - 2; + while (cursor >= 0 && declarationLine - (cursor + 1) <= maxDistance) { + const text = (lines[cursor] ?? "").trim(); + if (!text) { + cursor -= 1; + continue; + } + if (!text.startsWith("@")) break; + output.push({ line: cursor + 1, text }); + cursor -= 1; + } + return output.reverse(); +} + +function parseLiteralDecorator(text: string, name: string): string | undefined { + const escaped = escapeIdentifier(name); + const empty = new RegExp(`^@${escaped}\\(\\s*\\)\\s*;?$`).exec(text); + if (empty) return ""; + const literal = new RegExp(`^@${escaped}\\(\\s*(["'])([^"']*)\\1\\s*\\)\\s*;?$`).exec(text); + return literal?.[2] === undefined ? undefined : normalizeRoute(literal[2]); +} + +function parseGuards(text: string): string[] | undefined { + const match = text.match(/^@UseGuards\(\s*([^)]*?)\s*\)\s*;?$/); + if (!match?.[1]) return undefined; + const names = match[1].split(",").map((part) => part.trim()); + if (names.length === 0 || names.some((name) => !/^[A-Za-z_$][\w$]*$/.test(name))) return undefined; + return names; +} + +function classSpan(lines: readonly string[], startIndex: number): { name: string; endLine: number } | undefined { + const line = lines[startIndex] ?? ""; + const match = line.match(/^\s*(?:export\s+)?(?:default\s+)?class\s+([A-Za-z_$][\w$]*)(?:\s+extends\s+[^\{]+)?\s*\{/); + const name = match?.[1]; + if (!name) return undefined; + let depth = braceDelta(line); + if (depth <= 0) return undefined; + for (let cursor = startIndex + 1; cursor < lines.length; cursor += 1) { + depth += braceDelta(lines[cursor] ?? ""); + if (depth <= 0) return { name, endLine: cursor + 1 }; + } + return undefined; +} + +function classMethods(lines: readonly string[], controller: ClassSpan): MethodSpan[] { + const output: MethodSpan[] = []; + let depth = 1; + for (let index = controller.line; index < controller.endLine - 1; index += 1) { + const line = lines[index] ?? ""; + if (depth === 1) { + const match = line.match(/^\s*(?:(?:public|private|protected|static|readonly|override|async)\s+)*([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*(?::[^\{]+)?\s*\{/); + const name = match?.[1]; + if (name && name !== "constructor") { + let methodDepth = braceDelta(line); + if (methodDepth > 0) { + let endLine = index + 1; + for (let cursor = index + 1; cursor < controller.endLine; cursor += 1) { + methodDepth += braceDelta(lines[cursor] ?? ""); + endLine = cursor + 1; + if (methodDepth <= 0) break; + } + output.push({ name, line: index + 1, endLine }); + } + } + } + depth += braceDelta(line); + } + return output; +} + +function routeKey(entrypoint: RouteEntrypoint): string { + return [normalizePath(entrypoint.route.path), entrypoint.route.line, entrypoint.route.method, entrypoint.route.route, entrypoint.handler?.id ?? ""].join("\0"); +} + +/** + * Resolve explicit NestJS controller + HTTP-method decorators into bounded structural route entrypoints. + * + * Only unaliased one-line named imports from `@nestjs/common`, literal `@Controller()` prefixes, + * literal/empty HTTP decorators, and immediate class methods are accepted. `@UseGuards()` evidence is + * retained only for plain identifier arguments. Dynamic decorator arguments, decorator aliases, + * factories such as `AuthGuard("jwt")`, shadowed decorator bindings, malformed class boundaries, and + * unsupported syntax fail closed. Synthetic class-method nodes are added only to the caller-owned + * lexical call graph so exact sinks inside the resolved method can participate in existing route/sink + * correlation. No claim is made that NestJS instantiates the controller, executes a guard, exposes the + * route at runtime, or makes a finding exploitable. + */ +export async function composeNestJsControllerEntrypoints( + rootPath: string, + files: readonly IndexFileInput[], + graph: CallGraph, + entrypoints: readonly RouteEntrypoint[], + options: { maxDecoratorDistance?: number; maxRoutes?: number; maxCallDepth?: number; maxCallNodes?: number } = {}, +): Promise { + const maxDecoratorDistance = boundedInteger(options.maxDecoratorDistance, DEFAULT_MAX_DECORATOR_DISTANCE, MAX_DECORATOR_DISTANCE, "NestJS maxDecoratorDistance"); + const maxRoutes = boundedInteger(options.maxRoutes, DEFAULT_MAX_ROUTES, MAX_ROUTES, "NestJS maxRoutes"); + const maxCallDepth = Math.max(0, Math.min(20, options.maxCallDepth ?? 3)); + const maxCallNodes = Math.max(1, Math.min(1_000, options.maxCallNodes ?? 100)); + const output = [...entrypoints]; + const existing = new Set(output.map(routeKey)); + const guardContexts: NestJsGuardContext[] = []; + let produced = 0; + + for (const file of files) { + if (produced >= maxRoutes) break; + const content = await safeReadSource(rootPath, file); + if (content === undefined) continue; + const path = normalizePath(file.path); + const lines = content.split(/\r?\n/); + const controllers: ClassSpan[] = []; + + for (let index = 0; index < lines.length; index += 1) { + const span = classSpan(lines, index); + if (!span) continue; + const declarationLine = index + 1; + const decorators = decoratorBlock(lines, declarationLine, maxDecoratorDistance); + const controllerDecorators = decorators.flatMap((decorator) => { + const prefix = parseLiteralDecorator(decorator.text, "Controller"); + return prefix === undefined ? [] : [{ ...decorator, prefix }]; + }); + if (controllerDecorators.length !== 1) continue; + const controllerDecorator = controllerDecorators[0]; + if (!controllerDecorator || !hasUnshadowedNestImport(content, "Controller", controllerDecorator.line)) continue; + + const controllerGuards: GuardAttachment[] = []; + let invalidGuardDecorator = false; + for (const decorator of decorators.filter((candidate) => candidate.text.startsWith("@UseGuards"))) { + if (!hasUnshadowedNestImport(content, "UseGuards", decorator.line)) { + invalidGuardDecorator = true; + break; + } + const guards = parseGuards(decorator.text); + if (!guards) { + invalidGuardDecorator = true; + break; + } + controllerGuards.push(...guards.map((name) => ({ name, line: decorator.line, scope: "controller" as const }))); + } + if (invalidGuardDecorator) continue; + controllers.push({ + path, + name: span.name, + line: declarationLine, + endLine: span.endLine, + prefix: controllerDecorator.prefix, + controllerDecoratorLine: controllerDecorator.line, + controllerGuards, + }); + index = span.endLine - 1; + } + + for (const controller of controllers) { + for (const method of classMethods(lines, controller)) { + if (produced >= maxRoutes) break; + const decorators = decoratorBlock(lines, method.line, maxDecoratorDistance); + const routeDecorators: Array<{ line: number; method: string; route: string; symbol: string }> = []; + for (const decorator of decorators) { + for (const [symbol, httpMethod] of HTTP_DECORATORS) { + const route = parseLiteralDecorator(decorator.text, symbol); + if (route === undefined) continue; + if (!hasUnshadowedNestImport(content, symbol, decorator.line)) continue; + routeDecorators.push({ line: decorator.line, method: httpMethod, route, symbol }); + } + } + if (routeDecorators.length !== 1) continue; + const routeDecorator = routeDecorators[0]; + if (!routeDecorator) continue; + + const methodGuards: GuardAttachment[] = []; + let invalidGuardDecorator = false; + for (const decorator of decorators.filter((candidate) => candidate.text.startsWith("@UseGuards"))) { + if (!hasUnshadowedNestImport(content, "UseGuards", decorator.line)) { + invalidGuardDecorator = true; + break; + } + const guards = parseGuards(decorator.text); + if (!guards) { + invalidGuardDecorator = true; + break; + } + methodGuards.push(...guards.map((name) => ({ name, line: decorator.line, scope: "method" as const }))); + } + if (invalidGuardDecorator) continue; + + const handlerId = `${path}:${controller.name}.${method.name}:${method.line}`; + let handler = graph.nodes.find((node) => node.id === handlerId); + if (!handler) { + handler = { + id: handlerId, + path, + name: method.name, + line: method.line, + endLine: method.endLine, + kind: "function", + } satisfies CallGraphNode; + graph.nodes.push(handler); + } + const route: RouteSignal = { + path, + line: routeDecorator.line, + method: routeDecorator.method, + route: composeRoute(controller.prefix, routeDecorator.route), + frameworkHint: "NestJS controller", + handler: method.name, + }; + const entrypoint: RouteEntrypoint = { + route, + resolution: "decorated-function", + handler, + calls: findCallNeighborhood(graph, handler.id, maxCallDepth, maxCallNodes), + interpretation: "structural-route-call-evidence-only", + }; + const key = routeKey(entrypoint); + if (existing.has(key)) continue; + existing.add(key); + output.push(entrypoint); + produced += 1; + + const guards = [...controller.controllerGuards, ...methodGuards]; + if (guards.length > 0) { + guardContexts.push({ + route, + controller: { path, name: controller.name, line: controller.line }, + handler: { name: method.name, line: method.line }, + guards, + interpretation: "structural-nestjs-guard-attachment-not-runtime-protection", + }); + } + } + } + } + + return { entrypoints: output, guardContexts }; +} diff --git a/packages/repository/src/posture-html.ts b/packages/repository/src/posture-html.ts new file mode 100644 index 00000000..1e5bb7c2 --- /dev/null +++ b/packages/repository/src/posture-html.ts @@ -0,0 +1,54 @@ +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import type { RepositoryPostureSummary } from "./posture.js"; + +/** Render aggregate bounded lexical posture signals without source text or route paths. */ +export function renderRepositoryPostureHtml(posture: RepositoryPostureSummary): string { + const authObserved = posture.routeAuth["authorization-signal-observed"] + posture.routeAuth["authentication-signal-observed"]; + return ` + + + + + +SynSec repository posture + + + +

SynSec repository posture

+

Bounded lexical repository signals only. These counts are prioritization evidence, not runtime exposure, data-flow reachability, or proof that authentication is present or absent at runtime.

+
+
${posture.indexedFileCount}
indexed files
+
${posture.routeCount}
route signals
+
${authObserved}
routes with nearby auth signals
+
${posture.routesWithoutAuthSignals}
routes with no nearby auth signal observed
+
${posture.routesWithSinkSignals}
routes with nearby sink signals
+
+
+

Authentication / authorization proximity

+ + + + +
Authorization signal observed${posture.routeAuth["authorization-signal-observed"]}
Authentication signal observed${posture.routeAuth["authentication-signal-observed"]}
No auth signal observed${posture.routeAuth["no-auth-signal-observed"]}
+
+
+

Nearby sink kinds

+ + + + + +
Process${posture.routeSinkKinds.process}
Filesystem${posture.routeSinkKinds.filesystem}
Database${posture.routeSinkKinds.database}
Network${posture.routeSinkKinds.network}
+
+ +\n`; +} + +export async function writeRepositoryPostureHtml(path: string, posture: RepositoryPostureSummary): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, renderRepositoryPostureHtml(posture), { encoding: "utf8", mode: 0o600 }); + await chmod(path, 0o600).catch(() => undefined); +} diff --git a/packages/repository/src/posture.ts b/packages/repository/src/posture.ts new file mode 100644 index 00000000..add1e79a --- /dev/null +++ b/packages/repository/src/posture.ts @@ -0,0 +1,60 @@ +import type { RepositoryIndex, SinkSignal } from "./analysis.js"; +import { repositoryRouteAuthContexts, type RouteAuthStatus } from "./route-auth-context.js"; +import { repositoryRouteSinkContexts } from "./route-sink-context.js"; + +export interface RepositoryPostureSummary { + schemaVersion: 1; + indexedFileCount: number; + routeCount: number; + routeAuth: Record; + routeSinkKinds: Record; + routesWithSinkSignals: number; + routesWithoutAuthSignals: number; + /** Counts are derived from lexical repository signals and are not runtime security assertions. */ + interpretation: "bounded-lexical-posture-only"; +} + +export function buildRepositoryPosture( + index: RepositoryIndex, + options: { authRadius?: number; sinkRadius?: number; maxRoutes?: number } = {}, +): RepositoryPostureSummary { + const maxRoutes = Math.max(0, Math.min(5_000, options.maxRoutes ?? 1_000)); + const routeAuth = repositoryRouteAuthContexts(index, { + radius: options.authRadius, + maxRoutes, + }); + const routeSinks = repositoryRouteSinkContexts(index, { + radius: options.sinkRadius, + maxRoutes, + }); + + const authCounts: Record = { + "authorization-signal-observed": 0, + "authentication-signal-observed": 0, + "no-auth-signal-observed": 0, + }; + for (const route of routeAuth) authCounts[route.status] += 1; + + const sinkCounts: Record = { + process: 0, + filesystem: 0, + database: 0, + network: 0, + }; + let routesWithSinkSignals = 0; + for (const route of routeSinks) { + if (route.kinds.length > 0) routesWithSinkSignals += 1; + for (const kind of route.kinds) sinkCounts[kind] += 1; + } + + return { + schemaVersion: 1, + indexedFileCount: index.indexedFileCount, + routeCount: Math.min(index.routes.length, maxRoutes), + routeAuth: authCounts, + routeSinkKinds: sinkCounts, + routesWithSinkSignals, + routesWithoutAuthSignals: authCounts["no-auth-signal-observed"], + interpretation: "bounded-lexical-posture-only", + }; +} diff --git a/packages/repository/src/request-input-flow.ts b/packages/repository/src/request-input-flow.ts new file mode 100644 index 00000000..e6f03d14 --- /dev/null +++ b/packages/repository/src/request-input-flow.ts @@ -0,0 +1,511 @@ +import { lstat, readFile } from "node:fs/promises"; +import { extname, isAbsolute, relative, resolve, sep } from "node:path"; +import type { IndexFileInput, RepositoryIndex, RouteSignal, SinkSignal } from "./analysis.js"; +import type { CallGraph, CallGraphNode } from "./call-graph.js"; +import type { ImportCallLinkGraph } from "./import-call-links.js"; +import type { RouteEntrypoint, RouteEntrypointResolution } from "./route-entrypoints.js"; + +export type RequestInputKind = "body" | "query" | "path" | "header" | "cookie" | "file"; + +export interface RequestInputSignal { + path: string; + line: number; + kind: RequestInputKind; + frameworkFamily: "node-request" | "python-request"; + /** Sanitized structural access category; source text and values are intentionally excluded. */ + access: string; +} + +export interface RequestInputFlowEvidence { + source: { + path: string; + line: number; + kind: RequestInputKind; + frameworkFamily: RequestInputSignal["frameworkFamily"]; + access: string; + functionId: string; + functionName: string; + routeDepth: number; + }; + sink: { + path: string; + line: number; + kind: SinkSignal["kind"]; + functionId: string; + functionName: string; + routeDepth: number; + }; + /** Directed lexical/import-call distance from the source-bearing call to the sink-owning function. */ + callDistance: number; +} + +export interface RouteRequestInputFlowContext { + route: RouteSignal; + resolution: Exclude; + handler: { + id: string; + name: string; + path: string; + line: number; + endLine: number; + }; + evidence: RequestInputFlowEvidence[]; + sourceKinds: RequestInputKind[]; + sinkKinds: SinkSignal["kind"][]; + callScope: "same-file" | "same-file-and-explicit-imports"; + /** + * This proves only a bounded structural request-access -> same-line outbound call -> directed call + * path -> sink relationship (or request access and sink on the exact same line). It is not variable- + * level taint, runtime reachability, attacker control, or exploitability evidence. + */ + interpretation: "structural-request-source-call-sink-evidence-only"; +} + +export interface FindingRequestInputFlowEvidence { + method: string; + route: string; + frameworkHint?: string; + resolution: Exclude; + handler: string; + sourceKind: RequestInputKind; + sourceFunction: string; + sinkKind: SinkSignal["kind"]; + sinkFunction: string; + callDistance: number; + callScope: RouteRequestInputFlowContext["callScope"]; + interpretation: "structural-request-source-call-sink-evidence-only"; +} + +export interface RequestInputFlowOptions { + maxSignals?: number; + maxEvidence?: number; + maxRoutes?: number; + maxCallNodes?: number; + importCallLinks?: ImportCallLinkGraph; +} + +const MAX_SOURCE_BYTES = 512_000; +const MAX_FILES = 5_000; +const MAX_SIGNALS = 10_000; +const MAX_SIGNALS_PER_FILE = 500; +const jsExtensions = new Set([".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"]); + +function normalizedPath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function normalizedComparisonPath(value: string): string { + return normalizedPath(value).replace(/^\//, "").toLowerCase(); +} + +function insideRoot(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +async function readBoundedSource(root: string, file: IndexFileInput): Promise { + if (file.size > MAX_SOURCE_BYTES) return undefined; + const path = normalizedPath(file.path); + if (!path || path.includes("\0") || path.startsWith("../") || isAbsolute(file.path)) return undefined; + const absolute = resolve(root, path); + if (!insideRoot(root, absolute)) return undefined; + const info = await lstat(absolute).catch(() => undefined); + if (!info?.isFile() || info.isSymbolicLink() || info.size > MAX_SOURCE_BYTES) return undefined; + const content = await readFile(absolute, "utf8").catch(() => undefined); + if (content === undefined || content.includes("\u0000")) return undefined; + return content; +} + +function requestKind(member: string): RequestInputKind | undefined { + const normalized = member.toLowerCase(); + if (["body", "json", "form", "data", "values"].includes(normalized)) return "body"; + if (["query", "args", "get"].includes(normalized)) return "query"; + if (["params", "path_params"].includes(normalized)) return "path"; + if (["header", "headers"].includes(normalized)) return "header"; + if (["cookie", "cookies"].includes(normalized)) return "cookie"; + if (["file", "files"].includes(normalized)) return "file"; + return undefined; +} + +function nodeRequestAccesses(line: string): Array<{ kind: RequestInputKind; access: string }> { + const output: Array<{ kind: RequestInputKind; access: string }> = []; + const direct = /\b(?:req|request)\.(body|query|params|headers|cookies|files?)\b/gi; + for (let match = direct.exec(line); match; match = direct.exec(line)) { + const member = match[1]; + const kind = member ? requestKind(member) : undefined; + if (kind && member) output.push({ kind, access: `request.${member.toLowerCase()}` }); + } + + const koa = /\bctx\.(?:request\.)?(body|query|params|headers|cookies)\b/gi; + for (let match = koa.exec(line); match; match = koa.exec(line)) { + const member = match[1]; + const kind = member ? requestKind(member) : undefined; + if (kind && member) output.push({ kind, access: `ctx.request.${member.toLowerCase()}` }); + } + + const hono = /\b[A-Za-z_$][\w$]*\.req\.(json|query|param|header|cookie)\s*\(/gi; + for (let match = hono.exec(line); match; match = hono.exec(line)) { + const member = match[1]?.toLowerCase(); + const normalizedMember = member === "param" ? "params" : member; + const kind = normalizedMember ? requestKind(normalizedMember) : undefined; + if (kind && member) output.push({ kind, access: `context.req.${member}` }); + } + return output; +} + +function pythonRequestAccesses(line: string): Array<{ kind: RequestInputKind; access: string }> { + const output: Array<{ kind: RequestInputKind; access: string }> = []; + const flask = /\brequest\.(args|form|json|values|headers|cookies|files|data)\b/g; + for (let match = flask.exec(line); match; match = flask.exec(line)) { + const member = match[1]; + const kind = member ? requestKind(member) : undefined; + if (kind && member) output.push({ kind, access: `request.${member}` }); + } + if (/\brequest\.get_json\s*\(/.test(line)) output.push({ kind: "body", access: "request.get_json" }); + + const django = /\brequest\.(GET|POST|body|headers|COOKIES|FILES)\b/g; + for (let match = django.exec(line); match; match = django.exec(line)) { + const member = match[1]; + let kind: RequestInputKind | undefined; + if (member === "GET") kind = "query"; + else if (member === "POST" || member === "body") kind = "body"; + else kind = member ? requestKind(member) : undefined; + if (kind && member) output.push({ kind, access: `request.${member}` }); + } + return output; +} + +/** + * Collect explicit request-access expressions only. Function parameters, variable names, decorators, + * route declarations, and auth-looking tokens are not inferred as request-controlled input. + */ +export async function collectRequestInputSignals( + rootPath: string, + files: readonly IndexFileInput[], + options: Pick = {}, +): Promise { + const root = resolve(rootPath); + const maxSignals = Math.max(1, Math.min(MAX_SIGNALS, options.maxSignals ?? MAX_SIGNALS)); + const output: RequestInputSignal[] = []; + + for (const file of files.slice(0, MAX_FILES)) { + if (output.length >= maxSignals) break; + const extension = extname(file.path).toLowerCase(); + const family = jsExtensions.has(extension) ? "node-request" : extension === ".py" ? "python-request" : undefined; + if (!family) continue; + const content = await readBoundedSource(root, file); + if (!content) continue; + const lines = content.split(/\r?\n/); + let fileSignals = 0; + for (let index = 0; index < lines.length && fileSignals < MAX_SIGNALS_PER_FILE && output.length < maxSignals; index += 1) { + const accesses = family === "node-request" ? nodeRequestAccesses(lines[index] ?? "") : pythonRequestAccesses(lines[index] ?? ""); + for (const access of accesses) { + output.push({ + path: normalizedPath(file.path), + line: index + 1, + kind: access.kind, + frameworkFamily: family, + access: access.access, + }); + fileSignals += 1; + if (fileSignals >= MAX_SIGNALS_PER_FILE || output.length >= maxSignals) break; + } + } + } + + return output; +} + +function owningFunction(graph: CallGraph, path: string, line: number): CallGraphNode | undefined { + const normalized = normalizedComparisonPath(path); + const candidates = graph.nodes.filter( + (node) => normalizedComparisonPath(node.path) === normalized && line >= node.line && line <= node.endLine, + ); + return candidates.length === 1 ? candidates[0] : undefined; +} + +function adjacency( + graph: CallGraph, + importCallLinks: ImportCallLinkGraph | undefined, +): { targets: Map; importedEdges: Set } { + const targets = new Map>(); + const importedEdges = new Set(); + const add = (from: string, to: string): void => { + const bucket = targets.get(from) ?? new Set(); + bucket.add(to); + targets.set(from, bucket); + }; + for (const edge of graph.edges) if (edge.target) add(edge.from, edge.target); + for (const link of importCallLinks?.links ?? []) { + add(link.from, link.target); + importedEdges.add(`${link.from}\u0000${link.target}`); + } + return { + targets: new Map([...targets.entries()].map(([from, values]) => [from, [...values].sort()])), + importedEdges, + }; +} + +function sourceCallTargets( + graph: CallGraph, + importCallLinks: ImportCallLinkGraph | undefined, + sourceFunctionId: string, + sourceLine: number, +): { targets: string[]; importedTargets: Set } { + const targets = new Set(); + const importedTargets = new Set(); + for (const edge of graph.edges) { + if (edge.from === sourceFunctionId && edge.line === sourceLine && edge.target) targets.add(edge.target); + } + for (const link of importCallLinks?.links ?? []) { + if (link.from !== sourceFunctionId || link.line !== sourceLine) continue; + targets.add(link.target); + importedTargets.add(link.target); + } + return { targets: [...targets].sort(), importedTargets }; +} + +function reachableFrom( + start: string, + targets: ReadonlyMap, + maxDepth: number, + maxNodes: number, + allowed?: ReadonlySet, +): Map { + const depths = new Map([[start, 0]]); + const queue: Array<{ id: string; depth: number }> = [{ id: start, depth: 0 }]; + while (queue.length > 0 && depths.size < maxNodes) { + const current = queue.shift(); + if (!current || current.depth >= maxDepth) continue; + for (const target of targets.get(current.id) ?? []) { + if (allowed && !allowed.has(target)) continue; + if (depths.has(target)) continue; + const depth = current.depth + 1; + depths.set(target, depth); + queue.push({ id: target, depth }); + if (depths.size >= maxNodes) break; + } + } + return depths; +} + +function usedImportOnShortestPath( + start: string, + target: string, + targets: ReadonlyMap, + importedEdges: ReadonlySet, + maxDepth: number, + allowed: ReadonlySet, +): boolean { + if (start === target) return false; + const queue: Array<{ id: string; depth: number; usedImport: boolean }> = [{ id: start, depth: 0, usedImport: false }]; + const seen = new Set([start]); + while (queue.length > 0) { + const current = queue.shift(); + if (!current || current.depth >= maxDepth) continue; + for (const next of targets.get(current.id) ?? []) { + if (!allowed.has(next) || seen.has(next)) continue; + const usedImport = current.usedImport || importedEdges.has(`${current.id}\u0000${next}`); + if (next === target) return usedImport; + seen.add(next); + queue.push({ id: next, depth: current.depth + 1, usedImport }); + } + } + return false; +} + +function sourceToSinkPath( + source: { signal: RequestInputSignal; node: CallGraphNode }, + sink: { signal: SinkSignal; node: CallGraphNode }, + graph: CallGraph, + importCallLinks: ImportCallLinkGraph | undefined, + graphEdges: ReturnType, + routeNodes: ReadonlySet, + maxDepth: number, + maxNodes: number, +): { callDistance: number; usedImport: boolean } | undefined { + if (source.node.id === sink.node.id) { + return source.signal.line === sink.signal.line ? { callDistance: 0, usedImport: false } : undefined; + } + if (maxDepth < 1) return undefined; + + const firstHops = sourceCallTargets(graph, importCallLinks, source.node.id, source.signal.line); + let best: { callDistance: number; usedImport: boolean } | undefined; + for (const first of firstHops.targets) { + if (!routeNodes.has(first)) continue; + let distance: number; + let imported = firstHops.importedTargets.has(first); + if (first === sink.node.id) { + distance = 1; + } else { + const downstream = reachableFrom(first, graphEdges.targets, maxDepth - 1, maxNodes, routeNodes); + const remainder = downstream.get(sink.node.id); + if (remainder === undefined) continue; + distance = 1 + remainder; + imported = imported || usedImportOnShortestPath( + first, + sink.node.id, + graphEdges.targets, + graphEdges.importedEdges, + maxDepth - 1, + routeNodes, + ); + } + if (!best || distance < best.callDistance) best = { callDistance: distance, usedImport: imported }; + } + return best; +} + +/** + * Build bounded directional request-source -> call graph -> sink evidence for one resolved route. + * A source/sink must each belong to exactly one lexical function. Cross-function propagation starts + * only when the explicit request access occurs on the same line as a resolved outbound call; this + * intentionally refuses to infer local variable taint. Subsequent hops may cross only resolved same- + * file calls or explicit unique repository-local import bindings inside the route call neighborhood. + */ +export function routeRequestInputFlowContext( + index: RepositoryIndex, + requestInputs: readonly RequestInputSignal[], + entrypoint: RouteEntrypoint, + graph: CallGraph, + options: RequestInputFlowOptions = {}, +): RouteRequestInputFlowContext | undefined { + if (!entrypoint.handler || entrypoint.resolution === "unresolved") return undefined; + const maxEvidence = Math.max(1, Math.min(50, options.maxEvidence ?? 12)); + const maxNodes = Math.max(1, Math.min(1_000, options.maxCallNodes ?? 100)); + const maxDepth = Math.max(0, Math.min(12, entrypoint.calls?.maxDepth ?? 0)); + const graphEdges = adjacency(graph, options.importCallLinks); + const routeDepths = reachableFrom(entrypoint.handler.id, graphEdges.targets, maxDepth, maxNodes); + const routeNodes = new Set(routeDepths.keys()); + if (routeNodes.size === 0) return undefined; + + const sources = requestInputs.flatMap((signal) => { + const node = owningFunction(graph, signal.path, signal.line); + const routeDepth = node ? routeDepths.get(node.id) : undefined; + return node && routeDepth !== undefined ? [{ signal, node, routeDepth }] : []; + }); + if (sources.length === 0) return undefined; + + const sinks = index.sinks.flatMap((signal) => { + const node = owningFunction(graph, signal.path, signal.line); + const routeDepth = node ? routeDepths.get(node.id) : undefined; + return node && routeDepth !== undefined ? [{ signal, node, routeDepth }] : []; + }); + if (sinks.length === 0) return undefined; + + const evidence: RequestInputFlowEvidence[] = []; + let usedImport = false; + for (const source of sources) { + for (const sink of sinks) { + const path = sourceToSinkPath( + source, + sink, + graph, + options.importCallLinks, + graphEdges, + routeNodes, + maxDepth, + maxNodes, + ); + if (!path) continue; + usedImport = usedImport || path.usedImport; + evidence.push({ + source: { + path: source.signal.path, + line: source.signal.line, + kind: source.signal.kind, + frameworkFamily: source.signal.frameworkFamily, + access: source.signal.access, + functionId: source.node.id, + functionName: source.node.name, + routeDepth: source.routeDepth, + }, + sink: { + path: sink.signal.path, + line: sink.signal.line, + kind: sink.signal.kind, + functionId: sink.node.id, + functionName: sink.node.name, + routeDepth: sink.routeDepth, + }, + callDistance: path.callDistance, + }); + if (evidence.length >= maxEvidence) break; + } + if (evidence.length >= maxEvidence) break; + } + if (evidence.length === 0) return undefined; + + evidence.sort((a, b) => + a.callDistance - b.callDistance || + a.source.path.localeCompare(b.source.path) || a.source.line - b.source.line || + a.sink.path.localeCompare(b.sink.path) || a.sink.line - b.sink.line, + ); + return { + route: entrypoint.route, + resolution: entrypoint.resolution, + handler: { + id: entrypoint.handler.id, + name: entrypoint.handler.name, + path: entrypoint.handler.path, + line: entrypoint.handler.line, + endLine: entrypoint.handler.endLine, + }, + evidence: evidence.slice(0, maxEvidence), + sourceKinds: [...new Set(evidence.map((item) => item.source.kind))], + sinkKinds: [...new Set(evidence.map((item) => item.sink.kind))], + callScope: usedImport ? "same-file-and-explicit-imports" : "same-file", + interpretation: "structural-request-source-call-sink-evidence-only", + }; +} + +export function repositoryRouteRequestInputFlowContexts( + index: RepositoryIndex, + requestInputs: readonly RequestInputSignal[], + entrypoints: readonly RouteEntrypoint[], + graph: CallGraph, + options: RequestInputFlowOptions = {}, +): RouteRequestInputFlowContext[] { + const maxRoutes = Math.max(0, Math.min(5_000, options.maxRoutes ?? 1_000)); + const output: RouteRequestInputFlowContext[] = []; + for (const entrypoint of entrypoints.slice(0, maxRoutes)) { + const context = routeRequestInputFlowContext(index, requestInputs, entrypoint, graph, options); + if (context) output.push(context); + } + return output; +} + +/** Correlate only to an exact sink line already linked to an explicit request source by a directed call path. */ +export function findingRequestInputFlowEvidence( + contexts: readonly RouteRequestInputFlowContext[], + path: string, + line: number | undefined, + maxRoutes = 3, +): FindingRequestInputFlowEvidence[] { + if (!Number.isSafeInteger(line) || (line ?? 0) <= 0) return []; + const normalized = normalizedComparisonPath(path); + const limit = Math.max(1, Math.min(10, maxRoutes)); + const output: FindingRequestInputFlowEvidence[] = []; + for (const context of contexts) { + const match = context.evidence.find( + (item) => normalizedComparisonPath(item.sink.path) === normalized && item.sink.line === line, + ); + if (!match) continue; + output.push({ + method: context.route.method, + route: context.route.route, + ...(context.route.frameworkHint ? { frameworkHint: context.route.frameworkHint } : {}), + resolution: context.resolution, + handler: context.handler.name, + sourceKind: match.source.kind, + sourceFunction: match.source.functionName, + sinkKind: match.sink.kind, + sinkFunction: match.sink.functionName, + callDistance: match.callDistance, + callScope: context.callScope, + interpretation: "structural-request-source-call-sink-evidence-only", + }); + if (output.length >= limit) break; + } + return output; +} diff --git a/packages/repository/src/request-input-forwarding.ts b/packages/repository/src/request-input-forwarding.ts new file mode 100644 index 00000000..4ad7cd04 --- /dev/null +++ b/packages/repository/src/request-input-forwarding.ts @@ -0,0 +1,398 @@ +import { lstat, readFile } from "node:fs/promises"; +import { extname, isAbsolute, relative, resolve, sep } from "node:path"; +import type { IndexFileInput, SinkSignal } from "./analysis.js"; +import type { CallGraph, CallGraphNode } from "./call-graph.js"; +import type { ImportCallLinkGraph } from "./import-call-links.js"; +import type { RequestInputKind, RequestInputSignal } from "./request-input-flow.js"; +import type { RouteEntrypointResolution } from "./route-entrypoints.js"; +import type { RouteSinkFlowContext } from "./route-sink-flow.js"; + +export interface RequestInputForwardingEvidence { + source: { + path: string; + line: number; + kind: RequestInputKind; + frameworkFamily: RequestInputSignal["frameworkFamily"]; + access: string; + functionId: string; + functionName: string; + }; + forwarding: { + declarationLine: number; + callLine: number; + kind: "immutable-local-binding-direct-call-argument"; + }; + sink: { + path: string; + line: number; + kind: SinkSignal["kind"]; + functionId: string; + functionName: string; + }; + callDistance: number; +} + +export interface RouteRequestInputForwardingContext { + route: RouteSinkFlowContext["route"]; + resolution: Exclude; + handler: RouteSinkFlowContext["handler"]; + evidence: RequestInputForwardingEvidence[]; + sourceKinds: RequestInputKind[]; + sinkKinds: SinkSignal["kind"][]; + callScope: "same-file" | "same-file-and-explicit-imports"; + interpretation: "structural-request-source-immutable-binding-call-sink-evidence-only"; +} + +export interface FindingRequestInputForwardingEvidence { + method: string; + route: string; + frameworkHint?: string; + resolution: Exclude; + handler: string; + sourceKind: RequestInputKind; + sourceFunction: string; + sinkKind: SinkSignal["kind"]; + sinkFunction: string; + callDistance: number; + callScope: RouteRequestInputForwardingContext["callScope"]; + interpretation: "structural-request-source-immutable-binding-call-sink-evidence-only"; +} + +export interface RequestInputForwardingOptions { + maxEvidence?: number; + maxRoutes?: number; + maxCallNodes?: number; + maxForwardLines?: number; +} + +const MAX_SOURCE_BYTES = 512_000; +const MAX_FILES = 5_000; +const MAX_FORWARD_LINES = 40; +const jsExtensions = new Set([".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"]); + +function normalizedPath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function comparisonPath(value: string): string { + return normalizedPath(value).replace(/^\//, "").toLowerCase(); +} + +function insideRoot(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +async function readBoundedSource(root: string, file: IndexFileInput): Promise { + if (file.size > MAX_SOURCE_BYTES || !jsExtensions.has(extname(file.path).toLowerCase())) return undefined; + const path = normalizedPath(file.path); + if (!path || path.includes("\0") || path.startsWith("../") || isAbsolute(file.path)) return undefined; + const absolute = resolve(root, path); + if (!insideRoot(root, absolute)) return undefined; + const info = await lstat(absolute).catch(() => undefined); + if (!info?.isFile() || info.isSymbolicLink() || info.size > MAX_SOURCE_BYTES) return undefined; + const content = await readFile(absolute, "utf8").catch(() => undefined); + if (content === undefined || content.includes("\u0000")) return undefined; + return content.split(/\r?\n/); +} + +function owningFunction(graph: CallGraph, path: string, line: number): CallGraphNode | undefined { + const normalized = comparisonPath(path); + const matches = graph.nodes.filter( + (node) => comparisonPath(node.path) === normalized && line >= node.line && line <= node.endLine, + ); + return matches.length === 1 ? matches[0] : undefined; +} + +function graphAdjacency( + graph: CallGraph, + importCallLinks: ImportCallLinkGraph, +): { targets: Map; importedEdges: Set } { + const targets = new Map>(); + const importedEdges = new Set(); + const add = (from: string, target: string): void => { + const bucket = targets.get(from) ?? new Set(); + bucket.add(target); + targets.set(from, bucket); + }; + for (const edge of graph.edges) if (edge.target) add(edge.from, edge.target); + for (const link of importCallLinks.links) { + add(link.from, link.target); + importedEdges.add(`${link.from}\u0000${link.target}`); + } + return { + targets: new Map([...targets].map(([from, values]) => [from, [...values].sort()])), + importedEdges, + }; +} + +function reachableDepths( + start: string, + targets: ReadonlyMap, + maxDepth: number, + maxNodes: number, +): Map { + const depths = new Map([[start, 0]]); + const queue: Array<{ id: string; depth: number }> = [{ id: start, depth: 0 }]; + while (queue.length > 0 && depths.size < maxNodes) { + const current = queue.shift(); + if (!current || current.depth >= maxDepth) continue; + for (const next of targets.get(current.id) ?? []) { + if (depths.has(next)) continue; + depths.set(next, current.depth + 1); + queue.push({ id: next, depth: current.depth + 1 }); + if (depths.size >= maxNodes) break; + } + } + return depths; +} + +function shortestPath( + start: string, + target: string, + adjacency: ReturnType, + allowed: ReadonlySet, + maxDepth: number, + maxNodes: number, +): { distance: number; usedImport: boolean } | undefined { + if (start === target) return { distance: 0, usedImport: false }; + const queue: Array<{ id: string; depth: number; usedImport: boolean }> = [{ id: start, depth: 0, usedImport: false }]; + const seen = new Set([start]); + let examined = 0; + while (queue.length > 0 && examined < maxNodes) { + const current = queue.shift(); + if (!current || current.depth >= maxDepth) continue; + examined += 1; + for (const next of adjacency.targets.get(current.id) ?? []) { + if (!allowed.has(next) || seen.has(next)) continue; + const usedImport = current.usedImport || adjacency.importedEdges.has(`${current.id}\u0000${next}`); + if (next === target) return { distance: current.depth + 1, usedImport }; + seen.add(next); + queue.push({ id: next, depth: current.depth + 1, usedImport }); + } + } + return undefined; +} + +function simpleRequestBinding(line: string): string | undefined { + const match = /^\s*const\s+([A-Za-z_$][\w$]*)\s*=\s*((?:req|request)\.(?:body|query|params|headers|cookies|files?)(?:\.[A-Za-z_$][\w$]*|\[["'][^"'\r\n]{1,64}["']\])*)\s*;?\s*$/.exec(line); + return match?.[1]; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function identifierPattern(identifier: string): RegExp { + return new RegExp(`(^|[^A-Za-z0-9_$])${escapeRegExp(identifier)}([^A-Za-z0-9_$]|$)`); +} + +function directCallCallees(line: string, identifier: string): string[] { + const output = new Set(); + const calls = /\b([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\(([^()]*)\)/g; + for (let match = calls.exec(line); match; match = calls.exec(line)) { + const callee = match[1]; + const args = match[2]; + if (!callee || args === undefined) continue; + const values = args.split(",").map((value) => value.trim()); + if (values.includes(identifier)) output.add(callee); + } + return [...output].sort(); +} + +function resolvedTargetAtLine( + graph: CallGraph, + importCallLinks: ImportCallLinkGraph, + owner: CallGraphNode, + line: number, + callees: readonly string[], +): { target: string; usedImport: boolean } | undefined { + const candidates = new Map(); + for (const edge of graph.edges) { + if (edge.from !== owner.id || edge.line !== line || !edge.target || !callees.includes(edge.callee)) continue; + candidates.set(edge.target, candidates.get(edge.target) ?? false); + } + for (const link of importCallLinks.links) { + if (link.from !== owner.id || link.line !== line || !callees.includes(link.callee)) continue; + candidates.set(link.target, true); + } + if (candidates.size !== 1) return undefined; + const first = candidates.entries().next().value as [string, boolean] | undefined; + return first ? { target: first[0], usedImport: first[1] } : undefined; +} + +function forwardingTarget( + lines: readonly string[], + signal: RequestInputSignal, + owner: CallGraphNode, + graph: CallGraph, + importCallLinks: ImportCallLinkGraph, + maxForwardLines: number, +): { callLine: number; target: string; usedImport: boolean } | undefined { + const declaration = lines[signal.line - 1] ?? ""; + const binding = simpleRequestBinding(declaration); + if (!binding) return undefined; + const identifier = identifierPattern(binding); + const end = Math.min(owner.endLine, signal.line + maxForwardLines); + let candidate: { callLine: number; target: string; usedImport: boolean } | undefined; + + for (let lineNumber = signal.line + 1; lineNumber <= owner.endLine; lineNumber += 1) { + const line = lines[lineNumber - 1] ?? ""; + if (!identifier.test(line)) continue; + if (lineNumber > end) return undefined; + if (/^\s*(?:\/\/|\/\*|\*)/.test(line)) continue; + if (candidate) return undefined; + if (new RegExp(`\\b${escapeRegExp(binding)}\\s*(?:=|\\+=|-=|\\*=|\/=|%=|\\+\\+|--)`).test(line)) return undefined; + const callees = directCallCallees(line, binding); + if (callees.length === 0) return undefined; + const resolved = resolvedTargetAtLine(graph, importCallLinks, owner, lineNumber, callees); + if (!resolved) return undefined; + candidate = { callLine: lineNumber, target: resolved.target, usedImport: resolved.usedImport }; + } + return candidate; +} + +/** + * Add one deliberately narrow level of local data-flow evidence without claiming general taint. + * Only JS/TS `const x = req....` declarations are eligible. The immutable binding + * must have exactly one later use in the same lexical function, within a bounded line distance, and + * that use must be an unchanged direct argument to exactly one resolved local/imported call. Any + * mutation, validation/sanitization step, transformation, aliasing, multiple use, unresolved call, + * destructuring, nested expression, Python assignment, or ambiguity is omitted. + */ +export async function repositoryRouteRequestInputForwardingContexts( + rootPath: string, + files: readonly IndexFileInput[], + requestInputs: readonly RequestInputSignal[], + routeFlows: readonly RouteSinkFlowContext[], + graph: CallGraph, + importCallLinks: ImportCallLinkGraph, + options: RequestInputForwardingOptions = {}, +): Promise { + const root = resolve(rootPath); + const maxRoutes = Math.max(0, Math.min(5_000, options.maxRoutes ?? 1_000)); + const maxEvidence = Math.max(1, Math.min(50, options.maxEvidence ?? 12)); + const maxNodes = Math.max(1, Math.min(1_000, options.maxCallNodes ?? 100)); + const maxForwardLines = Math.max(1, Math.min(MAX_FORWARD_LINES, options.maxForwardLines ?? 12)); + const fileMap = new Map(files.slice(0, MAX_FILES).map((file) => [comparisonPath(file.path), file])); + const sourceCache = new Map(); + const adjacency = graphAdjacency(graph, importCallLinks); + const output: RouteRequestInputForwardingContext[] = []; + + for (const routeFlow of routeFlows.slice(0, maxRoutes)) { + const maxDepth = Math.max(0, Math.min(12, routeFlow.maxDepth)); + const routeDepths = reachableDepths(routeFlow.handler.id, adjacency.targets, maxDepth, maxNodes); + const routeNodes = new Set(routeDepths.keys()); + const evidence: RequestInputForwardingEvidence[] = []; + let usedImport = false; + + for (const signal of requestInputs) { + if (signal.frameworkFamily !== "node-request") continue; + const owner = owningFunction(graph, signal.path, signal.line); + if (!owner || !routeNodes.has(owner.id)) continue; + const fileKey = comparisonPath(signal.path); + const file = fileMap.get(fileKey); + if (!file) continue; + let lines = sourceCache.get(fileKey); + if (!sourceCache.has(fileKey)) { + lines = await readBoundedSource(root, file); + sourceCache.set(fileKey, lines); + } + if (!lines) continue; + const forwarding = forwardingTarget(lines, signal, owner, graph, importCallLinks, maxForwardLines); + if (!forwarding || !routeNodes.has(forwarding.target)) continue; + + for (const sink of routeFlow.evidence) { + const path = shortestPath( + forwarding.target, + sink.functionId, + adjacency, + routeNodes, + Math.max(0, maxDepth - 1), + maxNodes, + ); + if (!path) continue; + usedImport = usedImport || forwarding.usedImport || path.usedImport; + evidence.push({ + source: { + path: signal.path, + line: signal.line, + kind: signal.kind, + frameworkFamily: signal.frameworkFamily, + access: signal.access, + functionId: owner.id, + functionName: owner.name, + }, + forwarding: { + declarationLine: signal.line, + callLine: forwarding.callLine, + kind: "immutable-local-binding-direct-call-argument", + }, + sink: { + path: sink.path, + line: sink.line, + kind: sink.kind, + functionId: sink.functionId, + functionName: sink.functionName, + }, + callDistance: 1 + path.distance, + }); + if (evidence.length >= maxEvidence) break; + } + if (evidence.length >= maxEvidence) break; + } + + if (evidence.length === 0) continue; + evidence.sort((a, b) => + a.callDistance - b.callDistance || + a.source.path.localeCompare(b.source.path) || a.source.line - b.source.line || + a.sink.path.localeCompare(b.sink.path) || a.sink.line - b.sink.line, + ); + output.push({ + route: routeFlow.route, + resolution: routeFlow.resolution, + handler: routeFlow.handler, + evidence, + sourceKinds: [...new Set(evidence.map((item) => item.source.kind))], + sinkKinds: [...new Set(evidence.map((item) => item.sink.kind))], + callScope: usedImport ? "same-file-and-explicit-imports" : "same-file", + interpretation: "structural-request-source-immutable-binding-call-sink-evidence-only", + }); + } + return output; +} + +/** Correlate only to the exact sink line already linked through the bounded immutable forwarding rule. */ +export function findingRequestInputForwardingEvidence( + contexts: readonly RouteRequestInputForwardingContext[], + path: string, + line: number | undefined, + maxRoutes = 3, +): FindingRequestInputForwardingEvidence[] { + if (!Number.isSafeInteger(line) || (line ?? 0) <= 0) return []; + const normalized = comparisonPath(path); + const limit = Math.max(1, Math.min(10, maxRoutes)); + const output: FindingRequestInputForwardingEvidence[] = []; + for (const context of contexts) { + const match = context.evidence.find( + (item) => comparisonPath(item.sink.path) === normalized && item.sink.line === line, + ); + if (!match) continue; + output.push({ + method: context.route.method, + route: context.route.route, + ...(context.route.frameworkHint ? { frameworkHint: context.route.frameworkHint } : {}), + resolution: context.resolution, + handler: context.handler.name, + sourceKind: match.source.kind, + sourceFunction: match.source.functionName, + sinkKind: match.sink.kind, + sinkFunction: match.sink.functionName, + callDistance: match.callDistance, + callScope: context.callScope, + interpretation: "structural-request-source-immutable-binding-call-sink-evidence-only", + }); + if (output.length >= limit) break; + } + return output; +} diff --git a/packages/repository/src/request-input-return-alias-flow.ts b/packages/repository/src/request-input-return-alias-flow.ts new file mode 100644 index 00000000..87573339 --- /dev/null +++ b/packages/repository/src/request-input-return-alias-flow.ts @@ -0,0 +1,484 @@ +import { lstat, readFile } from "node:fs/promises"; +import { extname, isAbsolute, relative, resolve, sep } from "node:path"; +import type { IndexFileInput, RouteSignal, SinkSignal } from "./analysis.js"; +import type { CallGraph, CallGraphNode } from "./call-graph.js"; +import type { ImportCallLinkGraph } from "./import-call-links.js"; +import type { RequestInputSignal } from "./request-input-flow.js"; +import type { RouteSinkFlowContext } from "./route-sink-flow.js"; + +const MAX_SOURCE_BYTES = 512_000; +const MAX_FILES = 5_000; +const DEFAULT_MAX_FORWARD_LINES = 12; +const MAX_FORWARD_LINES = 40; +const MAX_EVIDENCE = 50; +const jsExtensions = new Set([".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"]); + +export interface RequestInputReturnAliasFlowEvidence { + source: { + path: string; + line: number; + kind: RequestInputSignal["kind"]; + access: string; + functionId: string; + functionName: string; + }; + bridge: { + callerFunctionId: string; + callerFunctionName: string; + callerPath: string; + helperCallLine: number; + aliasLine: number; + forwardingCallLine: number; + routeDepth: number; + bindingHops: 2; + }; + sink: { + path: string; + line: number; + kind: SinkSignal["kind"]; + functionId: string; + functionName: string; + }; + callDistance: number; + callScope: "same-file" | "same-file-and-explicit-imports"; +} + +export interface RouteRequestInputReturnAliasFlowContext { + route: RouteSignal; + resolution: RouteSinkFlowContext["resolution"]; + handler: RouteSinkFlowContext["handler"]; + evidence: RequestInputReturnAliasFlowEvidence[]; + sourceKinds: RequestInputSignal["kind"][]; + sinkKinds: SinkSignal["kind"][]; + interpretation: "structural-request-source-return-two-immutable-bindings-call-sink-evidence-only"; +} + +export interface FindingRequestInputReturnAliasFlowEvidence { + method: string; + route: string; + frameworkHint?: string; + resolution: RouteSinkFlowContext["resolution"]; + handler: string; + sourceKind: RequestInputSignal["kind"]; + sourceFunction: string; + sinkKind: SinkSignal["kind"]; + sinkFunction: string; + callDistance: number; + callScope: RequestInputReturnAliasFlowEvidence["callScope"]; + bindingHops: 2; + interpretation: "structural-request-source-return-two-immutable-bindings-call-sink-evidence-only"; +} + +export interface RequestInputReturnAliasFlowOptions { + maxForwardLines?: number; + maxEvidence?: number; + maxRoutes?: number; + maxCallNodes?: number; +} + +interface GraphEdges { + targets: Map; + imported: Set; +} + +interface AliasBridge { + caller: CallGraphNode; + helper: CallGraphNode; + helperCallLine: number; + aliasLine: number; + forwardingCallLine: number; + forwardingTarget: string; + helperCallImported: boolean; + forwardingCallImported: boolean; +} + +function normalizedPath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function comparisonPath(value: string): string { + return normalizedPath(value).replace(/^\//, "").toLowerCase(); +} + +function insideRoot(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function boundedForwardLines(value: number | undefined): number { + const resolved = value ?? DEFAULT_MAX_FORWARD_LINES; + if (!Number.isSafeInteger(resolved) || resolved < 2 || resolved > MAX_FORWARD_LINES) { + throw new Error(`Request-input return-alias-flow maxForwardLines must be an integer between 2 and ${MAX_FORWARD_LINES}.`); + } + return resolved; +} + +async function readBoundedSource(root: string, file: IndexFileInput): Promise { + if (file.size > MAX_SOURCE_BYTES) return undefined; + const path = normalizedPath(file.path); + if (!path || path.includes("\0") || path.startsWith("../") || isAbsolute(file.path)) return undefined; + const absolute = resolve(root, path); + if (!insideRoot(root, absolute)) return undefined; + const info = await lstat(absolute).catch(() => undefined); + if (!info?.isFile() || info.isSymbolicLink() || info.size > MAX_SOURCE_BYTES) return undefined; + const content = await readFile(absolute, "utf8").catch(() => undefined); + return content === undefined || content.includes("\u0000") ? undefined : content; +} + +function owningFunction(graph: CallGraph, path: string, line: number): CallGraphNode | undefined { + const normalized = comparisonPath(path); + const matches = graph.nodes.filter( + (node) => comparisonPath(node.path) === normalized && line >= node.line && line <= node.endLine, + ); + return matches.length === 1 ? matches[0] : undefined; +} + +function graphEdges(graph: CallGraph, imports: ImportCallLinkGraph): GraphEdges { + const buckets = new Map>(); + const imported = new Set(); + const add = (from: string, target: string): void => { + const bucket = buckets.get(from) ?? new Set(); + bucket.add(target); + buckets.set(from, bucket); + }; + for (const edge of graph.edges) if (edge.target) add(edge.from, edge.target); + for (const link of imports.links) { + add(link.from, link.target); + imported.add(`${link.from}\u0000${link.target}`); + } + return { + targets: new Map([...buckets.entries()].map(([key, value]) => [key, [...value].sort()])), + imported, + }; +} + +function shortestPath( + start: string, + target: string, + edges: GraphEdges, + maxDepth: number, + maxNodes: number, +): { distance: number; usedImport: boolean } | undefined { + if (start === target) return { distance: 0, usedImport: false }; + const queue: Array<{ id: string; depth: number; usedImport: boolean }> = [{ id: start, depth: 0, usedImport: false }]; + const bestDepth = new Map([[start, 0]]); + let examined = 0; + while (queue.length > 0 && examined < maxNodes) { + const current = queue.shift(); + if (!current || current.depth >= maxDepth) continue; + examined += 1; + for (const next of edges.targets.get(current.id) ?? []) { + const depth = current.depth + 1; + const usedImport = current.usedImport || edges.imported.has(`${current.id}\u0000${next}`); + if (next === target) return { distance: depth, usedImport }; + const prior = bestDepth.get(next); + if (prior !== undefined && prior <= depth) continue; + bestDepth.set(next, depth); + queue.push({ id: next, depth, usedImport }); + } + } + return undefined; +} + +function helperHasExactSingleReturn(source: string, node: CallGraphNode, signal: RequestInputSignal): boolean { + const lines = source.split(/\r?\n/); + const body = lines.slice(node.line - 1, node.endLine); + const returnLines: number[] = []; + for (let index = 0; index < body.length; index += 1) { + if (/^\s*return\b/.test(body[index] ?? "")) returnLines.push(node.line + index); + } + if (returnLines.length !== 1 || returnLines[0] !== signal.line || signal.frameworkFamily !== "node-request") return false; + const line = lines[signal.line - 1] ?? ""; + return /^\s*return\s+(?:req|request)\.(?:body|query|params|headers|cookies|files?)(?:\.[A-Za-z_$][\w$]*|\[["'][^"'\r\n]{1,64}["']\])*\s*;?\s*$/.test(line); +} + +function resolvedCallsTo( + graph: CallGraph, + imports: ImportCallLinkGraph, + helperId: string, +): Array<{ from: string; line: number; callee: string; imported: boolean }> { + const output: Array<{ from: string; line: number; callee: string; imported: boolean }> = []; + for (const edge of graph.edges) { + if (edge.target === helperId) output.push({ from: edge.from, line: edge.line, callee: edge.callee, imported: false }); + } + for (const link of imports.links) { + if (link.target === helperId) output.push({ from: link.from, line: link.line, callee: link.callee, imported: true }); + } + return output.sort((a, b) => a.from.localeCompare(b.from) || a.line - b.line || a.callee.localeCompare(b.callee)); +} + +function uniqueResolvedTargetAtLine( + graph: CallGraph, + imports: ImportCallLinkGraph, + callerId: string, + line: number, + callee: string, +): { target: string; imported: boolean } | undefined { + const matches: Array<{ target: string; imported: boolean }> = []; + for (const edge of graph.edges) { + if (edge.from === callerId && edge.line === line && edge.callee === callee && edge.target) { + matches.push({ target: edge.target, imported: false }); + } + } + for (const link of imports.links) { + if (link.from === callerId && link.line === line && link.callee === callee) { + matches.push({ target: link.target, imported: true }); + } + } + const uniqueTargets = [...new Set(matches.map((match) => match.target))]; + if (uniqueTargets.length !== 1) return undefined; + const target = uniqueTargets[0]; + return target ? { target, imported: matches.some((match) => match.target === target && match.imported) } : undefined; +} + +function parseHelperBinding(line: string, callee: string): string | undefined { + const escaped = escapeRegExp(callee); + return line.match(new RegExp(`^\\s*const\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*${escaped}\\(\\s*[A-Za-z_$][\\w$]*\\s*\\)\\s*;?\\s*$`))?.[1]; +} + +function identifierOccurrences(text: string, identifier: string): number { + const pattern = new RegExp(`\\b${escapeRegExp(identifier)}\\b`, "g"); + let count = 0; + for (let match = pattern.exec(text); match; match = pattern.exec(text)) count += 1; + return count; +} + +function findExactAliasForwardUse( + lines: readonly string[], + caller: CallGraphNode, + helperCallLine: number, + binding: string, + maxForwardLines: number, +): { aliasLine: number; forwardingLine: number; callee: string } | undefined { + const bindingUses: Array<{ line: number; text: string }> = []; + for (let line = helperCallLine + 1; line <= caller.endLine; line += 1) { + const text = lines[line - 1] ?? ""; + const count = identifierOccurrences(text, binding); + for (let index = 0; index < count; index += 1) bindingUses.push({ line, text }); + if (bindingUses.length > 1) return undefined; + } + if (bindingUses.length !== 1) return undefined; + const aliasUse = bindingUses[0]; + if (!aliasUse || aliasUse.line - helperCallLine >= maxForwardLines) return undefined; + const alias = aliasUse.text.match(new RegExp(`^\\s*const\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*${escapeRegExp(binding)}\\s*;?\\s*$`))?.[1]; + if (!alias || alias === binding) return undefined; + + const aliasUses: Array<{ line: number; text: string }> = []; + for (let line = aliasUse.line + 1; line <= caller.endLine; line += 1) { + const text = lines[line - 1] ?? ""; + const count = identifierOccurrences(text, alias); + for (let index = 0; index < count; index += 1) aliasUses.push({ line, text }); + if (aliasUses.length > 1) return undefined; + } + if (aliasUses.length !== 1) return undefined; + const forward = aliasUses[0]; + if (!forward || forward.line - helperCallLine > maxForwardLines) return undefined; + const call = forward.text.match(new RegExp(`^\\s*([A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$][\\w$]*)?)\\s*\\(\\s*${escapeRegExp(alias)}\\s*\\)\\s*;?\\s*$`)); + return call?.[1] + ? { aliasLine: aliasUse.line, forwardingLine: forward.line, callee: call[1] } + : undefined; +} + +async function aliasBridgeCandidates( + root: string, + files: readonly IndexFileInput[], + signals: readonly RequestInputSignal[], + graph: CallGraph, + imports: ImportCallLinkGraph, + maxForwardLines: number, +): Promise { + const fileByPath = new Map(files.slice(0, MAX_FILES).map((file) => [comparisonPath(file.path), file])); + const sourceCache = new Map(); + const nodeById = new Map(graph.nodes.map((node) => [node.id, node])); + const getSource = async (path: string): Promise => { + const key = comparisonPath(path); + if (sourceCache.has(key)) return sourceCache.get(key); + const file = fileByPath.get(key); + const source = file && jsExtensions.has(extname(file.path).toLowerCase()) ? await readBoundedSource(root, file) : undefined; + sourceCache.set(key, source); + return source; + }; + const output: AliasBridge[] = []; + + for (const signal of signals) { + if (signal.frameworkFamily !== "node-request") continue; + const helper = owningFunction(graph, signal.path, signal.line); + if (!helper || helper.kind === "python-function") continue; + const helperSource = await getSource(helper.path); + if (!helperSource || !helperHasExactSingleReturn(helperSource, helper, signal)) continue; + + for (const inbound of resolvedCallsTo(graph, imports, helper.id)) { + const caller = nodeById.get(inbound.from); + if (!caller || caller.kind === "python-function") continue; + const callerSource = await getSource(caller.path); + if (!callerSource) continue; + const lines = callerSource.split(/\r?\n/); + const binding = parseHelperBinding(lines[inbound.line - 1] ?? "", inbound.callee); + if (!binding) continue; + const forward = findExactAliasForwardUse(lines, caller, inbound.line, binding, maxForwardLines); + if (!forward) continue; + const target = uniqueResolvedTargetAtLine(graph, imports, caller.id, forward.forwardingLine, forward.callee); + if (!target || target.target === helper.id) continue; + output.push({ + caller, + helper, + helperCallLine: inbound.line, + aliasLine: forward.aliasLine, + forwardingCallLine: forward.forwardingLine, + forwardingTarget: target.target, + helperCallImported: inbound.imported, + forwardingCallImported: target.imported, + }); + } + } + return output; +} + +/** + * Build one deliberately narrow additional return-value flow shape: + * `const value = helper(req); const alias = value; sink(alias)`. + * + * Both bindings must be `const`; the first binding and alias must each occur exactly once after + * declaration; the alias must be passed unchanged as the sole argument to one uniquely resolved + * call; and the helper must contain exactly one direct `return req.` statement. A second + * alias hop, transformation, mutation, multiple use, branching return, unresolved call, Python + * function, or ambiguous ownership is omitted rather than inferred. + */ +export async function repositoryRouteRequestInputReturnAliasFlowContexts( + rootPath: string, + files: readonly IndexFileInput[], + signals: readonly RequestInputSignal[], + routeFlows: readonly RouteSinkFlowContext[], + graph: CallGraph, + imports: ImportCallLinkGraph, + options: RequestInputReturnAliasFlowOptions = {}, +): Promise { + const root = resolve(rootPath); + const maxForwardLines = boundedForwardLines(options.maxForwardLines); + const maxEvidence = Math.max(1, Math.min(MAX_EVIDENCE, options.maxEvidence ?? 12)); + const maxRoutes = Math.max(0, Math.min(5_000, options.maxRoutes ?? 1_000)); + const maxCallNodes = Math.max(1, Math.min(1_000, options.maxCallNodes ?? 100)); + const bridges = await aliasBridgeCandidates(root, files, signals, graph, imports, maxForwardLines); + const edges = graphEdges(graph, imports); + const sourceByFunction = new Map(); + for (const signal of signals) { + const node = owningFunction(graph, signal.path, signal.line); + if (!node) continue; + const bucket = sourceByFunction.get(node.id) ?? []; + bucket.push(signal); + sourceByFunction.set(node.id, bucket); + } + const output: RouteRequestInputReturnAliasFlowContext[] = []; + + for (const routeFlow of routeFlows.slice(0, maxRoutes)) { + const evidence: RequestInputReturnAliasFlowEvidence[] = []; + const routeMaxDepth = Math.max(0, routeFlow.maxDepth); + for (const bridge of bridges) { + if (evidence.length >= maxEvidence) break; + const routeToCaller = shortestPath(routeFlow.handler.id, bridge.caller.id, edges, routeMaxDepth, maxCallNodes); + if (!routeToCaller || routeToCaller.distance + 1 > routeMaxDepth) continue; + const sources = sourceByFunction.get(bridge.helper.id) ?? []; + for (const source of sources) { + if (evidence.length >= maxEvidence) break; + const helperSourceFile = files.find((file) => comparisonPath(file.path) === comparisonPath(bridge.helper.path)); + if (!helperSourceFile) continue; + const helperSource = await readBoundedSource(root, helperSourceFile); + if (!helperSource || !helperHasExactSingleReturn(helperSource, bridge.helper, source)) continue; + + for (const sink of routeFlow.evidence) { + if (evidence.length >= maxEvidence) break; + const downstream = shortestPath(bridge.forwardingTarget, sink.functionId, edges, routeMaxDepth, maxCallNodes); + if (!downstream) continue; + const usedImport = routeToCaller.usedImport + || bridge.helperCallImported + || bridge.forwardingCallImported + || downstream.usedImport; + evidence.push({ + source: { + path: source.path, + line: source.line, + kind: source.kind, + access: source.access, + functionId: bridge.helper.id, + functionName: bridge.helper.name, + }, + bridge: { + callerFunctionId: bridge.caller.id, + callerFunctionName: bridge.caller.name, + callerPath: bridge.caller.path, + helperCallLine: bridge.helperCallLine, + aliasLine: bridge.aliasLine, + forwardingCallLine: bridge.forwardingCallLine, + routeDepth: routeToCaller.distance, + bindingHops: 2, + }, + sink: { + path: sink.path, + line: sink.line, + kind: sink.kind, + functionId: sink.functionId, + functionName: sink.functionName, + }, + callDistance: 1 + downstream.distance, + callScope: usedImport ? "same-file-and-explicit-imports" : "same-file", + }); + } + } + } + if (evidence.length === 0) continue; + evidence.sort((a, b) => a.bridge.routeDepth - b.bridge.routeDepth + || a.callDistance - b.callDistance + || a.source.path.localeCompare(b.source.path) + || a.source.line - b.source.line + || a.sink.path.localeCompare(b.sink.path) + || a.sink.line - b.sink.line); + output.push({ + route: routeFlow.route, + resolution: routeFlow.resolution, + handler: routeFlow.handler, + evidence, + sourceKinds: [...new Set(evidence.map((item) => item.source.kind))], + sinkKinds: [...new Set(evidence.map((item) => item.sink.kind))], + interpretation: "structural-request-source-return-two-immutable-bindings-call-sink-evidence-only", + }); + } + return output; +} + +/** Return sanitized structural evidence only when a finding exactly matches the linked sink line. */ +export function findingRequestInputReturnAliasFlowEvidence( + contexts: readonly RouteRequestInputReturnAliasFlowContext[], + path: string, + line: number | undefined, + maxRoutes = 3, +): FindingRequestInputReturnAliasFlowEvidence[] { + if (!Number.isSafeInteger(line) || (line ?? 0) <= 0) return []; + const normalized = comparisonPath(path); + const limit = Math.max(1, Math.min(10, maxRoutes)); + const output: FindingRequestInputReturnAliasFlowEvidence[] = []; + for (const context of contexts) { + const match = context.evidence.find((item) => comparisonPath(item.sink.path) === normalized && item.sink.line === line); + if (!match) continue; + output.push({ + method: context.route.method, + route: context.route.route, + ...(context.route.frameworkHint ? { frameworkHint: context.route.frameworkHint } : {}), + resolution: context.resolution, + handler: context.handler.name, + sourceKind: match.source.kind, + sourceFunction: match.source.functionName, + sinkKind: match.sink.kind, + sinkFunction: match.sink.functionName, + callDistance: match.callDistance, + callScope: match.callScope, + bindingHops: 2, + interpretation: "structural-request-source-return-two-immutable-bindings-call-sink-evidence-only", + }); + if (output.length >= limit) break; + } + return output; +} diff --git a/packages/repository/src/request-input-return-flow.ts b/packages/repository/src/request-input-return-flow.ts new file mode 100644 index 00000000..fe1664f2 --- /dev/null +++ b/packages/repository/src/request-input-return-flow.ts @@ -0,0 +1,480 @@ +import { lstat, readFile } from "node:fs/promises"; +import { extname, isAbsolute, relative, resolve, sep } from "node:path"; +import type { IndexFileInput, RouteSignal, SinkSignal } from "./analysis.js"; +import type { CallGraph, CallGraphNode } from "./call-graph.js"; +import type { ImportCallLinkGraph } from "./import-call-links.js"; +import type { RequestInputSignal } from "./request-input-flow.js"; +import type { RouteSinkFlowContext } from "./route-sink-flow.js"; + +const MAX_SOURCE_BYTES = 512_000; +const MAX_FILES = 5_000; +const DEFAULT_MAX_FORWARD_LINES = 12; +const MAX_FORWARD_LINES = 40; +const MAX_EVIDENCE = 50; +const jsExtensions = new Set([".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"]); + +export interface RequestInputReturnFlowEvidence { + source: { + path: string; + line: number; + kind: RequestInputSignal["kind"]; + access: string; + functionId: string; + functionName: string; + }; + bridge: { + callerFunctionId: string; + callerFunctionName: string; + callerPath: string; + helperCallLine: number; + forwardingCallLine: number; + routeDepth: number; + }; + sink: { + path: string; + line: number; + kind: SinkSignal["kind"]; + functionId: string; + functionName: string; + }; + /** Directed call distance from the forwarding call to the sink-owning function. */ + callDistance: number; + callScope: "same-file" | "same-file-and-explicit-imports"; +} + +export interface RouteRequestInputReturnFlowContext { + route: RouteSignal; + resolution: RouteSinkFlowContext["resolution"]; + handler: RouteSinkFlowContext["handler"]; + evidence: RequestInputReturnFlowEvidence[]; + sourceKinds: RequestInputSignal["kind"][]; + sinkKinds: SinkSignal["kind"][]; + /** + * Evidence requires an exact direct request access returned from one helper, an exact resolved + * helper call whose return is bound with const, and exactly one unchanged direct-argument use + * before a bounded downstream sink. It is not general taint, runtime reachability, attacker + * control, sanitization analysis, or exploitability evidence. + */ + interpretation: "structural-request-source-return-binding-call-sink-evidence-only"; +} + +export interface FindingRequestInputReturnFlowEvidence { + method: string; + route: string; + frameworkHint?: string; + resolution: RouteSinkFlowContext["resolution"]; + handler: string; + sourceKind: RequestInputSignal["kind"]; + sourceFunction: string; + sinkKind: SinkSignal["kind"]; + sinkFunction: string; + callDistance: number; + callScope: RequestInputReturnFlowEvidence["callScope"]; + interpretation: "structural-request-source-return-binding-call-sink-evidence-only"; +} + +export interface RequestInputReturnFlowOptions { + maxForwardLines?: number; + maxEvidence?: number; + maxRoutes?: number; + maxCallNodes?: number; +} + +interface GraphEdges { + targets: Map; + imported: Set; +} + +interface HelperBridge { + caller: CallGraphNode; + helper: CallGraphNode; + helperCallLine: number; + binding: string; + forwardingCallLine: number; + forwardingTarget: string; + helperCallImported: boolean; + forwardingCallImported: boolean; +} + +function normalizedPath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function comparisonPath(value: string): string { + return normalizedPath(value).replace(/^\//, "").toLowerCase(); +} + +function insideRoot(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function boundedForwardLines(value: number | undefined): number { + const resolved = value ?? DEFAULT_MAX_FORWARD_LINES; + if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > MAX_FORWARD_LINES) { + throw new Error(`Request-input return-flow maxForwardLines must be an integer between 1 and ${MAX_FORWARD_LINES}.`); + } + return resolved; +} + +async function readBoundedSource(root: string, file: IndexFileInput): Promise { + if (file.size > MAX_SOURCE_BYTES) return undefined; + const path = normalizedPath(file.path); + if (!path || path.includes("\0") || path.startsWith("../") || isAbsolute(file.path)) return undefined; + const absolute = resolve(root, path); + if (!insideRoot(root, absolute)) return undefined; + const info = await lstat(absolute).catch(() => undefined); + if (!info?.isFile() || info.isSymbolicLink() || info.size > MAX_SOURCE_BYTES) return undefined; + const content = await readFile(absolute, "utf8").catch(() => undefined); + return content === undefined || content.includes("\u0000") ? undefined : content; +} + +function owningFunction(graph: CallGraph, path: string, line: number): CallGraphNode | undefined { + const normalized = comparisonPath(path); + const matches = graph.nodes.filter( + (node) => comparisonPath(node.path) === normalized && line >= node.line && line <= node.endLine, + ); + return matches.length === 1 ? matches[0] : undefined; +} + +function graphEdges(graph: CallGraph, imports: ImportCallLinkGraph): GraphEdges { + const buckets = new Map>(); + const imported = new Set(); + const add = (from: string, target: string): void => { + const bucket = buckets.get(from) ?? new Set(); + bucket.add(target); + buckets.set(from, bucket); + }; + for (const edge of graph.edges) if (edge.target) add(edge.from, edge.target); + for (const link of imports.links) { + add(link.from, link.target); + imported.add(`${link.from}\u0000${link.target}`); + } + return { + targets: new Map([...buckets.entries()].map(([key, value]) => [key, [...value].sort()])), + imported, + }; +} + +function shortestPath( + start: string, + target: string, + edges: GraphEdges, + maxDepth: number, + maxNodes: number, +): { distance: number; usedImport: boolean } | undefined { + if (start === target) return { distance: 0, usedImport: false }; + const queue: Array<{ id: string; depth: number; usedImport: boolean }> = [{ id: start, depth: 0, usedImport: false }]; + const bestDepth = new Map([[start, 0]]); + let examined = 0; + while (queue.length > 0 && examined < maxNodes) { + const current = queue.shift(); + if (!current || current.depth >= maxDepth) continue; + examined += 1; + for (const next of edges.targets.get(current.id) ?? []) { + const depth = current.depth + 1; + const usedImport = current.usedImport || edges.imported.has(`${current.id}\u0000${next}`); + if (next === target) return { distance: depth, usedImport }; + const prior = bestDepth.get(next); + if (prior !== undefined && prior <= depth) continue; + bestDepth.set(next, depth); + queue.push({ id: next, depth, usedImport }); + } + } + return undefined; +} + +function helperHasExactSingleReturn( + source: string, + node: CallGraphNode, + signal: RequestInputSignal, +): boolean { + const lines = source.split(/\r?\n/); + const body = lines.slice(node.line - 1, node.endLine); + const returnLines: number[] = []; + for (let index = 0; index < body.length; index += 1) { + if (/^\s*return\b/.test(body[index] ?? "")) returnLines.push(node.line + index); + } + if (returnLines.length !== 1 || returnLines[0] !== signal.line) return false; + const line = lines[signal.line - 1] ?? ""; + if (signal.frameworkFamily !== "node-request") return false; + return /^\s*return\s+(?:req|request)\.(?:body|query|params|headers|cookies|files?)(?:\.[A-Za-z_$][\w$]*|\[["'][^"'\r\n]{1,64}["']\])*\s*;?\s*$/.test(line); +} + +function resolvedCallsTo( + graph: CallGraph, + imports: ImportCallLinkGraph, + helperId: string, +): Array<{ from: string; line: number; callee: string; imported: boolean }> { + const output: Array<{ from: string; line: number; callee: string; imported: boolean }> = []; + for (const edge of graph.edges) { + if (edge.target === helperId) output.push({ from: edge.from, line: edge.line, callee: edge.callee, imported: false }); + } + for (const link of imports.links) { + if (link.target === helperId) output.push({ from: link.from, line: link.line, callee: link.callee, imported: true }); + } + return output.sort((a, b) => a.from.localeCompare(b.from) || a.line - b.line || a.callee.localeCompare(b.callee)); +} + +function uniqueResolvedTargetAtLine( + graph: CallGraph, + imports: ImportCallLinkGraph, + callerId: string, + line: number, + callee: string, +): { target: string; imported: boolean } | undefined { + const matches: Array<{ target: string; imported: boolean }> = []; + for (const edge of graph.edges) { + if (edge.from === callerId && edge.line === line && edge.callee === callee && edge.target) { + matches.push({ target: edge.target, imported: false }); + } + } + for (const link of imports.links) { + if (link.from === callerId && link.line === line && link.callee === callee) { + matches.push({ target: link.target, imported: true }); + } + } + const uniqueTargets = [...new Set(matches.map((match) => match.target))]; + if (uniqueTargets.length !== 1) return undefined; + const target = uniqueTargets[0]; + if (!target) return undefined; + return { target, imported: matches.some((match) => match.target === target && match.imported) }; +} + +function parseHelperBinding(line: string, callee: string): { binding: string } | undefined { + const escaped = escapeRegExp(callee); + const match = line.match(new RegExp(`^\\s*const\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*${escaped}\\(\\s*[A-Za-z_$][\\w$]*\\s*\\)\\s*;?\\s*$`)); + return match?.[1] ? { binding: match[1] } : undefined; +} + +function findSingleForwardUse( + lines: readonly string[], + caller: CallGraphNode, + helperCallLine: number, + binding: string, + maxForwardLines: number, +): { line: number; callee: string } | undefined { + const escapedBinding = escapeRegExp(binding); + const occurrence = new RegExp(`\\b${escapedBinding}\\b`, "g"); + const uses: Array<{ line: number; text: string }> = []; + for (let line = helperCallLine + 1; line <= caller.endLine; line += 1) { + const text = lines[line - 1] ?? ""; + occurrence.lastIndex = 0; + let count = 0; + for (let match = occurrence.exec(text); match; match = occurrence.exec(text)) count += 1; + for (let index = 0; index < count; index += 1) uses.push({ line, text }); + if (uses.length > 1) return undefined; + } + if (uses.length !== 1) return undefined; + const use = uses[0]; + if (!use || use.line - helperCallLine > maxForwardLines) return undefined; + const call = use.text.match(new RegExp(`^\\s*([A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$][\\w$]*)?)\\s*\\(\\s*${escapedBinding}\\s*\\)\\s*;?\\s*$`)); + return call?.[1] ? { line: use.line, callee: call[1] } : undefined; +} + +async function bridgeCandidates( + root: string, + files: readonly IndexFileInput[], + signals: readonly RequestInputSignal[], + graph: CallGraph, + imports: ImportCallLinkGraph, + maxForwardLines: number, +): Promise { + const fileByPath = new Map(files.slice(0, MAX_FILES).map((file) => [comparisonPath(file.path), file])); + const sourceCache = new Map(); + const nodeById = new Map(graph.nodes.map((node) => [node.id, node])); + const getSource = async (path: string): Promise => { + const key = comparisonPath(path); + if (sourceCache.has(key)) return sourceCache.get(key); + const file = fileByPath.get(key); + const source = file && jsExtensions.has(extname(file.path).toLowerCase()) ? await readBoundedSource(root, file) : undefined; + sourceCache.set(key, source); + return source; + }; + const output: HelperBridge[] = []; + + for (const signal of signals) { + if (signal.frameworkFamily !== "node-request") continue; + const helper = owningFunction(graph, signal.path, signal.line); + if (!helper || helper.kind === "python-function") continue; + const helperSource = await getSource(helper.path); + if (!helperSource || !helperHasExactSingleReturn(helperSource, helper, signal)) continue; + + for (const inbound of resolvedCallsTo(graph, imports, helper.id)) { + const caller = nodeById.get(inbound.from); + if (!caller || caller.kind === "python-function") continue; + const callerSource = await getSource(caller.path); + if (!callerSource) continue; + const lines = callerSource.split(/\r?\n/); + const binding = parseHelperBinding(lines[inbound.line - 1] ?? "", inbound.callee); + if (!binding) continue; + const forward = findSingleForwardUse(lines, caller, inbound.line, binding.binding, maxForwardLines); + if (!forward) continue; + const target = uniqueResolvedTargetAtLine(graph, imports, caller.id, forward.line, forward.callee); + if (!target || target.target === helper.id) continue; + output.push({ + caller, + helper, + helperCallLine: inbound.line, + binding: binding.binding, + forwardingCallLine: forward.line, + forwardingTarget: target.target, + helperCallImported: inbound.imported, + forwardingCallImported: target.imported, + }); + } + } + + return output; +} + +/** + * Build the deliberately narrow request-helper-return -> immutable caller binding -> downstream call + * evidence layer. It accepts only JS/TS helpers with exactly one `return req.` statement, + * exact resolved one-argument helper calls assigned to `const`, and one unchanged direct-argument + * use of that binding. Any transformation, destructuring, mutation, multiple use, unresolved call, + * non-direct return, or ambiguous function ownership is omitted rather than inferred. + */ +export async function repositoryRouteRequestInputReturnFlowContexts( + rootPath: string, + files: readonly IndexFileInput[], + signals: readonly RequestInputSignal[], + routeFlows: readonly RouteSinkFlowContext[], + graph: CallGraph, + imports: ImportCallLinkGraph, + options: RequestInputReturnFlowOptions = {}, +): Promise { + const root = resolve(rootPath); + const maxForwardLines = boundedForwardLines(options.maxForwardLines); + const maxEvidence = Math.max(1, Math.min(MAX_EVIDENCE, options.maxEvidence ?? 12)); + const maxRoutes = Math.max(0, Math.min(5_000, options.maxRoutes ?? 1_000)); + const maxCallNodes = Math.max(1, Math.min(1_000, options.maxCallNodes ?? 100)); + const bridges = await bridgeCandidates(root, files, signals, graph, imports, maxForwardLines); + const edges = graphEdges(graph, imports); + const sourceByFunction = new Map(); + for (const signal of signals) { + const node = owningFunction(graph, signal.path, signal.line); + if (!node) continue; + const bucket = sourceByFunction.get(node.id) ?? []; + bucket.push(signal); + sourceByFunction.set(node.id, bucket); + } + const output: RouteRequestInputReturnFlowContext[] = []; + + for (const routeFlow of routeFlows.slice(0, maxRoutes)) { + const evidence: RequestInputReturnFlowEvidence[] = []; + const routeMaxDepth = Math.max(0, routeFlow.maxDepth); + for (const bridge of bridges) { + if (evidence.length >= maxEvidence) break; + const routeToCaller = shortestPath(routeFlow.handler.id, bridge.caller.id, edges, routeMaxDepth, maxCallNodes); + if (!routeToCaller) continue; + const callerToHelper = uniqueResolvedTargetAtLine(graph, imports, bridge.caller.id, bridge.helperCallLine, + (graph.edges.find((edge) => edge.from === bridge.caller.id && edge.line === bridge.helperCallLine && edge.target === bridge.helper.id)?.callee) + ?? (imports.links.find((link) => link.from === bridge.caller.id && link.line === bridge.helperCallLine && link.target === bridge.helper.id)?.callee) + ?? ""); + if (!callerToHelper || callerToHelper.target !== bridge.helper.id) continue; + if (routeToCaller.distance + 1 > routeMaxDepth) continue; + + const sources = sourceByFunction.get(bridge.helper.id) ?? []; + for (const source of sources) { + if (evidence.length >= maxEvidence) break; + const helperSourceFile = files.find((file) => comparisonPath(file.path) === comparisonPath(bridge.helper.path)); + if (!helperSourceFile) continue; + const helperSource = await readBoundedSource(root, helperSourceFile); + if (!helperSource || !helperHasExactSingleReturn(helperSource, bridge.helper, source)) continue; + + for (const sink of routeFlow.evidence) { + if (evidence.length >= maxEvidence) break; + const downstream = shortestPath(bridge.forwardingTarget, sink.functionId, edges, routeMaxDepth, maxCallNodes); + if (!downstream) continue; + const callDistance = 1 + downstream.distance; + const usedImport = routeToCaller.usedImport + || bridge.helperCallImported + || bridge.forwardingCallImported + || downstream.usedImport; + evidence.push({ + source: { + path: source.path, + line: source.line, + kind: source.kind, + access: source.access, + functionId: bridge.helper.id, + functionName: bridge.helper.name, + }, + bridge: { + callerFunctionId: bridge.caller.id, + callerFunctionName: bridge.caller.name, + callerPath: bridge.caller.path, + helperCallLine: bridge.helperCallLine, + forwardingCallLine: bridge.forwardingCallLine, + routeDepth: routeToCaller.distance, + }, + sink: { + path: sink.path, + line: sink.line, + kind: sink.kind, + functionId: sink.functionId, + functionName: sink.functionName, + }, + callDistance, + callScope: usedImport ? "same-file-and-explicit-imports" : "same-file", + }); + } + } + } + if (evidence.length === 0) continue; + evidence.sort((a, b) => a.bridge.routeDepth - b.bridge.routeDepth + || a.callDistance - b.callDistance + || a.source.path.localeCompare(b.source.path) + || a.source.line - b.source.line + || a.sink.path.localeCompare(b.sink.path) + || a.sink.line - b.sink.line); + output.push({ + route: routeFlow.route, + resolution: routeFlow.resolution, + handler: routeFlow.handler, + evidence, + sourceKinds: [...new Set(evidence.map((item) => item.source.kind))], + sinkKinds: [...new Set(evidence.map((item) => item.sink.kind))], + interpretation: "structural-request-source-return-binding-call-sink-evidence-only", + }); + } + return output; +} + +/** Return sanitized structural evidence only when a finding exactly matches the linked sink line. */ +export function findingRequestInputReturnFlowEvidence( + contexts: readonly RouteRequestInputReturnFlowContext[], + path: string, + line: number | undefined, + maxRoutes = 3, +): FindingRequestInputReturnFlowEvidence[] { + if (!Number.isSafeInteger(line) || (line ?? 0) <= 0) return []; + const normalized = comparisonPath(path); + const limit = Math.max(1, Math.min(10, maxRoutes)); + const output: FindingRequestInputReturnFlowEvidence[] = []; + for (const context of contexts) { + const match = context.evidence.find((item) => comparisonPath(item.sink.path) === normalized && item.sink.line === line); + if (!match) continue; + output.push({ + method: context.route.method, + route: context.route.route, + ...(context.route.frameworkHint ? { frameworkHint: context.route.frameworkHint } : {}), + resolution: context.resolution, + handler: context.handler.name, + sourceKind: match.source.kind, + sourceFunction: match.source.functionName, + sinkKind: match.sink.kind, + sinkFunction: match.sink.functionName, + callDistance: match.callDistance, + callScope: match.callScope, + interpretation: "structural-request-source-return-binding-call-sink-evidence-only", + }); + if (output.length >= limit) break; + } + return output; +} diff --git a/packages/repository/src/route-auth-context.ts b/packages/repository/src/route-auth-context.ts new file mode 100644 index 00000000..c9830653 --- /dev/null +++ b/packages/repository/src/route-auth-context.ts @@ -0,0 +1,81 @@ +import type { AuthSignal, RepositoryIndex, RouteSignal } from "./analysis.js"; + +export type RouteAuthStatus = + | "authorization-signal-observed" + | "authentication-signal-observed" + | "no-auth-signal-observed"; + +export interface RouteAuthEvidence { + line: number; + distance: number; + kind: AuthSignal["kind"]; +} + +export interface RouteAuthContext { + route: RouteSignal; + status: RouteAuthStatus; + evidence: RouteAuthEvidence[]; + radius: number; + /** Lexical proximity is evidence for review prioritization, not proof of route protection. */ + interpretation: "lexical-auth-signals-only"; +} + +function normalizePath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, "").replace(/^\//, "").toLowerCase(); +} + +function authPriority(kind: AuthSignal["kind"]): number { + if (kind === "authorization") return 4; + if (kind === "authentication") return 3; + if (kind === "token") return 2; + return 1; +} + +export function routeAuthContext( + index: RepositoryIndex, + route: RouteSignal, + options: { radius?: number; maxEvidence?: number } = {}, +): RouteAuthContext { + const radius = Math.max(0, Math.min(500, options.radius ?? 40)); + const maxEvidence = Math.max(1, Math.min(20, options.maxEvidence ?? 5)); + const routePath = normalizePath(route.path); + + const evidence = index.authSignals + .filter((signal) => normalizePath(signal.path) === routePath) + .map((signal): RouteAuthEvidence => ({ + line: signal.line, + distance: Math.abs(signal.line - route.line), + kind: signal.kind, + })) + .filter((signal) => signal.distance <= radius) + .sort((a, b) => { + const priority = authPriority(b.kind) - authPriority(a.kind); + if (priority !== 0) return priority; + return a.distance - b.distance || a.line - b.line; + }) + .slice(0, maxEvidence); + + const status: RouteAuthStatus = evidence.some((signal) => signal.kind === "authorization") + ? "authorization-signal-observed" + : evidence.length > 0 + ? "authentication-signal-observed" + : "no-auth-signal-observed"; + + return { + route, + status, + evidence, + radius, + interpretation: "lexical-auth-signals-only", + }; +} + +export function repositoryRouteAuthContexts( + index: RepositoryIndex, + options: { radius?: number; maxEvidence?: number; maxRoutes?: number } = {}, +): RouteAuthContext[] { + const maxRoutes = Math.max(0, Math.min(5_000, options.maxRoutes ?? 1_000)); + return index.routes + .slice(0, maxRoutes) + .map((route) => routeAuthContext(index, route, options)); +} diff --git a/packages/repository/src/route-entrypoints.ts b/packages/repository/src/route-entrypoints.ts new file mode 100644 index 00000000..6006ceea --- /dev/null +++ b/packages/repository/src/route-entrypoints.ts @@ -0,0 +1,102 @@ +import type { RepositoryIndex, RouteSignal } from "./analysis.js"; +import type { CallGraph, CallGraphNode, CallNeighborhood } from "./call-graph.js"; +import { findCallNeighborhood } from "./call-graph.js"; + +export type RouteEntrypointResolution = + | "decorated-function" + | "named-function" + | "imported-named-function" + | "unresolved"; + +export interface RouteEntrypoint { + route: RouteSignal; + resolution: RouteEntrypointResolution; + handler?: CallGraphNode; + calls?: CallNeighborhood; + /** Route-to-handler mapping and downstream calls are static structural evidence, not runtime reachability proof. */ + interpretation: "structural-route-call-evidence-only"; +} + +function normalizePath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function isDecoratorRoute(route: RouteSignal): boolean { + return route.frameworkHint === "Decorator router" || route.frameworkHint === "Python web router"; +} + +function decoratedHandler( + route: RouteSignal, + graph: CallGraph, + maxDeclarationDistance: number, +): CallGraphNode | undefined { + if (!isDecoratorRoute(route)) return undefined; + const routePath = normalizePath(route.path); + const boundedDistance = Math.max(1, Math.min(20, maxDeclarationDistance)); + const candidates = graph.nodes + .filter((node) => { + if (normalizePath(node.path) !== routePath) return false; + const distance = node.line - route.line; + return distance > 0 && distance <= boundedDistance; + }) + .sort((a, b) => a.line - b.line || a.name.localeCompare(b.name)); + + const first = candidates[0]; + if (!first) return undefined; + const nearestDistance = first.line - route.line; + const nearest = candidates.filter((candidate) => candidate.line - route.line === nearestDistance); + return nearest.length === 1 ? nearest[0] : undefined; +} + +function namedNodeHandler(route: RouteSignal, graph: CallGraph): CallGraphNode | undefined { + if (route.frameworkHint !== "Node HTTP router" || !route.handler) return undefined; + const routePath = normalizePath(route.path); + const candidates = graph.nodes.filter( + (node) => normalizePath(node.path) === routePath && node.name === route.handler, + ); + return candidates.length === 1 ? candidates[0] : undefined; +} + +export function resolveRouteEntrypoints( + index: RepositoryIndex, + graph: CallGraph, + options: { maxDeclarationDistance?: number; maxCallDepth?: number; maxCallNodes?: number } = {}, +): RouteEntrypoint[] { + const maxDeclarationDistance = options.maxDeclarationDistance ?? 5; + const maxCallDepth = options.maxCallDepth ?? 3; + const maxCallNodes = options.maxCallNodes ?? 100; + + return index.routes.map((route) => { + const decorated = decoratedHandler(route, graph, maxDeclarationDistance); + const named = decorated ? undefined : namedNodeHandler(route, graph); + const handler = decorated ?? named; + if (!handler) { + return { + route, + resolution: "unresolved", + interpretation: "structural-route-call-evidence-only", + }; + } + + return { + route, + resolution: decorated ? "decorated-function" : "named-function", + handler, + calls: findCallNeighborhood(graph, handler.id, maxCallDepth, maxCallNodes), + interpretation: "structural-route-call-evidence-only", + }; + }); +} + +export function routeEntrypointForLocation( + entrypoints: readonly RouteEntrypoint[], + path: string, + line: number, +): RouteEntrypoint | undefined { + const normalized = normalizePath(path); + return entrypoints.find((entrypoint) => { + const handler = entrypoint.handler; + if (!handler || normalizePath(handler.path) !== normalized) return false; + return line >= handler.line && line <= handler.endLine; + }); +} diff --git a/packages/repository/src/route-flow-analysis.ts b/packages/repository/src/route-flow-analysis.ts new file mode 100644 index 00000000..4f93e122 --- /dev/null +++ b/packages/repository/src/route-flow-analysis.ts @@ -0,0 +1,363 @@ +import { lstat } from "node:fs/promises"; +import { isAbsolute, relative, resolve, sep } from "node:path"; +import type { IndexFileInput, RepositoryIndex } from "./analysis.js"; +import { buildCallGraph, type CallGraph } from "./call-graph.js"; +import { resolveDjangoRouteEntrypoints } from "./django-route-handlers.js"; +import { composeDjangoIncludedRouteEntrypoints } from "./django-urlconf-composition.js"; +import { composeExpressRouterEntrypoints } from "./express-router-composition.js"; +import { + buildFastApiRouteDependencyContexts, + type FastApiRouteDependencyContext, +} from "./fastapi-route-dependencies.js"; +import { composeFastApiRouterEntrypoints } from "./fastapi-router-composition.js"; +import { composeFlaskBlueprintEntrypoints } from "./flask-blueprint-composition.js"; +import { + buildGinRouteRequestInputFlowContexts, + type GinRouteRequestInputFlowContext, +} from "./gin-request-input-flow.js"; +import { + buildGinRouteRequestInputForwardingContexts, + type GinRouteRequestInputForwardingContext, +} from "./gin-request-input-forwarding.js"; +import { + composeGinRouterEntrypoints, + type GinRouteMiddlewareContext, +} from "./gin-router-composition.js"; +import { buildImportCallLinkGraph, type ImportCallLinkGraph } from "./import-call-links.js"; +import { resolveImportedNodeRouteEntrypoints } from "./import-route-handlers.js"; +import { + buildKoaRouteRequestInputFlowContexts, + type KoaRouteRequestInputFlowContext, +} from "./koa-request-input-flow.js"; +import { + buildKoaRouteRequestInputForwardingContexts, + type KoaRouteRequestInputForwardingContext, +} from "./koa-request-input-forwarding.js"; +import { + composeKoaRouterEntrypoints, + type KoaRouteMiddlewareContext, +} from "./koa-router-composition.js"; +import type { ModuleGraph } from "./module-graph.js"; +import { + composeNestJsControllerEntrypoints, + type NestJsGuardContext, +} from "./nestjs-controller-composition.js"; +import { + repositoryRouteRequestInputForwardingContexts, + type RouteRequestInputForwardingContext, +} from "./request-input-forwarding.js"; +import { + collectRequestInputSignals, + repositoryRouteRequestInputFlowContexts, + type RequestInputSignal, + type RouteRequestInputFlowContext, +} from "./request-input-flow.js"; +import { + repositoryRouteRequestInputReturnFlowContexts, + type RouteRequestInputReturnFlowContext, +} from "./request-input-return-flow.js"; +import { resolveRouteEntrypoints, type RouteEntrypoint } from "./route-entrypoints.js"; +import { + buildRouteMiddlewareCompositionContexts, + type RouteMiddlewareCompositionContext, +} from "./route-middleware-composition.js"; +import { + repositoryRouteProtectionContexts, + type RouteProtectionContext, + type RouteProtectionOptions, +} from "./route-protection-context.js"; +import { + buildRouteSecurityReviewContexts, + type RouteSecurityReviewContext, +} from "./route-security-review.js"; +import { + repositoryRouteSinkFlowContexts, + type RouteSinkFlowContext, + type RouteSinkFlowOptions, +} from "./route-sink-flow.js"; + +const DEFAULT_MAX_ANALYSIS_FILES = 5_000; +const MAX_ANALYSIS_FILES = 5_000; + +export interface RepositoryRouteFlowAnalysis { + callGraph: CallGraph; + importCallLinks: ImportCallLinkGraph; + requestInputs: RequestInputSignal[]; + entrypoints: RouteEntrypoint[]; + routeMiddlewareContexts: RouteMiddlewareCompositionContext[]; + ginMiddlewareContexts: GinRouteMiddlewareContext[]; + ginRequestInputFlows: GinRouteRequestInputFlowContext[]; + ginRequestInputForwardingFlows: GinRouteRequestInputForwardingContext[]; + koaMiddlewareContexts: KoaRouteMiddlewareContext[]; + koaRequestInputFlows: KoaRouteRequestInputFlowContext[]; + koaRequestInputForwardingFlows: KoaRouteRequestInputForwardingContext[]; + fastApiDependencyContexts: FastApiRouteDependencyContext[]; + nestJsGuardContexts: NestJsGuardContext[]; + routeFlows: RouteSinkFlowContext[]; + requestInputFlows: RouteRequestInputFlowContext[]; + requestInputForwardingFlows: RouteRequestInputForwardingContext[]; + requestInputReturnFlows: RouteRequestInputReturnFlowContext[]; + routeProtectionContexts: RouteProtectionContext[]; + routeSecurityReviews: RouteSecurityReviewContext[]; + inputFileCount: number; + analyzedFileCount: number; + skippedUnsafeFileCount: number; + truncatedFileCount: number; + coverage: "complete-input" | "bounded-input"; + /** All relationships are bounded static repository evidence only. */ + interpretation: "repository-structural-route-flow-evidence-only"; +} + +export interface RepositoryRouteFlowAnalysisOptions { + maxDeclarationDistance?: number; + maxCallDepth?: number; + maxCallNodes?: number; + maxEvidence?: number; + maxRoutes?: number; + maxRequestInputSignals?: number; + maxRequestInputForwardLines?: number; + maxRequestInputReturnForwardLines?: number; + maxGinRequestInputForwardLines?: number; + maxKoaRequestInputForwardLines?: number; + maxDjangoIncludeDepth?: number; + maxDjangoComposedRoutes?: number; + maxExpressMountDepth?: number; + maxExpressComposedRoutes?: number; + maxFastApiIncludeDepth?: number; + maxFastApiComposedRoutes?: number; + maxFlaskBlueprintDepth?: number; + maxFlaskComposedRoutes?: number; + maxGinRoutes?: number; + maxKoaRoutes?: number; + maxNestJsDecoratorDistance?: number; + maxNestJsRoutes?: number; + /** Maximum number of supplied repository files eligible for lexical analysis. */ + maxFiles?: number; +} + +interface SafeAnalysisFileResult { + files: IndexFileInput[]; + skippedUnsafeFileCount: number; + truncatedFileCount: number; +} + +function insideRoot(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +function boundedMaxFiles(value: number | undefined): number { + if (value === undefined) return DEFAULT_MAX_ANALYSIS_FILES; + if (!Number.isSafeInteger(value) || value < 1 || value > MAX_ANALYSIS_FILES) { + throw new Error(`Repository route-flow maxFiles must be an integer between 1 and ${MAX_ANALYSIS_FILES}.`); + } + return value; +} + +async function safeAnalysisFiles( + rootPath: string, + files: readonly IndexFileInput[], + maxFiles: number, +): Promise { + const root = resolve(rootPath); + const output: IndexFileInput[] = []; + let skippedUnsafeFileCount = 0; + const eligible = files.slice(0, maxFiles); + + for (const file of eligible) { + if (typeof file.path !== "string" || !file.path || file.path.includes("\0") || isAbsolute(file.path)) { + skippedUnsafeFileCount += 1; + continue; + } + const candidate = resolve(root, file.path); + if (!insideRoot(root, candidate)) { + skippedUnsafeFileCount += 1; + continue; + } + const info = await lstat(candidate).catch(() => undefined); + if (!info?.isFile() || info.isSymbolicLink()) { + skippedUnsafeFileCount += 1; + continue; + } + output.push({ path: file.path, size: info.size }); + } + + return { + files: output, + skippedUnsafeFileCount, + truncatedFileCount: Math.max(0, files.length - eligible.length), + }; +} + +/** Build bounded defensive route-flow context from already-indexed repository files. */ +export async function buildRepositoryRouteFlowAnalysis( + rootPath: string, + files: readonly IndexFileInput[], + index: RepositoryIndex, + moduleGraph: ModuleGraph, + options: RepositoryRouteFlowAnalysisOptions = {}, +): Promise { + const maxCallNodes = Math.max(1, Math.min(1_000, options.maxCallNodes ?? 100)); + const maxFiles = boundedMaxFiles(options.maxFiles); + const safe = await safeAnalysisFiles(rootPath, files, maxFiles); + const callGraph = await buildCallGraph(rootPath, safe.files); + const importCallLinks = await buildImportCallLinkGraph(rootPath, safe.files, moduleGraph, callGraph); + const requestInputs = await collectRequestInputSignals(rootPath, safe.files, { + ...(options.maxRequestInputSignals !== undefined ? { maxSignals: options.maxRequestInputSignals } : {}), + }); + let entrypoints = resolveRouteEntrypoints(index, callGraph, { + ...(options.maxDeclarationDistance !== undefined ? { maxDeclarationDistance: options.maxDeclarationDistance } : {}), + ...(options.maxCallDepth !== undefined ? { maxCallDepth: options.maxCallDepth } : {}), + maxCallNodes, + }); + entrypoints = await resolveImportedNodeRouteEntrypoints(rootPath, safe.files, moduleGraph, callGraph, entrypoints, { + ...(options.maxCallDepth !== undefined ? { maxCallDepth: options.maxCallDepth } : {}), + maxCallNodes, + }); + entrypoints = await composeExpressRouterEntrypoints(rootPath, safe.files, moduleGraph, entrypoints, { + ...(options.maxExpressMountDepth !== undefined ? { maxMountDepth: options.maxExpressMountDepth } : {}), + ...(options.maxExpressComposedRoutes !== undefined ? { maxComposedRoutes: options.maxExpressComposedRoutes } : {}), + }); + const gin = await composeGinRouterEntrypoints(rootPath, safe.files, callGraph, entrypoints, { + ...(options.maxGinRoutes !== undefined ? { maxRoutes: options.maxGinRoutes } : {}), + ...(options.maxCallDepth !== undefined ? { maxCallDepth: options.maxCallDepth } : {}), + maxCallNodes, + }); + entrypoints = gin.entrypoints; + const ginMiddlewareContexts = gin.middlewareContexts; + const koa = await composeKoaRouterEntrypoints(rootPath, safe.files, callGraph, entrypoints, { + ...(options.maxKoaRoutes !== undefined ? { maxRoutes: options.maxKoaRoutes } : {}), + ...(options.maxCallDepth !== undefined ? { maxCallDepth: options.maxCallDepth } : {}), + maxCallNodes, + }); + entrypoints = koa.entrypoints; + const koaMiddlewareContexts = koa.middlewareContexts; + entrypoints = await resolveImportedNodeRouteEntrypoints(rootPath, safe.files, moduleGraph, callGraph, entrypoints, { + ...(options.maxCallDepth !== undefined ? { maxCallDepth: options.maxCallDepth } : {}), + maxCallNodes, + }); + entrypoints = await resolveDjangoRouteEntrypoints(rootPath, safe.files, moduleGraph, callGraph, entrypoints, { + ...(options.maxCallDepth !== undefined ? { maxCallDepth: options.maxCallDepth } : {}), + maxCallNodes, + }); + entrypoints = await composeDjangoIncludedRouteEntrypoints(rootPath, safe.files, entrypoints, { + ...(options.maxDjangoIncludeDepth !== undefined ? { maxIncludeDepth: options.maxDjangoIncludeDepth } : {}), + ...(options.maxDjangoComposedRoutes !== undefined ? { maxComposedRoutes: options.maxDjangoComposedRoutes } : {}), + }); + entrypoints = await composeFastApiRouterEntrypoints(rootPath, safe.files, moduleGraph, entrypoints, { + ...(options.maxFastApiIncludeDepth !== undefined ? { maxIncludeDepth: options.maxFastApiIncludeDepth } : {}), + ...(options.maxFastApiComposedRoutes !== undefined ? { maxComposedRoutes: options.maxFastApiComposedRoutes } : {}), + ...(options.maxDeclarationDistance !== undefined ? { maxDeclarationDistance: options.maxDeclarationDistance } : {}), + ...(options.maxCallDepth !== undefined ? { maxCallDepth: options.maxCallDepth } : {}), + maxCallNodes, + }); + entrypoints = await composeFlaskBlueprintEntrypoints(rootPath, safe.files, moduleGraph, entrypoints, { + ...(options.maxFlaskBlueprintDepth !== undefined ? { maxRegisterDepth: options.maxFlaskBlueprintDepth } : {}), + ...(options.maxFlaskComposedRoutes !== undefined ? { maxComposedRoutes: options.maxFlaskComposedRoutes } : {}), + ...(options.maxDeclarationDistance !== undefined ? { maxDeclarationDistance: options.maxDeclarationDistance } : {}), + ...(options.maxCallDepth !== undefined ? { maxCallDepth: options.maxCallDepth } : {}), + maxCallNodes, + }); + const nestJs = await composeNestJsControllerEntrypoints(rootPath, safe.files, callGraph, entrypoints, { + ...(options.maxNestJsDecoratorDistance !== undefined ? { maxDecoratorDistance: options.maxNestJsDecoratorDistance } : {}), + ...(options.maxNestJsRoutes !== undefined ? { maxRoutes: options.maxNestJsRoutes } : {}), + ...(options.maxCallDepth !== undefined ? { maxCallDepth: options.maxCallDepth } : {}), + maxCallNodes, + }); + entrypoints = nestJs.entrypoints; + const nestJsGuardContexts = nestJs.guardContexts; + const routeMiddlewareContexts = await buildRouteMiddlewareCompositionContexts(rootPath, safe.files, index, moduleGraph, callGraph, importCallLinks, entrypoints, { + ...(options.maxRoutes !== undefined ? { maxRoutes: options.maxRoutes } : {}), + ...(options.maxCallDepth !== undefined ? { maxCallDepth: options.maxCallDepth } : {}), + maxCallNodes, + }); + const fastApiDependencyContexts = await buildFastApiRouteDependencyContexts(rootPath, safe.files, index, moduleGraph, callGraph, entrypoints, { + ...(options.maxRoutes !== undefined ? { maxRoutes: options.maxRoutes } : {}), + ...(options.maxCallDepth !== undefined ? { maxCallDepth: options.maxCallDepth } : {}), + maxCallNodes, + ...(options.maxEvidence !== undefined ? { maxEvidence: options.maxEvidence } : {}), + }); + const flowOptions: RouteSinkFlowOptions = { + importCallLinks, + maxCallNodes, + ...(options.maxEvidence !== undefined ? { maxEvidence: options.maxEvidence } : {}), + ...(options.maxRoutes !== undefined ? { maxRoutes: options.maxRoutes } : {}), + }; + const protectionOptions: RouteProtectionOptions = { + importCallLinks, + maxCallNodes, + ...(options.maxEvidence !== undefined ? { maxEvidence: options.maxEvidence } : {}), + ...(options.maxRoutes !== undefined ? { maxRoutes: options.maxRoutes } : {}), + }; + const routeFlows = repositoryRouteSinkFlowContexts(index, entrypoints, callGraph, flowOptions); + const ginRequestInputFlows = await buildGinRouteRequestInputFlowContexts(rootPath, routeFlows, callGraph, { + maxFiles, + ...(options.maxEvidence !== undefined ? { maxEvidence: options.maxEvidence } : {}), + ...(options.maxRoutes !== undefined ? { maxRoutes: options.maxRoutes } : {}), + }); + const ginRequestInputForwardingFlows = await buildGinRouteRequestInputForwardingContexts(rootPath, routeFlows, callGraph, { + maxFiles, + ...(options.maxEvidence !== undefined ? { maxEvidence: options.maxEvidence } : {}), + ...(options.maxRoutes !== undefined ? { maxRoutes: options.maxRoutes } : {}), + ...(options.maxGinRequestInputForwardLines !== undefined ? { maxForwardLines: options.maxGinRequestInputForwardLines } : {}), + }); + const koaRequestInputFlows = await buildKoaRouteRequestInputFlowContexts(rootPath, routeFlows, callGraph, { + maxFiles, + ...(options.maxEvidence !== undefined ? { maxEvidence: options.maxEvidence } : {}), + ...(options.maxRoutes !== undefined ? { maxRoutes: options.maxRoutes } : {}), + }); + const koaRequestInputForwardingFlows = await buildKoaRouteRequestInputForwardingContexts(rootPath, routeFlows, callGraph, { + maxFiles, + ...(options.maxEvidence !== undefined ? { maxEvidence: options.maxEvidence } : {}), + ...(options.maxRoutes !== undefined ? { maxRoutes: options.maxRoutes } : {}), + ...(options.maxKoaRequestInputForwardLines !== undefined ? { maxForwardLines: options.maxKoaRequestInputForwardLines } : {}), + }); + const requestInputFlows = repositoryRouteRequestInputFlowContexts(index, requestInputs, entrypoints, callGraph, { + importCallLinks, + maxCallNodes, + ...(options.maxEvidence !== undefined ? { maxEvidence: options.maxEvidence } : {}), + ...(options.maxRoutes !== undefined ? { maxRoutes: options.maxRoutes } : {}), + }); + const requestInputForwardingFlows = await repositoryRouteRequestInputForwardingContexts(rootPath, safe.files, requestInputs, routeFlows, callGraph, importCallLinks, { + maxCallNodes, + ...(options.maxEvidence !== undefined ? { maxEvidence: options.maxEvidence } : {}), + ...(options.maxRoutes !== undefined ? { maxRoutes: options.maxRoutes } : {}), + ...(options.maxRequestInputForwardLines !== undefined ? { maxForwardLines: options.maxRequestInputForwardLines } : {}), + }); + const requestInputReturnFlows = await repositoryRouteRequestInputReturnFlowContexts(rootPath, safe.files, requestInputs, routeFlows, callGraph, importCallLinks, { + maxCallNodes, + ...(options.maxEvidence !== undefined ? { maxEvidence: options.maxEvidence } : {}), + ...(options.maxRoutes !== undefined ? { maxRoutes: options.maxRoutes } : {}), + ...(options.maxRequestInputReturnForwardLines !== undefined ? { maxForwardLines: options.maxRequestInputReturnForwardLines } : {}), + }); + const routeProtectionContexts = repositoryRouteProtectionContexts(index, entrypoints, callGraph, protectionOptions); + const routeSecurityReviews = buildRouteSecurityReviewContexts(routeFlows, routeProtectionContexts, options.maxRoutes ?? 1_000); + + return { + callGraph, + importCallLinks, + requestInputs, + entrypoints, + routeMiddlewareContexts, + ginMiddlewareContexts, + ginRequestInputFlows, + ginRequestInputForwardingFlows, + koaMiddlewareContexts, + koaRequestInputFlows, + koaRequestInputForwardingFlows, + fastApiDependencyContexts, + nestJsGuardContexts, + routeFlows, + requestInputFlows, + requestInputForwardingFlows, + requestInputReturnFlows, + routeProtectionContexts, + routeSecurityReviews, + inputFileCount: files.length, + analyzedFileCount: safe.files.length, + skippedUnsafeFileCount: safe.skippedUnsafeFileCount, + truncatedFileCount: safe.truncatedFileCount, + coverage: safe.truncatedFileCount === 0 ? "complete-input" : "bounded-input", + interpretation: "repository-structural-route-flow-evidence-only", + }; +} diff --git a/packages/repository/src/route-middleware-composition.ts b/packages/repository/src/route-middleware-composition.ts new file mode 100644 index 00000000..59f79abc --- /dev/null +++ b/packages/repository/src/route-middleware-composition.ts @@ -0,0 +1,298 @@ +import { lstat, readFile } from "node:fs/promises"; +import { isAbsolute, relative, resolve, sep } from "node:path"; +import type { AuthSignal, IndexFileInput, RepositoryIndex, RouteSignal } from "./analysis.js"; +import type { CallGraph, CallGraphNode } from "./call-graph.js"; +import type { ImportCallLinkGraph } from "./import-call-links.js"; +import type { ModuleGraph, ResolvedModuleEdge } from "./module-graph.js"; +import type { RouteEntrypoint } from "./route-entrypoints.js"; + +const MAX_SOURCE_BYTES = 512_000; +const MAX_MIDDLEWARE_PER_ROUTE = 16; +const MAX_REACHABLE_NODES = 100; +const MAX_REACHABILITY_DEPTH = 3; + +export type RouteMiddlewareResolution = "same-file-function" | "imported-named-function" | "unresolved"; + +export interface RouteMiddlewareAuthEvidence { + path: string; + line: number; + kind: AuthSignal["kind"]; + middleware: string; + functionName: string; + depth: number; +} + +export interface RouteMiddlewareBinding { + name: string; + position: number; + resolution: RouteMiddlewareResolution; + node?: { + id: string; + name: string; + path: string; + line: number; + endLine: number; + }; +} + +export interface RouteMiddlewareCompositionContext { + route: RouteSignal; + handler: string; + middleware: RouteMiddlewareBinding[]; + authEvidence: RouteMiddlewareAuthEvidence[]; + status: "authorization-signal-observed" | "authentication-signal-observed" | "no-auth-signal-observed"; + callScope: "middleware-function-only" | "middleware-and-bounded-callees"; + /** Explicit named middleware composition is static structural evidence, not proof that middleware executes or protects the route. */ + interpretation: "structural-route-middleware-evidence-not-runtime-protection"; +} + +interface ImportBinding { + localName: string; + importedName: string; + edge: ResolvedModuleEdge; +} + +function normalizePath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function insideRoot(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +async function safeReadSource(rootPath: string, file: IndexFileInput): Promise { + if (!file.path || file.path.includes("\0") || isAbsolute(file.path)) return undefined; + const root = resolve(rootPath); + const candidate = resolve(root, file.path); + if (!insideRoot(root, candidate)) return undefined; + const info = await lstat(candidate).catch(() => undefined); + if (!info?.isFile() || info.isSymbolicLink() || info.size > MAX_SOURCE_BYTES) return undefined; + const content = await readFile(candidate, "utf8").catch(() => undefined); + return content === undefined || content.includes("\u0000") ? undefined : content; +} + +function parseNamedRouteArguments(line: string, route: RouteSignal): string[] | undefined { + if (route.frameworkHint !== "Node HTTP router" || route.method === "USE") return undefined; + const registration = line.match( + /\b(?:app|router|server)\.(?:get|post|put|patch|delete|options|head)\s*\(\s*(?:"[^"]*"|'[^']*'|`[^`]*`)\s*,\s*(.*?)\s*\)\s*;?\s*(?:\/\/.*)?$/i, + ); + const args = registration?.[1]?.trim(); + if (!args || !/^[A-Za-z_$][\w$]*(?:\s*,\s*[A-Za-z_$][\w$]*)+$/.test(args)) return undefined; + const names = args.split(",").map((value) => value.trim()); + return names.length >= 2 ? names : undefined; +} + +function parseNamedImport(line: string, edge: ResolvedModuleEdge): ImportBinding[] { + const match = line.match(/^\s*import\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/); + if (!match?.[1] || match[2] !== edge.specifier) return []; + const output: ImportBinding[] = []; + for (const raw of match[1].split(",")) { + const part = raw.trim().replace(/^type\s+/, ""); + const binding = part.match(/^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/); + const importedName = binding?.[1]; + if (importedName) output.push({ importedName, localName: binding?.[2] ?? importedName, edge }); + } + return output; +} + +function parseDestructuredRequire(line: string, edge: ResolvedModuleEdge): ImportBinding[] { + const match = line.match(/^\s*(?:const|let|var)\s*\{([^}]*)\}\s*=\s*require\s*\(\s*["']([^"']+)["']\s*\)/); + if (!match?.[1] || match[2] !== edge.specifier) return []; + const output: ImportBinding[] = []; + for (const raw of match[1].split(",")) { + const part = raw.trim(); + const binding = part.match(/^([A-Za-z_$][\w$]*)(?:\s*:\s*([A-Za-z_$][\w$]*))?$/); + const importedName = binding?.[1]; + if (importedName) output.push({ importedName, localName: binding?.[2] ?? importedName, edge }); + } + return output; +} + +function escapeIdentifier(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function bindingShadowedBeforeRoute(content: string, binding: ImportBinding, routeLine: number): boolean { + if (routeLine <= binding.edge.line) return true; + const escaped = escapeIdentifier(binding.localName); + const lines = content.split(/\r?\n/).slice(binding.edge.line, routeLine - 1); + const declaration = new RegExp(`\\b(?:const|let|var|function|class)\\s+${escaped}\\b`); + const assignment = new RegExp(`(^|[^.\\w$])${escaped}\\s*=(?!=)`); + const parameter = new RegExp(`\\([^)]*\\b${escaped}\\b[^)]*\\)\\s*(?:=>|\\{)`); + return lines.some((line) => declaration.test(line) || assignment.test(line) || parameter.test(line)); +} + +function hasNamedExportEvidence(content: string, binding: ImportBinding): boolean { + const escaped = escapeIdentifier(binding.importedName); + if (binding.edge.kind === "import") { + return new RegExp(`(^|\\n)\\s*export\\s+(?:async\\s+)?function\\s+${escaped}\\b`).test(content) + || new RegExp(`(^|\\n)\\s*export\\s+(?:const|let|var|class)\\s+${escaped}\\b`).test(content) + || new RegExp(`(^|\\n)\\s*export\\s*\\{[^}]*\\b${escaped}\\b(?:\\s*,|\\s*\\})`).test(content); + } + if (binding.edge.kind === "require") { + return new RegExp(`(^|\\n)\\s*(?:module\\.)?exports\\.${escaped}\\s*=\\s*${escaped}\\b`).test(content) + || new RegExp(`(^|\\n)\\s*module\\.exports\\s*=\\s*\\{[^}]*\\b${escaped}\\b(?:\\s*[:,}]|\\s*,)`).test(content); + } + return false; +} + +function localNode(graph: CallGraph, routePath: string, name: string): CallGraphNode | undefined { + const matches = graph.nodes.filter((node) => normalizePath(node.path) === routePath && node.name === name); + return matches.length === 1 ? matches[0] : undefined; +} + +async function resolveImportedNode( + routePath: string, + routeLine: number, + name: string, + content: string, + sourceFor: (path: string) => Promise, + moduleGraph: ModuleGraph, + graph: CallGraph, +): Promise { + const lines = content.split(/\r?\n/); + const bindings: ImportBinding[] = []; + for (const edge of moduleGraph.edges) { + if (normalizePath(edge.from) !== routePath || edge.resolution !== "repository-file" || !edge.target) continue; + if (edge.kind !== "import" && edge.kind !== "require") continue; + const sourceLine = lines[edge.line - 1] ?? ""; + const parsed = edge.kind === "import" ? parseNamedImport(sourceLine, edge) : parseDestructuredRequire(sourceLine, edge); + for (const binding of parsed) { + if (binding.localName === name && !bindingShadowedBeforeRoute(content, binding, routeLine)) bindings.push(binding); + } + } + const candidates: CallGraphNode[] = []; + for (const binding of bindings) { + const target = binding.edge.target; + if (!target) continue; + const matches = graph.nodes.filter( + (node) => normalizePath(node.path) === normalizePath(target) && node.name === binding.importedName, + ); + if (matches.length !== 1) continue; + const targetSource = await sourceFor(target); + if (targetSource !== undefined && hasNamedExportEvidence(targetSource, binding)) candidates.push(matches[0]!); + } + const distinct = [...new Map(candidates.map((node) => [node.id, node])).values()]; + return distinct.length === 1 ? distinct[0] : undefined; +} + +function reachableNodes( + root: CallGraphNode, + graph: CallGraph, + importCallLinks: ImportCallLinkGraph, + maxDepth: number, + maxNodes: number, +): Map { + const depths = new Map([[root.id, 0]]); + const queue: Array<{ id: string; depth: number }> = [{ id: root.id, depth: 0 }]; + while (queue.length > 0 && depths.size < maxNodes) { + const current = queue.shift(); + if (!current || current.depth >= maxDepth) continue; + const sameFile = graph.edges.flatMap((edge) => edge.from === current.id && edge.target ? [edge.target] : []); + const imported = importCallLinks.links.flatMap((link) => link.from === current.id ? [link.target] : []); + for (const id of [...new Set([...sameFile, ...imported])].sort()) { + if (depths.has(id)) continue; + depths.set(id, current.depth + 1); + queue.push({ id, depth: current.depth + 1 }); + if (depths.size >= maxNodes) break; + } + } + return depths; +} + +function statusFromEvidence(evidence: readonly RouteMiddlewareAuthEvidence[]): RouteMiddlewareCompositionContext["status"] { + if (evidence.some((item) => item.kind === "authorization")) return "authorization-signal-observed"; + if (evidence.length > 0) return "authentication-signal-observed"; + return "no-auth-signal-observed"; +} + +function sameRoute(a: RouteSignal, b: RouteSignal): boolean { + return normalizePath(a.path) === normalizePath(b.path) && a.line === b.line && a.method === b.method && a.route === b.route; +} + +export async function buildRouteMiddlewareCompositionContexts( + rootPath: string, + files: readonly IndexFileInput[], + index: RepositoryIndex, + moduleGraph: ModuleGraph, + graph: CallGraph, + importCallLinks: ImportCallLinkGraph, + entrypoints: readonly RouteEntrypoint[], + options: { maxRoutes?: number; maxCallDepth?: number; maxCallNodes?: number } = {}, +): Promise { + const fileByPath = new Map(files.map((file) => [normalizePath(file.path), file])); + const sourceCache = new Map(); + const maxRoutes = Math.max(0, Math.min(5_000, options.maxRoutes ?? 1_000)); + const maxDepth = Math.max(0, Math.min(MAX_REACHABILITY_DEPTH, options.maxCallDepth ?? 2)); + const maxNodes = Math.max(1, Math.min(MAX_REACHABLE_NODES, options.maxCallNodes ?? 50)); + + async function sourceFor(path: string): Promise { + const normalized = normalizePath(path); + if (sourceCache.has(normalized)) return sourceCache.get(normalized); + const file = fileByPath.get(normalized); + const content = file ? await safeReadSource(rootPath, file) : undefined; + sourceCache.set(normalized, content); + return content; + } + + const output: RouteMiddlewareCompositionContext[] = []; + for (const entrypoint of entrypoints.slice(0, maxRoutes)) { + const route = entrypoint.route; + if (route.frameworkHint !== "Node HTTP router") continue; + const routePath = normalizePath(route.path); + const content = await sourceFor(routePath); + if (content === undefined) continue; + const line = content.split(/\r?\n/)[route.line - 1] ?? ""; + const names = parseNamedRouteArguments(line, route); + if (!names) continue; + const handler = names.at(-1); + if (!handler || (route.handler && route.handler !== handler)) continue; + const middlewareNames = names.slice(0, -1,).slice(0, MAX_MIDDLEWARE_PER_ROUTE); + if (middlewareNames.length === 0) continue; + + const middleware: RouteMiddlewareBinding[] = []; + const evidence: RouteMiddlewareAuthEvidence[] = []; + let usedCallees = false; + for (let position = 0; position < middlewareNames.length; position += 1) { + const name = middlewareNames[position]!; + const local = localNode(graph, routePath, name); + const imported = local ? undefined : await resolveImportedNode(routePath, route.line, name, content, sourceFor, moduleGraph, graph); + const node = local ?? imported; + middleware.push({ + name, + position, + resolution: local ? "same-file-function" : imported ? "imported-named-function" : "unresolved", + ...(node ? { node: { id: node.id, name: node.name, path: node.path, line: node.line, endLine: node.endLine } } : {}), + }); + if (!node) continue; + const depths = reachableNodes(node, graph, importCallLinks, maxDepth, maxNodes); + if ([...depths.values()].some((depth) => depth > 0)) usedCallees = true; + for (const signal of index.authSignals) { + const owners = graph.nodes.filter((candidate) => { + if (!depths.has(candidate.id) || normalizePath(candidate.path) !== normalizePath(signal.path)) return false; + return signal.line >= candidate.line && signal.line <= candidate.endLine; + }); + if (owners.length !== 1) continue; + const owner = owners[0]!; + const depth = depths.get(owner.id); + if (depth === undefined) continue; + evidence.push({ path: signal.path, line: signal.line, kind: signal.kind, middleware: name, functionName: owner.name, depth }); + } + } + + const uniqueEvidence = [...new Map(evidence.map((item) => [`${item.path}:${item.line}:${item.kind}:${item.middleware}:${item.functionName}`, item])).values()] + .sort((a, b) => a.depth - b.depth || a.path.localeCompare(b.path) || a.line - b.line); + output.push({ + route, + handler, + middleware, + authEvidence: uniqueEvidence, + status: statusFromEvidence(uniqueEvidence), + callScope: usedCallees ? "middleware-and-bounded-callees" : "middleware-function-only", + interpretation: "structural-route-middleware-evidence-not-runtime-protection", + }); + } + + return output.filter((context) => entrypoints.some((entrypoint) => sameRoute(entrypoint.route, context.route))); +} diff --git a/packages/repository/src/route-protection-context.ts b/packages/repository/src/route-protection-context.ts new file mode 100644 index 00000000..2ffd95d7 --- /dev/null +++ b/packages/repository/src/route-protection-context.ts @@ -0,0 +1,248 @@ +import type { AuthSignal, RepositoryIndex, RouteSignal } from "./analysis.js"; +import type { CallGraph, CallGraphNode } from "./call-graph.js"; +import type { ImportCallLinkGraph } from "./import-call-links.js"; +import type { RouteEntrypoint, RouteEntrypointResolution } from "./route-entrypoints.js"; +import type { RouteSinkFlowContext } from "./route-sink-flow.js"; + +export type RouteProtectionStatus = + | "authorization-signal-observed" + | "authentication-signal-observed" + | "no-auth-signal-observed"; + +export interface RouteProtectionEvidence { + path: string; + line: number; + kind: AuthSignal["kind"]; + source: "route-registration" | "reachable-function"; + functionName?: string; + depth?: number; +} + +export interface RouteProtectionContext { + route: RouteSignal; + resolution: Exclude; + handler: { + id: string; + name: string; + path: string; + line: number; + endLine: number; + }; + status: RouteProtectionStatus; + evidence: RouteProtectionEvidence[]; + callScope: "same-file" | "same-file-and-explicit-imports"; + /** Static auth-related tokens are review context only and do not prove effective route protection. */ + interpretation: "structural-auth-signals-not-protection-proof"; +} + +export interface FindingRouteProtectionEvidence { + method: string; + route: string; + frameworkHint?: string; + resolution: Exclude; + handler: string; + status: RouteProtectionStatus; + evidenceKinds: AuthSignal["kind"][]; + callScope: "same-file" | "same-file-and-explicit-imports"; + /** Correlated structural evidence only; never an authorization or exploitability verdict. */ + interpretation: "structural-auth-signals-not-protection-proof"; +} + +export interface RouteProtectionOptions { + maxEvidence?: number; + maxRoutes?: number; + maxCallNodes?: number; + importCallLinks?: ImportCallLinkGraph; +} + +function normalizePath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, "").replace(/^\//, "").toLowerCase(); +} + +function authPriority(kind: AuthSignal["kind"]): number { + if (kind === "authorization") return 4; + if (kind === "authentication") return 3; + if (kind === "token") return 2; + return 1; +} + +function statusFromEvidence(evidence: readonly RouteProtectionEvidence[]): RouteProtectionStatus { + if (evidence.some((item) => item.kind === "authorization")) return "authorization-signal-observed"; + if (evidence.length > 0) return "authentication-signal-observed"; + return "no-auth-signal-observed"; +} + +function reachableDepths( + entrypoint: RouteEntrypoint, + graph: CallGraph, + importCallLinks: ImportCallLinkGraph | undefined, + maxCallNodes: number, +): { depths: Map; usedImportLink: boolean } { + const depths = new Map(); + if (!entrypoint.handler || entrypoint.resolution === "unresolved") return { depths, usedImportLink: false }; + + const maxDepth = Math.max(0, entrypoint.calls?.maxDepth ?? 0); + const nodeLimit = Math.max(1, Math.min(1_000, maxCallNodes)); + const queue: Array<{ id: string; depth: number }> = [{ id: entrypoint.handler.id, depth: 0 }]; + depths.set(entrypoint.handler.id, 0); + let usedImportLink = false; + + while (queue.length > 0 && depths.size < nodeLimit) { + const current = queue.shift(); + if (!current || current.depth >= maxDepth) continue; + const sameFileTargets = graph.edges.flatMap((edge) => edge.from === current.id && edge.target ? [edge.target] : []); + const importedTargets = importCallLinks?.links.flatMap((link) => link.from === current.id ? [link.target] : []) ?? []; + const importedSet = new Set(importedTargets); + const adjacent = [...new Set([...sameFileTargets, ...importedTargets])].sort(); + for (const id of adjacent) { + if (depths.has(id)) continue; + const nextDepth = current.depth + 1; + depths.set(id, nextDepth); + if (importedSet.has(id)) usedImportLink = true; + queue.push({ id, depth: nextDepth }); + if (depths.size >= nodeLimit) break; + } + } + + return { depths, usedImportLink }; +} + +function owningReachableFunction( + graph: CallGraph, + depths: ReadonlyMap, + signal: AuthSignal, +): { node: CallGraphNode; depth: number } | undefined { + const path = normalizePath(signal.path); + const candidates = graph.nodes.filter((node) => { + if (!depths.has(node.id) || normalizePath(node.path) !== path) return false; + return signal.line >= node.line && signal.line <= node.endLine; + }); + if (candidates.length !== 1) return undefined; + const node = candidates[0]; + if (!node) return undefined; + const depth = depths.get(node.id); + return depth === undefined ? undefined : { node, depth }; +} + +/** + * Correlate one resolved route with auth-related lexical signals at its registration and inside its + * bounded call neighborhood. This is deliberately not a protection verdict: middleware may be + * ineffective, branches may bypass checks, and auth-related names may be unrelated to enforcement. + */ +export function routeProtectionContext( + index: RepositoryIndex, + entrypoint: RouteEntrypoint, + graph: CallGraph, + options: RouteProtectionOptions = {}, +): RouteProtectionContext | undefined { + if (!entrypoint.handler || entrypoint.resolution === "unresolved") return undefined; + const maxEvidence = Math.max(1, Math.min(50, options.maxEvidence ?? 12)); + const reachability = reachableDepths(entrypoint, graph, options.importCallLinks, options.maxCallNodes ?? 100); + const routePath = normalizePath(entrypoint.route.path); + + const registrationEvidence = index.authSignals + .filter((signal) => normalizePath(signal.path) === routePath && signal.line === entrypoint.route.line) + .map((signal): RouteProtectionEvidence => ({ + path: signal.path, + line: signal.line, + kind: signal.kind, + source: "route-registration", + })); + + const reachableEvidence = index.authSignals + .map((signal): RouteProtectionEvidence | undefined => { + const owner = owningReachableFunction(graph, reachability.depths, signal); + if (!owner) return undefined; + return { + path: signal.path, + line: signal.line, + kind: signal.kind, + source: "reachable-function", + functionName: owner.node.name, + depth: owner.depth, + }; + }) + .filter((value): value is RouteProtectionEvidence => Boolean(value)); + + const evidence = [...registrationEvidence, ...reachableEvidence] + .sort((a, b) => authPriority(b.kind) - authPriority(a.kind) + || (a.depth ?? -1) - (b.depth ?? -1) + || a.path.localeCompare(b.path) + || a.line - b.line) + .slice(0, maxEvidence); + + return { + route: entrypoint.route, + resolution: entrypoint.resolution, + handler: { + id: entrypoint.handler.id, + name: entrypoint.handler.name, + path: entrypoint.handler.path, + line: entrypoint.handler.line, + endLine: entrypoint.handler.endLine, + }, + status: statusFromEvidence(evidence), + evidence, + callScope: reachability.usedImportLink ? "same-file-and-explicit-imports" : "same-file", + interpretation: "structural-auth-signals-not-protection-proof", + }; +} + +export function repositoryRouteProtectionContexts( + index: RepositoryIndex, + entrypoints: readonly RouteEntrypoint[], + graph: CallGraph, + options: RouteProtectionOptions = {}, +): RouteProtectionContext[] { + const maxRoutes = Math.max(0, Math.min(5_000, options.maxRoutes ?? 1_000)); + const output: RouteProtectionContext[] = []; + for (const entrypoint of entrypoints.slice(0, maxRoutes)) { + const context = routeProtectionContext(index, entrypoint, graph, options); + if (context) output.push(context); + } + return output; +} + +function sameRoute(a: RouteSignal, b: RouteSignal): boolean { + return normalizePath(a.path) === normalizePath(b.path) + && a.line === b.line + && a.method === b.method + && a.route === b.route; +} + +/** + * Return minimized route-protection context only for routes whose structural sink flow exactly + * matches the finding location. Source lines and raw auth evidence are intentionally omitted. + */ +export function findingRouteProtectionEvidence( + protections: readonly RouteProtectionContext[], + routeFlows: readonly RouteSinkFlowContext[], + path: string, + line: number | undefined, + maxRoutes = 3, +): FindingRouteProtectionEvidence[] { + if (!Number.isSafeInteger(line) || (line ?? 0) <= 0) return []; + const normalized = normalizePath(path); + const limit = Math.max(1, Math.min(10, maxRoutes)); + const output: FindingRouteProtectionEvidence[] = []; + + for (const flow of routeFlows) { + const sinkMatch = flow.evidence.some((item) => normalizePath(item.path) === normalized && item.line === line); + if (!sinkMatch) continue; + const protection = protections.find((item) => sameRoute(item.route, flow.route) && item.handler.id === flow.handler.id); + if (!protection) continue; + output.push({ + method: protection.route.method, + route: protection.route.route, + ...(protection.route.frameworkHint ? { frameworkHint: protection.route.frameworkHint } : {}), + resolution: protection.resolution, + handler: protection.handler.name, + status: protection.status, + evidenceKinds: [...new Set(protection.evidence.map((item) => item.kind))], + callScope: protection.callScope, + interpretation: "structural-auth-signals-not-protection-proof", + }); + if (output.length >= limit) break; + } + return output; +} diff --git a/packages/repository/src/route-security-review.ts b/packages/repository/src/route-security-review.ts new file mode 100644 index 00000000..59a843dd --- /dev/null +++ b/packages/repository/src/route-security-review.ts @@ -0,0 +1,189 @@ +import type { RouteProtectionContext, RouteProtectionStatus } from "./route-protection-context.js"; +import type { RouteSinkFlowContext } from "./route-sink-flow.js"; + +export type RouteSecurityReviewSignal = + | "sensitive-sink-with-authorization-signal" + | "sensitive-sink-with-authentication-signal" + | "sensitive-sink-without-auth-signal" + | "sensitive-sink-auth-context-unavailable"; + +export interface RouteSecurityReviewContext { + method: string; + route: string; + frameworkHint?: string; + handler: string; + sinkKinds: RouteSinkFlowContext["kinds"]; + protectionStatus: RouteProtectionStatus | "not-assessed"; + signal: RouteSecurityReviewSignal; + callScope: "same-file" | "same-file-and-explicit-imports"; + /** Aggregate structural review context only; never runtime reachability, protection, or exploitability proof. */ + interpretation: "structural-route-security-review-context-only"; +} + +export interface RouteSecurityReviewSummary { + total: number; + needsAuthReview: number; + signals: Record; + sinkKinds: Record; + /** Aggregate counts derived from validated structural contexts; never a vulnerability or protection verdict. */ + interpretation: "aggregate-structural-route-security-review-only"; +} + +const REVIEW_SIGNALS = new Set([ + "sensitive-sink-with-authorization-signal", + "sensitive-sink-with-authentication-signal", + "sensitive-sink-without-auth-signal", + "sensitive-sink-auth-context-unavailable", +]); +const PROTECTION_STATUSES = new Set([ + "authorization-signal-observed", + "authentication-signal-observed", + "no-auth-signal-observed", + "not-assessed", +]); +const SINK_KINDS = new Set([ + "process", + "database", + "filesystem", + "network", +]); + +function normalizePath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, "").replace(/^\//, "").toLowerCase(); +} + +function sameRoute(flow: RouteSinkFlowContext, protection: RouteProtectionContext): boolean { + return normalizePath(flow.route.path) === normalizePath(protection.route.path) + && flow.route.line === protection.route.line + && flow.route.method === protection.route.method + && flow.route.route === protection.route.route + && flow.handler.id === protection.handler.id; +} + +function signalFor(status: RouteProtectionStatus | "not-assessed"): RouteSecurityReviewSignal { + if (status === "authorization-signal-observed") return "sensitive-sink-with-authorization-signal"; + if (status === "authentication-signal-observed") return "sensitive-sink-with-authentication-signal"; + if (status === "no-auth-signal-observed") return "sensitive-sink-without-auth-signal"; + return "sensitive-sink-auth-context-unavailable"; +} + +/** + * Join already-resolved route-to-sink and route-protection contexts into a minimized route-level + * review surface. A protection context is accepted only when exactly one structural record matches + * the same route and handler; duplicate or missing matches become `not-assessed` rather than being + * guessed. Routes without linked sink evidence are omitted. + */ +export function buildRouteSecurityReviewContexts( + routeFlows: readonly RouteSinkFlowContext[], + protections: readonly RouteProtectionContext[], + maxRoutes = 1_000, +): RouteSecurityReviewContext[] { + if (!Number.isSafeInteger(maxRoutes) || maxRoutes < 0 || maxRoutes > 5_000) { + throw new Error("Route security review maxRoutes must be an integer between 0 and 5000."); + } + + const output: RouteSecurityReviewContext[] = []; + for (const flow of routeFlows) { + if (output.length >= maxRoutes) break; + if (flow.evidence.length === 0 || flow.kinds.length === 0) continue; + + const matches = protections.filter((protection) => sameRoute(flow, protection)); + const protectionStatus = matches.length === 1 && matches[0] + ? matches[0].status + : "not-assessed"; + + output.push({ + method: flow.route.method, + route: flow.route.route, + ...(flow.route.frameworkHint ? { frameworkHint: flow.route.frameworkHint } : {}), + handler: flow.handler.name, + sinkKinds: [...flow.kinds], + protectionStatus, + signal: signalFor(protectionStatus), + callScope: flow.callScope, + interpretation: "structural-route-security-review-context-only", + }); + } + + return output; +} + +function validBoundedLabel(value: unknown): value is string { + return typeof value === "string" + && value.length > 0 + && value.length <= 1_024 + && !/[\u0000-\u001f\u007f]/.test(value); +} + +/** + * Derive a disclosure-minimized aggregate from route-security contexts while treating the supplied + * records as untrusted runtime data. Signal/status mismatches, unknown sink kinds, duplicate kinds, + * invalid labels, unsupported call scopes, and oversized collections fail closed instead of being + * counted. The summary never copies route names, handler names, paths, framework hints, or evidence. + */ +export function summarizeRouteSecurityReviews( + contexts: readonly RouteSecurityReviewContext[], +): RouteSecurityReviewSummary { + if (!Array.isArray(contexts) || contexts.length > 5_000) { + throw new Error("Route security review summary accepts at most 5000 contexts."); + } + + const signals: RouteSecurityReviewSummary["signals"] = { + "sensitive-sink-with-authorization-signal": 0, + "sensitive-sink-with-authentication-signal": 0, + "sensitive-sink-without-auth-signal": 0, + "sensitive-sink-auth-context-unavailable": 0, + }; + const sinkKinds: RouteSecurityReviewSummary["sinkKinds"] = { + process: 0, + database: 0, + filesystem: 0, + network: 0, + }; + + for (const context of contexts) { + if (!context || typeof context !== "object") { + throw new Error("Route security review summary received an invalid context."); + } + if (!validBoundedLabel(context.method) || !validBoundedLabel(context.route) || !validBoundedLabel(context.handler)) { + throw new Error("Route security review summary received invalid route identity metadata."); + } + if (context.frameworkHint !== undefined && !validBoundedLabel(context.frameworkHint)) { + throw new Error("Route security review summary received invalid framework metadata."); + } + const protectionStatus = context.protectionStatus as RouteProtectionStatus | "not-assessed"; + const signal = context.signal as RouteSecurityReviewSignal; + if (!PROTECTION_STATUSES.has(protectionStatus) + || !REVIEW_SIGNALS.has(signal) + || signalFor(protectionStatus) !== signal) { + throw new Error("Route security review summary received inconsistent protection metadata."); + } + if (context.callScope !== "same-file" && context.callScope !== "same-file-and-explicit-imports") { + throw new Error("Route security review summary received an invalid call scope."); + } + if (context.interpretation !== "structural-route-security-review-context-only") { + throw new Error("Route security review summary received an unsupported interpretation."); + } + if (!Array.isArray(context.sinkKinds) || context.sinkKinds.length < 1 || context.sinkKinds.length > SINK_KINDS.size) { + throw new Error("Route security review summary received invalid sink metadata."); + } + const sinkKindValues = context.sinkKinds as RouteSinkFlowContext["kinds"][number][]; + const uniqueKinds = new Set(sinkKindValues); + if (uniqueKinds.size !== sinkKindValues.length || [...uniqueKinds].some((kind) => !SINK_KINDS.has(kind))) { + throw new Error("Route security review summary received invalid sink metadata."); + } + + signals[signal] += 1; + for (const kind of uniqueKinds) sinkKinds[kind] += 1; + } + + return { + total: contexts.length, + needsAuthReview: + signals["sensitive-sink-without-auth-signal"] + + signals["sensitive-sink-auth-context-unavailable"], + signals, + sinkKinds, + interpretation: "aggregate-structural-route-security-review-only", + }; +} diff --git a/packages/repository/src/route-sink-context.ts b/packages/repository/src/route-sink-context.ts new file mode 100644 index 00000000..474bb984 --- /dev/null +++ b/packages/repository/src/route-sink-context.ts @@ -0,0 +1,73 @@ +import type { RepositoryIndex, RouteSignal, SinkSignal } from "./analysis.js"; + +export interface RouteSinkEvidence { + line: number; + distance: number; + kind: SinkSignal["kind"]; +} + +export interface RouteSinkContext { + route: RouteSignal; + evidence: RouteSinkEvidence[]; + kinds: SinkSignal["kind"][]; + radius: number; + /** Lexical proximity is review evidence only, not proof of call/data-flow reachability. */ + interpretation: "lexical-sink-signals-only"; +} + +function normalizePath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, "").replace(/^\//, "").toLowerCase(); +} + +function sinkPriority(kind: SinkSignal["kind"]): number { + if (kind === "process") return 4; + if (kind === "database") return 3; + if (kind === "filesystem") return 2; + return 1; +} + +export function routeSinkContext( + index: RepositoryIndex, + route: RouteSignal, + options: { radius?: number; maxEvidence?: number } = {}, +): RouteSinkContext { + const radius = Math.max(0, Math.min(500, options.radius ?? 80)); + const maxEvidence = Math.max(1, Math.min(20, options.maxEvidence ?? 8)); + const routePath = normalizePath(route.path); + + const evidence = index.sinks + .filter((signal) => normalizePath(signal.path) === routePath) + .map((signal): RouteSinkEvidence => ({ + line: signal.line, + distance: Math.abs(signal.line - route.line), + kind: signal.kind, + })) + .filter((signal) => signal.distance <= radius) + .sort((a, b) => { + const distance = a.distance - b.distance; + if (distance !== 0) return distance; + const priority = sinkPriority(b.kind) - sinkPriority(a.kind); + if (priority !== 0) return priority; + return a.line - b.line; + }) + .slice(0, maxEvidence); + + const kinds = [...new Set(evidence.map((signal) => signal.kind))]; + return { + route, + evidence, + kinds, + radius, + interpretation: "lexical-sink-signals-only", + }; +} + +export function repositoryRouteSinkContexts( + index: RepositoryIndex, + options: { radius?: number; maxEvidence?: number; maxRoutes?: number } = {}, +): RouteSinkContext[] { + const maxRoutes = Math.max(0, Math.min(5_000, options.maxRoutes ?? 1_000)); + return index.routes + .slice(0, maxRoutes) + .map((route) => routeSinkContext(index, route, options)); +} diff --git a/packages/repository/src/route-sink-flow.ts b/packages/repository/src/route-sink-flow.ts new file mode 100644 index 00000000..48b5a89a --- /dev/null +++ b/packages/repository/src/route-sink-flow.ts @@ -0,0 +1,225 @@ +import type { RepositoryIndex, RouteSignal, SinkSignal } from "./analysis.js"; +import type { CallGraph, CallGraphNode } from "./call-graph.js"; +import type { ImportCallLinkGraph } from "./import-call-links.js"; +import type { RouteEntrypoint, RouteEntrypointResolution } from "./route-entrypoints.js"; + +export interface RouteSinkFlowEvidence { + path: string; + line: number; + kind: SinkSignal["kind"]; + functionId: string; + functionName: string; + depth: number; +} + +export interface RouteSinkFlowContext { + route: RouteSignal; + resolution: Exclude; + handler: { + id: string; + name: string; + path: string; + line: number; + endLine: number; + }; + evidence: RouteSinkFlowEvidence[]; + kinds: SinkSignal["kind"][]; + maxDepth: number; + callScope: "same-file" | "same-file-and-explicit-imports"; + /** Static route/function/call/sink linkage is review evidence, not runtime or attacker reachability proof. */ + interpretation: "structural-route-call-sink-evidence-only"; +} + +export interface FindingRouteSinkFlowEvidence { + method: string; + route: string; + frameworkHint?: string; + resolution: Exclude; + handler: string; + sinkKind: SinkSignal["kind"]; + functionName: string; + depth: number; + callScope: "same-file" | "same-file-and-explicit-imports"; + /** Exact-line structural linkage only; never runtime or attacker reachability proof. */ + interpretation: "structural-route-call-sink-evidence-only"; +} + +export interface RouteSinkFlowOptions { + maxEvidence?: number; + maxRoutes?: number; + maxCallNodes?: number; + importCallLinks?: ImportCallLinkGraph; +} + +function normalizePath(value: string): string { + return value.replaceAll("\\", "/").replace(/^\.\//, "").replace(/^\//, "").toLowerCase(); +} + +function sinkPriority(kind: SinkSignal["kind"]): number { + if (kind === "process") return 4; + if (kind === "database") return 3; + if (kind === "filesystem") return 2; + return 1; +} + +function reachableDepths( + entrypoint: RouteEntrypoint, + graph: CallGraph, + importCallLinks: ImportCallLinkGraph | undefined, + maxCallNodes: number, +): { depths: Map; usedImportLink: boolean } { + const depths = new Map(); + if (!entrypoint.handler || entrypoint.resolution === "unresolved") return { depths, usedImportLink: false }; + + if (!importCallLinks) { + depths.set(entrypoint.handler.id, 0); + for (const callee of entrypoint.calls?.callees ?? []) { + const current = depths.get(callee.id); + if (current === undefined || callee.depth < current) depths.set(callee.id, callee.depth); + } + return { depths, usedImportLink: false }; + } + + const maxDepth = Math.max(0, entrypoint.calls?.maxDepth ?? 0); + const nodeLimit = Math.max(1, Math.min(1_000, maxCallNodes)); + const queue: Array<{ id: string; depth: number }> = [{ id: entrypoint.handler.id, depth: 0 }]; + depths.set(entrypoint.handler.id, 0); + let usedImportLink = false; + + while (queue.length > 0 && depths.size < nodeLimit) { + const current = queue.shift(); + if (!current || current.depth >= maxDepth) continue; + const sameFileTargets = graph.edges.flatMap((edge) => edge.from === current.id && edge.target ? [edge.target] : []); + const importedTargets = importCallLinks.links.flatMap((link) => link.from === current.id ? [link.target] : []); + const importedSet = new Set(importedTargets); + const adjacent = [...new Set([...sameFileTargets, ...importedTargets])].sort(); + for (const id of adjacent) { + if (depths.has(id)) continue; + const nextDepth = current.depth + 1; + depths.set(id, nextDepth); + if (importedSet.has(id)) usedImportLink = true; + queue.push({ id, depth: nextDepth }); + if (depths.size >= nodeLimit) break; + } + } + + return { depths, usedImportLink }; +} + +function owningReachableFunction( + graph: CallGraph, + depths: ReadonlyMap, + signal: SinkSignal, +): { node: CallGraphNode; depth: number } | undefined { + const path = normalizePath(signal.path); + const candidates = graph.nodes.filter((node) => { + if (!depths.has(node.id) || normalizePath(node.path) !== path) return false; + return signal.line >= node.line && signal.line <= node.endLine; + }); + if (candidates.length !== 1) return undefined; + const node = candidates[0]; + if (!node) return undefined; + const depth = depths.get(node.id); + return depth === undefined ? undefined : { node, depth }; +} + +/** + * Link one already-resolved route entrypoint to sink signals located inside its bounded lexical + * call neighborhood. When an import-call graph is supplied, traversal may additionally cross an + * explicit repository-local import binding that resolves to one unique lexical target function. + * Ambiguous function ownership or import resolution is omitted rather than guessed. + */ +export function routeSinkFlowContext( + index: RepositoryIndex, + entrypoint: RouteEntrypoint, + graph: CallGraph, + options: RouteSinkFlowOptions = {}, +): RouteSinkFlowContext | undefined { + if (!entrypoint.handler || entrypoint.resolution === "unresolved") return undefined; + const maxEvidence = Math.max(1, Math.min(50, options.maxEvidence ?? 12)); + const reachability = reachableDepths(entrypoint, graph, options.importCallLinks, options.maxCallNodes ?? 100); + if (reachability.depths.size === 0) return undefined; + + const evidence = index.sinks + .map((signal): RouteSinkFlowEvidence | undefined => { + const owner = owningReachableFunction(graph, reachability.depths, signal); + if (!owner) return undefined; + return { + path: signal.path, + line: signal.line, + kind: signal.kind, + functionId: owner.node.id, + functionName: owner.node.name, + depth: owner.depth, + }; + }) + .filter((value): value is RouteSinkFlowEvidence => Boolean(value)) + .sort((a, b) => a.depth - b.depth || sinkPriority(b.kind) - sinkPriority(a.kind) || a.path.localeCompare(b.path) || a.line - b.line) + .slice(0, maxEvidence); + + return { + route: entrypoint.route, + resolution: entrypoint.resolution, + handler: { + id: entrypoint.handler.id, + name: entrypoint.handler.name, + path: entrypoint.handler.path, + line: entrypoint.handler.line, + endLine: entrypoint.handler.endLine, + }, + evidence, + kinds: [...new Set(evidence.map((signal) => signal.kind))], + maxDepth: entrypoint.calls?.maxDepth ?? 0, + callScope: reachability.usedImportLink ? "same-file-and-explicit-imports" : "same-file", + interpretation: "structural-route-call-sink-evidence-only", + }; +} + +export function repositoryRouteSinkFlowContexts( + index: RepositoryIndex, + entrypoints: readonly RouteEntrypoint[], + graph: CallGraph, + options: RouteSinkFlowOptions = {}, +): RouteSinkFlowContext[] { + const maxRoutes = Math.max(0, Math.min(5_000, options.maxRoutes ?? 1_000)); + const output: RouteSinkFlowContext[] = []; + for (const entrypoint of entrypoints.slice(0, maxRoutes)) { + const context = routeSinkFlowContext(index, entrypoint, graph, options); + if (context) output.push(context); + } + return output; +} + +/** Return sanitized structural route-flow evidence only when the finding location exactly matches a linked sink line. */ +export function findingRouteSinkFlowEvidence( + contexts: readonly RouteSinkFlowContext[], + path: string, + line: number | undefined, + maxRoutes = 3, +): FindingRouteSinkFlowEvidence[] { + if (!Number.isSafeInteger(line) || (line ?? 0) <= 0) return []; + const normalized = normalizePath(path); + const limit = Math.max(1, Math.min(10, maxRoutes)); + const output: FindingRouteSinkFlowEvidence[] = []; + + for (const context of contexts) { + const match = context.evidence.find( + (evidence) => normalizePath(evidence.path) === normalized && evidence.line === line, + ); + if (!match) continue; + output.push({ + method: context.route.method, + route: context.route.route, + ...(context.route.frameworkHint ? { frameworkHint: context.route.frameworkHint } : {}), + resolution: context.resolution, + handler: context.handler.name, + sinkKind: match.kind, + functionName: match.functionName, + depth: match.depth, + callScope: context.callScope, + interpretation: "structural-route-call-sink-evidence-only", + }); + if (output.length >= limit) break; + } + return output; +} diff --git a/packages/repository/src/test-ownership.ts b/packages/repository/src/test-ownership.ts new file mode 100644 index 00000000..d4df8ddf --- /dev/null +++ b/packages/repository/src/test-ownership.ts @@ -0,0 +1,101 @@ +import { posix } from "node:path"; +import type { IndexFileInput } from "./analysis.js"; +import type { ModuleGraph } from "./module-graph.js"; + +export type TestOwnershipReason = "direct-import" | "filename-convention"; + +export interface LikelyTestOwner { + path: string; + reasons: TestOwnershipReason[]; +} + +export interface TestOwnershipContext { + sourcePath: string; + likelyTests: LikelyTestOwner[]; + maxResults: number; + /** Heuristics identify likely related tests; they do not prove execution or coverage. */ + interpretation: "likely-test-ownership-only"; +} + +function normalize(value: string): string { + return posix.normalize(value.replaceAll("\\", "/").replace(/^\.\//, "")).replace(/^\//, ""); +} + +function isTestPath(path: string): boolean { + const value = normalize(path).toLowerCase(); + const base = posix.basename(value); + return value.includes("/__tests__/") + || value.startsWith("tests/") + || value.startsWith("test/") + || /(?:^|\.)(?:test|spec)\.[^.]+$/.test(base) + || /^test_[^.]+\.py$/.test(base) + || /_test\.go$/.test(base); +} + +function filenameStem(path: string): string { + const base = posix.basename(normalize(path)).toLowerCase(); + return base + .replace(/(?:\.test|\.spec)(?=\.)/, "") + .replace(/^test_/, "") + .replace(/_test(?=\.)/, "") + .replace(/\.[^.]+$/, ""); +} + +function conventionMatch(sourcePath: string, testPath: string): boolean { + if (!isTestPath(testPath)) return false; + const sourceStem = filenameStem(sourcePath); + const testStem = filenameStem(testPath); + return Boolean(sourceStem && sourceStem === testStem); +} + +export function findLikelyTestOwners( + graph: ModuleGraph, + files: readonly IndexFileInput[], + sourcePath: string, + options: { maxResults?: number } = {}, +): TestOwnershipContext { + const normalizedSource = normalize(sourcePath); + const maxResults = Math.max(0, Math.min(100, options.maxResults ?? 20)); + const reasons = new Map>(); + + for (const edge of graph.edges) { + if (!edge.target || normalize(edge.target).toLowerCase() !== normalizedSource.toLowerCase()) continue; + const from = normalize(edge.from); + if (!isTestPath(from)) continue; + const entry = reasons.get(from) ?? new Set(); + entry.add("direct-import"); + reasons.set(from, entry); + } + + for (const file of files) { + const path = normalize(file.path); + if (path.toLowerCase() === normalizedSource.toLowerCase() || !conventionMatch(normalizedSource, path)) continue; + const entry = reasons.get(path) ?? new Set(); + entry.add("filename-convention"); + reasons.set(path, entry); + } + + const priority: Record = { + "direct-import": 2, + "filename-convention": 1, + }; + const likelyTests = [...reasons.entries()] + .map(([path, values]): LikelyTestOwner => ({ + path, + reasons: [...values].sort((a, b) => priority[b] - priority[a] || a.localeCompare(b)), + })) + .sort((a, b) => { + const direct = Number(b.reasons.includes("direct-import")) - Number(a.reasons.includes("direct-import")); + if (direct !== 0) return direct; + const reasonCount = b.reasons.length - a.reasons.length; + return reasonCount || a.path.localeCompare(b.path); + }) + .slice(0, maxResults); + + return { + sourcePath: normalizedSource, + likelyTests, + maxResults, + interpretation: "likely-test-ownership-only", + }; +} diff --git a/packages/repository/tsconfig.json b/packages/repository/tsconfig.json new file mode 100644 index 00000000..e6761c0d --- /dev/null +++ b/packages/repository/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [ + { "path": "../core" }, + { "path": "../report" } + ], + "include": ["src/**/*.ts"] +} diff --git a/packages/scanner-sdk/package.json b/packages/scanner-sdk/package.json index 6a33d31a..adef3d18 100644 --- a/packages/scanner-sdk/package.json +++ b/packages/scanner-sdk/package.json @@ -3,7 +3,10 @@ "version": "0.1.0", "private": true, "type": "module", - "exports": "./dist/index.js", + "exports": { + ".": "./dist/index.js", + "./oci-sandbox": "./dist/oci-sandbox.js" + }, "types": "./dist/index.d.ts", "scripts": { "build": "tsc -p tsconfig.json", diff --git a/packages/scanner-sdk/src/index.ts b/packages/scanner-sdk/src/index.ts index 01d51ade..a8bd9ef3 100644 --- a/packages/scanner-sdk/src/index.ts +++ b/packages/scanner-sdk/src/index.ts @@ -1,4 +1,5 @@ import { spawn } from "node:child_process"; +import { delimiter, isAbsolute, relative, resolve } from "node:path"; import type { ScanResult, ScanTarget } from "@synsec/core"; export type ScannerCapability = @@ -14,6 +15,8 @@ export interface ScannerContext { target: ScanTarget; timeoutMs?: number; signal?: AbortSignal; + /** Repository-relative files requested by an incremental scan. Adapters may use this to reduce work. */ + changedFiles?: string[]; } export interface ScannerAvailability { @@ -40,7 +43,125 @@ export interface ProcessOptions { cwd?: string; timeoutMs?: number; signal?: AbortSignal; + /** Explicit child environment. When omitted, SynSec passes only a small non-secret OS allowlist. */ env?: NodeJS.ProcessEnv; + /** Maximum bytes retained from each output stream. Defaults to 64 MiB per stream. */ + maxOutputBytes?: number; + /** Grace period between SIGTERM and SIGKILL. Defaults to 2 seconds. */ + killGraceMs?: number; +} + +/** Injectable execution boundary used by scanner adapters. */ +export type ScannerProcessRunner = ( + command: string, + args: string[], + options?: ProcessOptions, +) => Promise; + +const DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024 * 1024; +const DEFAULT_KILL_GRACE_MS = 2_000; +const DEFAULT_MAX_OPERATIONAL_TEXT = 8 * 1024; +const SAFE_ENV_KEYS = new Set([ + "PATH", + "PATHEXT", + "SYSTEMROOT", + "COMSPEC", + "WINDIR", + "TEMP", + "TMP", + "TMPDIR", + "LANG", + "LC_ALL", + "TERM", + "COLORTERM", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "NODE_EXTRA_CA_CERTS", + "XDG_CACHE_HOME", +]); + +/** + * Sanitize scanner/process diagnostics before they cross the operational reporting boundary. + * This is deliberately conservative: diagnostics are for operators, not a source-evidence channel. + */ +export function sanitizeOperationalText(value: string, maxLength = DEFAULT_MAX_OPERATIONAL_TEXT): string { + if (!Number.isFinite(maxLength) || maxLength <= 0) throw new Error("maxLength must be a positive finite number."); + let text = value + .replace(/\0/g, "") + .replace(/[\u0001-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "") + .replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi, "$1[REDACTED]@") + .replace(/([?&](?:access[_-]?token|auth[_-]?token|api[_-]?key|password|passwd|token)=)[^&#\s]+/gi, "$1[REDACTED]") + .replace(/\b(?:github_pat_[A-Za-z0-9_]+|gh[pousr]_[A-Za-z0-9_]+)\b/g, "[REDACTED_TOKEN]") + .replace(/\bAKIA[A-Z0-9]{16}\b/g, "[REDACTED_ACCESS_KEY]") + .replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, "[REDACTED_JWT]") + .replace( + /(\b(?:authorization|proxy-authorization)\s*[:=]\s*)(?:bearer\s+)?(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s,;]+)/gi, + "$1[REDACTED]", + ) + .replace( + /(\b(?:api[_-]?key|access[_-]?token|auth[_-]?token|password|passwd)\s*[:=]\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s,;]+)/gi, + "$1[REDACTED]", + ) + .trim(); + if (text.length > maxLength) text = `${text.slice(0, maxLength)}…[truncated]`; + return text; +} + +function isWithinDirectory(candidate: string, root: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +/** + * Remove relative/empty PATH entries and, when a scanner working directory is known, directories + * inside that working tree. This prevents repository-controlled executables from shadowing the + * intended scanner binary through `.` or repository-local `.bin` entries. + */ +export function sanitizeScannerSearchPath(value: string, cwd?: string): string | undefined { + const root = cwd ? resolve(cwd) : undefined; + const seen = new Set(); + const entries: string[] = []; + for (const raw of value.split(delimiter)) { + const entry = raw.trim(); + if (!entry || !isAbsolute(entry)) continue; + const absolute = resolve(entry); + if (root && isWithinDirectory(absolute, root)) continue; + const key = process.platform === "win32" ? absolute.toLowerCase() : absolute; + if (seen.has(key)) continue; + seen.add(key); + entries.push(absolute); + } + return entries.length > 0 ? entries.join(delimiter) : undefined; +} + +/** + * Build the default environment for untrusted external scanner processes. + * Credentials, CI tokens, cloud secrets, registry tokens, proxy URLs, and user configuration roots + * are not inherited implicitly. In particular, HOME/USERPROFILE/AppData/XDG_CONFIG_HOME are omitted + * because scanner-specific config files under those roots can contain credentials even when token + * environment variables themselves have been removed. PATH is restricted to absolute directories + * outside the scanner working tree when `cwd` is provided. + */ +export function buildScannerProcessEnv(source: NodeJS.ProcessEnv = process.env, cwd?: string): NodeJS.ProcessEnv { + const result: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(source)) { + if (value === undefined) continue; + const normalized = key.toUpperCase(); + if (normalized === "PATH") { + const safePath = sanitizeScannerSearchPath(value, cwd); + if (safePath) result[key] = safePath; + continue; + } + if (SAFE_ENV_KEYS.has(normalized) || normalized.startsWith("LC_")) result[key] = value; + } + return result; +} + +function assertSafeScannerCommand(command: string): void { + if (!command.trim()) throw new Error("Scanner command must be non-empty."); + if (/[\\/]/.test(command) && !isAbsolute(command)) { + throw new Error("Relative scanner executable paths are not allowed; use a bare command name or an absolute path."); + } } export async function runProcess( @@ -48,10 +169,24 @@ export async function runProcess( args: string[], options: ProcessOptions = {}, ): Promise { + assertSafeScannerCommand(command); + if (options.signal?.aborted) throw new Error(`Process aborted before start: ${command}`); + if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) { + throw new Error("timeoutMs must be a positive finite number when provided."); + } + const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + if (!Number.isFinite(maxOutputBytes) || maxOutputBytes <= 0) { + throw new Error("maxOutputBytes must be a positive finite number."); + } + const killGraceMs = options.killGraceMs ?? DEFAULT_KILL_GRACE_MS; + if (!Number.isFinite(killGraceMs) || killGraceMs < 0) { + throw new Error("killGraceMs must be a non-negative finite number."); + } + return await new Promise((resolve, reject) => { const child = spawn(command, args, { cwd: options.cwd, - env: options.env ?? process.env, + env: options.env ?? buildScannerProcessEnv(process.env, options.cwd), shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], @@ -59,7 +194,13 @@ export async function runProcess( let stdout = ""; let stderr = ""; + let stdoutBytes = 0; + let stderrBytes = 0; let settled = false; + let timedOut = false; + let aborted = false; + let overflowError: Error | undefined; + let killEscalation: NodeJS.Timeout | undefined; const finish = (callback: () => void): void => { if (settled) return; @@ -67,38 +208,92 @@ export async function runProcess( callback(); }; + const terminate = (): void => { + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill("SIGTERM"); + if (!killEscalation) { + killEscalation = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + }, killGraceMs); + } + }; + + const stopForOverflow = (stream: "stdout" | "stderr", bytes: number): void => { + if (overflowError) return; + overflowError = new Error( + `Process ${command} exceeded the ${maxOutputBytes} byte ${stream} limit (${bytes} bytes observed).`, + ); + terminate(); + }; + child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); child.stdout.on("data", (chunk: string) => { + stdoutBytes += Buffer.byteLength(chunk); + if (stdoutBytes > maxOutputBytes) { + stopForOverflow("stdout", stdoutBytes); + return; + } stdout += chunk; }); child.stderr.on("data", (chunk: string) => { + stderrBytes += Buffer.byteLength(chunk); + if (stderrBytes > maxOutputBytes) { + stopForOverflow("stderr", stderrBytes); + return; + } stderr += chunk; }); const timeout = options.timeoutMs - ? setTimeout(() => child.kill("SIGTERM"), options.timeoutMs) + ? setTimeout(() => { + timedOut = true; + terminate(); + }, options.timeoutMs) : undefined; const onAbort = (): void => { - child.kill("SIGTERM"); + aborted = true; + terminate(); }; options.signal?.addEventListener("abort", onAbort, { once: true }); + const cleanup = (): void => { + if (timeout) clearTimeout(timeout); + if (killEscalation) clearTimeout(killEscalation); + options.signal?.removeEventListener("abort", onAbort); + }; + child.once("error", (error) => { - finish(() => reject(error)); + cleanup(); + finish(() => { + const sanitized = new Error(sanitizeOperationalText(error.message)); + sanitized.name = error.name; + reject(sanitized); + }); }); child.once("close", (code) => { - if (timeout) clearTimeout(timeout); - options.signal?.removeEventListener("abort", onAbort); - finish(() => + cleanup(); + finish(() => { + if (overflowError) { + reject(overflowError); + return; + } + if (timedOut) { + reject(new Error(`Process timed out after ${options.timeoutMs} ms: ${command}`)); + return; + } + if (aborted) { + reject(new Error(`Process aborted: ${command}`)); + return; + } resolve({ exitCode: code ?? -1, stdout, - stderr, - }), - ); + stderr: sanitizeOperationalText(stderr), + }); + }); }); }); } diff --git a/packages/scanner-sdk/src/oci-sandbox.ts b/packages/scanner-sdk/src/oci-sandbox.ts new file mode 100644 index 00000000..6cd8656d --- /dev/null +++ b/packages/scanner-sdk/src/oci-sandbox.ts @@ -0,0 +1,300 @@ +import { lstat } from "node:fs/promises"; +import { isAbsolute, relative, resolve } from "node:path"; +import { + runProcess, + type ProcessOptions, + type ProcessOutput, + type ScannerProcessRunner, +} from "./index.js"; + +const DEFAULT_CPU_LIMIT = 2; +const MIN_CPU_LIMIT = 0.1; +const MAX_CPU_LIMIT = 64; +const DEFAULT_MEMORY_BYTES = 2 * 1024 * 1024 * 1024; +const MIN_MEMORY_BYTES = 64 * 1024 * 1024; +const MAX_MEMORY_BYTES = 64 * 1024 * 1024 * 1024; +const DEFAULT_PIDS_LIMIT = 256; +const MIN_PIDS_LIMIT = 16; +const MAX_PIDS_LIMIT = 4096; +const DEFAULT_SCRATCH_BYTES = 512 * 1024 * 1024; +const MIN_SCRATCH_BYTES = 16 * 1024 * 1024; +const MAX_SCRATCH_BYTES = 16 * 1024 * 1024 * 1024; +const DEFAULT_CONTAINER_USER = "65532:65532"; +const DEFAULT_CONTAINER_WORKDIR = "/workspace"; + +export interface OciScannerSandboxOptions { + /** Bare or absolute local OCI CLI. Defaults to docker. */ + runtimeCommand?: string; + /** Pre-provisioned immutable scanner image. Mutable tags are rejected. */ + image: string; + /** Absolute host repository directory mounted read-only at /workspace. */ + repositoryRoot: string; + cpuLimit?: number; + memoryBytes?: number; + pidsLimit?: number; + scratchBytes?: number; + /** Numeric non-root uid:gid. Defaults to 65532:65532. */ + runAsUser?: string; + timeoutMs?: number; + signal?: AbortSignal; + maxOutputBytes?: number; + killGraceMs?: number; +} + +interface NumericContainerUser { + uid: number; + gid: number; + value: string; +} + +function boundedInteger(value: number | undefined, fallback: number, minimum: number, maximum: number, label: string): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved < minimum || resolved > maximum) { + throw new Error(`${label} must be an integer between ${minimum} and ${maximum}.`); + } + return resolved; +} + +function boundedCpu(value: number | undefined): number { + const resolved = value ?? DEFAULT_CPU_LIMIT; + if (!Number.isFinite(resolved) || resolved < MIN_CPU_LIMIT || resolved > MAX_CPU_LIMIT) { + throw new Error(`OCI scanner CPU limit must be between ${MIN_CPU_LIMIT} and ${MAX_CPU_LIMIT}.`); + } + return resolved; +} + +function immutableImage(value: string): string { + if (typeof value !== "string" || !value || value.length > 512 || /[\s\u0000-\u001f\u007f]/.test(value)) { + throw new Error("OCI scanner image must be a bounded non-secret image reference."); + } + if (!/@sha256:[a-f0-9]{64}$/i.test(value)) { + throw new Error("OCI scanner image must be pinned by sha256 digest; mutable tags are not allowed."); + } + return value; +} + +function numericNonRootUser(value: string | undefined): NumericContainerUser { + const normalized = value ?? DEFAULT_CONTAINER_USER; + const match = /^(\d+):(\d+)$/.exec(normalized); + if (!match) throw new Error("OCI scanner user must be a numeric uid:gid pair."); + const uid = Number(match[1]); + const gid = Number(match[2]); + if (!Number.isSafeInteger(uid) || !Number.isSafeInteger(gid) || uid <= 0 || gid <= 0 || uid > 2_147_483_647 || gid > 2_147_483_647) { + throw new Error("OCI scanner user must use positive bounded non-root uid/gid values."); + } + return { uid, gid, value: `${uid}:${gid}` }; +} + +function repositoryRoot(value: string): string { + if (typeof value !== "string" || !isAbsolute(value) || value.includes("\0") || value.includes(",")) { + throw new Error("OCI scanner repository root must be an absolute mount-safe path."); + } + return resolve(value); +} + +function scannerToken(value: string, label: string): string { + if (typeof value !== "string" || !value || value.length > 32_768 || /[\u0000\r\n]/.test(value)) { + throw new Error(`${label} must be a bounded single-line string.`); + } + return value; +} + +function ownedTmpfs(path: "/scratch" | "/tmp", bytes: number, user: NumericContainerUser): string { + return `${path}:rw,noexec,nosuid,nodev,size=${bytes},uid=${user.uid},gid=${user.gid},mode=0700`; +} + +function isWithinDirectory(candidate: string, root: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +function mapRepositoryArgument(value: string, root: string): string { + const normalizedRoot = root.replaceAll("\\", "/").replace(/\/$/, ""); + const normalized = value.replaceAll("\\", "/"); + if (normalized === normalizedRoot) return DEFAULT_CONTAINER_WORKDIR; + if (normalized.startsWith(`${normalizedRoot}/`)) { + return `${DEFAULT_CONTAINER_WORKDIR}/${normalized.slice(normalizedRoot.length + 1)}`; + } + for (const prefix of ["dir:", "file:"]) { + if (normalized === `${prefix}${normalizedRoot}`) return `${prefix}${DEFAULT_CONTAINER_WORKDIR}`; + if (normalized.startsWith(`${prefix}${normalizedRoot}/`)) { + return `${prefix}${DEFAULT_CONTAINER_WORKDIR}/${normalized.slice(prefix.length + normalizedRoot.length + 1)}`; + } + } + return value; +} + +export interface OciScannerSandboxPlan { + runtimeCommand: string; + runtimeArgs: string[]; + repositoryRoot: string; + image: string; + /** Concrete controls created by this invocation, not an operator declaration. */ + enforcedControls: { + networkPolicy: "none"; + repositoryReadOnly: true; + rootFilesystemReadOnly: true; + scratchSeparated: true; + runAsNonRoot: true; + capabilitiesDropped: true; + allowPrivilegeEscalation: false; + hostNetwork: false; + hostPid: false; + hostIpc: false; + hostSocketMounts: false; + pullPolicy: "never"; + }; +} + +/** + * Produce the exact OCI CLI invocation used by runOciSandboxedScanner(). + * + * The plan deliberately supports only network=none. Domain/CIDR-filtered egress requires an + * external network-policy implementation and must not be simulated with unrestricted bridge mode. + */ +export function buildOciScannerSandboxPlan( + commandValue: string, + argsValue: readonly string[], + options: OciScannerSandboxOptions, +): OciScannerSandboxPlan { + const runtimeCommand = scannerToken(options.runtimeCommand ?? "docker", "OCI runtime command"); + const image = immutableImage(options.image); + const root = repositoryRoot(options.repositoryRoot); + const command = scannerToken(commandValue, "OCI scanner command"); + const args = argsValue.map((value) => scannerToken(value, "OCI scanner argument")); + const cpuLimit = boundedCpu(options.cpuLimit); + const memoryBytes = boundedInteger( + options.memoryBytes, + DEFAULT_MEMORY_BYTES, + MIN_MEMORY_BYTES, + MAX_MEMORY_BYTES, + "OCI scanner memory limit", + ); + const pidsLimit = boundedInteger( + options.pidsLimit, + DEFAULT_PIDS_LIMIT, + MIN_PIDS_LIMIT, + MAX_PIDS_LIMIT, + "OCI scanner PID limit", + ); + const scratchBytes = boundedInteger( + options.scratchBytes, + DEFAULT_SCRATCH_BYTES, + MIN_SCRATCH_BYTES, + MAX_SCRATCH_BYTES, + "OCI scanner scratch limit", + ); + const user = numericNonRootUser(options.runAsUser); + + const runtimeArgs = [ + "run", + "--rm", + "--init", + "--pull=never", + "--network=none", + "--ipc=none", + "--read-only", + "--cap-drop=ALL", + "--security-opt=no-new-privileges=true", + `--pids-limit=${pidsLimit}`, + `--memory=${memoryBytes}`, + `--memory-swap=${memoryBytes}`, + `--cpus=${cpuLimit}`, + `--user=${user.value}`, + "--mount", + `type=bind,src=${root},dst=${DEFAULT_CONTAINER_WORKDIR},readonly`, + "--tmpfs", + ownedTmpfs("/scratch", scratchBytes, user), + "--tmpfs", + ownedTmpfs("/tmp", scratchBytes, user), + `--workdir=${DEFAULT_CONTAINER_WORKDIR}`, + "--env=HOME=/scratch", + "--env=TMPDIR=/tmp", + "--env=XDG_CACHE_HOME=/scratch/cache", + image, + command, + ...args, + ]; + + return { + runtimeCommand, + runtimeArgs, + repositoryRoot: root, + image, + enforcedControls: { + networkPolicy: "none", + repositoryReadOnly: true, + rootFilesystemReadOnly: true, + scratchSeparated: true, + runAsNonRoot: true, + capabilitiesDropped: true, + allowPrivilegeEscalation: false, + hostNetwork: false, + hostPid: false, + hostIpc: false, + hostSocketMounts: false, + pullPolicy: "never", + }, + }; +} + +/** + * Execute one scanner command inside a locally provisioned OCI image with enforced isolation. + * + * The repository path is independently checked with lstat immediately before the container starts; + * symlink roots and non-directories are rejected. The OCI CLI itself runs through SynSec's existing + * bounded process runner, so it receives the same credential-minimized host environment, timeout, + * abort, output-memory, and kill-escalation controls as other external processes. + */ +export async function runOciSandboxedScanner( + command: string, + args: readonly string[], + options: OciScannerSandboxOptions, +): Promise { + const plan = buildOciScannerSandboxPlan(command, args, options); + const info = await lstat(plan.repositoryRoot).catch(() => undefined); + if (!info?.isDirectory() || info.isSymbolicLink()) { + throw new Error("OCI scanner repository root must be an existing non-symlink directory."); + } + return runProcess(plan.runtimeCommand, plan.runtimeArgs, { + timeoutMs: options.timeoutMs, + signal: options.signal, + maxOutputBytes: options.maxOutputBytes, + killGraceMs: options.killGraceMs, + }); +} + +/** + * Adapt the OCI sandbox into the same process-runner contract used by scanner adapters. + * + * Host repository paths in scanner arguments are rewritten only when they exactly identify the + * configured repository root (or a descendant, including `dir:` / `file:` source forms). Arbitrary + * absolute host paths are not mounted into the container. Explicit child environments are rejected + * because scanner-specific credentials must never cross this boundary. + */ +export function createOciScannerProcessRunner(options: OciScannerSandboxOptions): ScannerProcessRunner { + const root = repositoryRoot(options.repositoryRoot); + return async (command: string, args: string[], processOptions: ProcessOptions = {}): Promise => { + if (processOptions.env !== undefined) { + throw new Error("OCI scanner execution does not accept explicit child environments."); + } + if (processOptions.cwd !== undefined) { + const cwd = resolve(processOptions.cwd); + if (!isWithinDirectory(cwd, root)) { + throw new Error("OCI scanner working directory must remain inside the configured repository root."); + } + if (cwd !== root) { + throw new Error("OCI scanner process runner currently requires the repository root as its working directory."); + } + } + const mappedArgs = args.map((value) => mapRepositoryArgument(value, root)); + return runOciSandboxedScanner(command, mappedArgs, { + ...options, + repositoryRoot: root, + timeoutMs: processOptions.timeoutMs ?? options.timeoutMs, + signal: processOptions.signal ?? options.signal, + maxOutputBytes: processOptions.maxOutputBytes ?? options.maxOutputBytes, + killGraceMs: processOptions.killGraceMs ?? options.killGraceMs, + }); + }; +} diff --git a/packages/scanners/src/betterleaks.ts b/packages/scanners/src/betterleaks.ts new file mode 100644 index 00000000..786a77d4 --- /dev/null +++ b/packages/scanners/src/betterleaks.ts @@ -0,0 +1,106 @@ +import { randomUUID } from "node:crypto"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import type { Finding, ScanResult } from "@synsec/core"; +import type { ScannerAdapter, ScannerAvailability, ScannerContext } from "@synsec/scanner-sdk"; +import { runProcess } from "@synsec/scanner-sdk"; +import { asArray, asNumber, asRecord, asString, commandAvailability, relativeLike, safeJson } from "./utils.js"; + +export function parseBetterleaksJson(raw: string, root = ""): Finding[] { + const parsed = safeJson(raw); + const findings: Finding[] = []; + for (const value of asArray(parsed)) { + const item = asRecord(value); + if (!item) continue; + const ruleId = asString(item.RuleID); + const description = asString(item.Description) ?? ruleId ?? "Potential secret detected"; + const attributes = asRecord(item.Attributes); + const rawFile = asString(item.File) ?? asString(attributes?.path) ?? asString(attributes?.Path); + const file = relativeLike(rawFile, root); + const startLine = asNumber(item.StartLine); + const fingerprint = asString(item.Fingerprint); + const validationStatus = asString(item.ValidationStatus); + + findings.push({ + id: randomUUID(), + title: description, + description: "A credential-like value was detected. SynSec requests fully redacted scanner output and never copies the secret into its normalized finding.", + category: "secret", + severity: validationStatus === "valid" ? "critical" : "high", + confidence: validationStatus === "valid" ? 0.995 : 0.98, + scanner: { name: "betterleaks", ruleId }, + location: file ? { path: file, startLine } : undefined, + fingerprint, + remediation: "Revoke or rotate the credential, remove it from the repository and Git history where necessary, and store credentials outside source control.", + metadata: { + validationStatus, + validationReason: asString(item.ValidationReason), + commit: asString(item.Commit), + author: asString(item.Author), + date: asString(item.Date), + tags: item.Tags, + }, + }); + } + return findings; +} + +export class BetterleaksAdapter implements ScannerAdapter { + readonly id = "betterleaks"; + readonly displayName = "Betterleaks"; + readonly capabilities = ["secret"] as const; + + checkAvailability(): Promise { + return commandAvailability("betterleaks", ["version"], this.displayName); + } + + async scan(context: ScannerContext): Promise { + const startedAt = new Date().toISOString(); + if (context.changedFiles && context.changedFiles.length === 0) { + return { + scanner: this.id, + startedAt, + completedAt: new Date().toISOString(), + target: context.target, + findings: [], + diagnostics: ["Changed-file scope is empty; Betterleaks was not invoked."], + }; + } + + const temp = await mkdtemp(join(tmpdir(), "synsec-betterleaks-")); + const report = join(temp, "report.json"); + try { + const gitRepo = await stat(join(context.target.path, ".git")).then(() => true).catch(() => false); + const mode = context.changedFiles ? "dir" : gitRepo ? "git" : "dir"; + const targets = context.changedFiles + ? context.changedFiles.map((path) => resolve(context.target.path, path)) + : [context.target.path]; + const output = await runProcess( + "betterleaks", + [ + mode, + "--report-format", "json", + "--report-path", report, + "--redact=100", + "--no-banner", + "--exit-code", "0", + ...targets, + ], + { timeoutMs: context.timeoutMs ?? 10 * 60_000, signal: context.signal }, + ); + if (output.exitCode !== 0) throw new Error(`Betterleaks scan failed (${output.exitCode}): ${output.stderr.trim()}`); + const raw = await readFile(report, "utf8").catch(() => "[]"); + return { + scanner: this.id, + startedAt, + completedAt: new Date().toISOString(), + target: context.target, + findings: parseBetterleaksJson(raw, context.target.path), + diagnostics: output.stderr.trim() ? [output.stderr.trim()] : [], + }; + } finally { + await rm(temp, { recursive: true, force: true }); + } + } +} diff --git a/packages/scanners/src/checkov.ts b/packages/scanners/src/checkov.ts new file mode 100644 index 00000000..92e62409 --- /dev/null +++ b/packages/scanners/src/checkov.ts @@ -0,0 +1,137 @@ +import { randomUUID } from "node:crypto"; +import { isAbsolute } from "node:path"; +import type { Finding, ScanResult } from "@synsec/core"; +import type { + ScannerAdapter, + ScannerAvailability, + ScannerContext, + ScannerProcessRunner, +} from "@synsec/scanner-sdk"; +import { runProcess } from "@synsec/scanner-sdk"; +import { asArray, asNumber, asRecord, asString, commandAvailability, normalizeSeverity, relativeLike, safeJson } from "./utils.js"; + +const MAX_CHANGED_FILES = 500; + +function runnerObjects(parsed: unknown): Record[] { + if (Array.isArray(parsed)) return parsed.map(asRecord).filter((value): value is Record => Boolean(value)); + const record = asRecord(parsed); + return record ? [record] : []; +} + +function checkovRepositoryPath(value: unknown): string | undefined { + const raw = asString(value)?.replace(/^\/+/, ""); + return relativeLike(raw, ""); +} + +function safeChangedFiles(files: readonly string[] | undefined): string[] | undefined { + if (files === undefined) return undefined; + if (files.length === 0) return undefined; + if (files.length > MAX_CHANGED_FILES) { + throw new Error(`Checkov changed-file scope exceeds the ${MAX_CHANGED_FILES}-file adapter limit.`); + } + const result: string[] = []; + const seen = new Set(); + for (const value of files) { + const path = value.replace(/\\/g, "/").replace(/^\.\//, "").trim(); + if ( + !path || + isAbsolute(path) || + /^[A-Za-z]:\//.test(path) || + path === ".." || + path.startsWith("../") || + path.includes("/../") || + path.includes("\0") + ) { + throw new Error("Checkov changed-file scope contains an unsafe repository path."); + } + if (!seen.has(path)) { + seen.add(path); + result.push(path); + } + } + return result.length > 0 ? result : undefined; +} + +export function buildCheckovArguments(context: ScannerContext): string[] { + const files = safeChangedFiles(context.changedFiles); + if (!files) return ["-d", context.target.path, "-o", "json", "--quiet", "--compact"]; + return [ + "-o", + "json", + "--quiet", + "--compact", + ...files.flatMap((path) => ["-f", path]), + ]; +} + +export function parseCheckovJson(raw: string): Finding[] { + const parsed = safeJson(raw); + const findings: Finding[] = []; + for (const runner of runnerObjects(parsed)) { + const checkType = asString(runner.check_type); + const results = asRecord(runner.results); + for (const value of asArray(results?.failed_checks)) { + const item = asRecord(value); + if (!item) continue; + const ruleId = asString(item.check_id) ?? asString(item.bc_check_id); + const file = checkovRepositoryPath(item.file_path); + const range = asArray(item.file_line_range); + const startLine = asNumber(range[0]); + const endLine = asNumber(range[1]); + const guideline = asString(item.guideline); + findings.push({ + id: randomUUID(), + title: asString(item.check_name) ?? ruleId ?? "Infrastructure configuration issue", + description: guideline, + category: "iac", + severity: normalizeSeverity(item.severity), + confidence: 0.92, + scanner: { name: "checkov", ruleId }, + location: file ? { path: file, startLine, endLine } : undefined, + remediation: guideline ? `Review the Checkov guidance: ${guideline}` : undefined, + metadata: { + framework: checkType, + resource: asString(item.resource), + checkClass: asString(item.check_class), + }, + }); + } + } + return findings; +} + +export class CheckovAdapter implements ScannerAdapter { + readonly id = "checkov"; + readonly displayName = "Checkov"; + readonly capabilities = ["iac"] as const; + + constructor(private readonly processRunner: ScannerProcessRunner = runProcess) {} + + checkAvailability(): Promise { + return commandAvailability("checkov", ["--version"], this.displayName, this.processRunner); + } + + async scan(context: ScannerContext): Promise { + const startedAt = new Date().toISOString(); + const output = await this.processRunner( + "checkov", + buildCheckovArguments(context), + { + cwd: context.target.path, + timeoutMs: context.timeoutMs ?? 10 * 60_000, + signal: context.signal, + }, + ); + if (output.exitCode !== 0 && output.exitCode !== 1) { + throw new Error(`Checkov scan failed (${output.exitCode}): ${output.stderr.trim()}`); + } + return { + scanner: this.id, + startedAt, + completedAt: new Date().toISOString(), + target: context.target, + findings: parseCheckovJson(output.stdout), + diagnostics: output.stderr.trim() ? [output.stderr.trim()] : [], + }; + } +} diff --git a/packages/scanners/src/gitleaks.ts b/packages/scanners/src/gitleaks.ts new file mode 100644 index 00000000..4fe97074 --- /dev/null +++ b/packages/scanners/src/gitleaks.ts @@ -0,0 +1,209 @@ +import { randomUUID } from "node:crypto"; +import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import type { Finding, ScannerExecutionScope, ScanResult } from "@synsec/core"; +import type { ScannerAdapter, ScannerAvailability, ScannerContext } from "@synsec/scanner-sdk"; +import { runProcess } from "@synsec/scanner-sdk"; +import { asArray, asNumber, asRecord, asString, commandAvailability, relativeLike, safeJson } from "./utils.js"; + +const MAX_CHANGED_FILES = 500; +const EXECUTION_INTERPRETATION = "scanner-execution-scope-not-coverage-proof" as const; + +export function normalizeGitleaksChangedFiles(files: readonly string[] | undefined): string[] | undefined { + if (files === undefined) return undefined; + if (files.length === 0) return []; + if (files.length > MAX_CHANGED_FILES) { + throw new Error(`Gitleaks changed-file scope exceeds the ${MAX_CHANGED_FILES}-file adapter limit.`); + } + + const result: string[] = []; + const seen = new Set(); + for (const value of files) { + const path = value.replace(/\\/g, "/").replace(/^\.\//, "").trim(); + if ( + !path || + isAbsolute(path) || + /^[A-Za-z]:\//.test(path) || + path === ".." || + path.startsWith("../") || + path.includes("/../") || + path.includes("\0") + ) { + throw new Error("Gitleaks changed-file scope contains an unsafe repository path."); + } + if (!seen.has(path)) { + seen.add(path); + result.push(path); + } + } + return result; +} + +async function stageChangedFiles( + repositoryRoot: string, + stagingRoot: string, + files: readonly string[], +): Promise<{ staged: true } | { staged: false; reason: string }> { + await mkdir(stagingRoot, { recursive: true }); + const resolvedRepositoryRoot = resolve(repositoryRoot); + const resolvedStagingRoot = resolve(stagingRoot); + + for (const path of files) { + const source = resolve(resolvedRepositoryRoot, path); + const sourceRelative = relative(resolvedRepositoryRoot, source); + if (!sourceRelative || sourceRelative === ".." || sourceRelative.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(sourceRelative)) { + return { staged: false, reason: "changed scope escaped the repository root" }; + } + + const info = await lstat(source).catch(() => undefined); + if (!info?.isFile() || info.isSymbolicLink()) { + return { staged: false, reason: "changed scope contains a missing, symlink, or non-regular file" }; + } + + const destination = resolve(resolvedStagingRoot, path); + const destinationRelative = relative(resolvedStagingRoot, destination); + if (!destinationRelative || destinationRelative === ".." || destinationRelative.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(destinationRelative)) { + return { staged: false, reason: "staged scope escaped the temporary root" }; + } + await mkdir(dirname(destination), { recursive: true }); + await copyFile(source, destination); + } + + // Preserve repository-local Gitleaks configuration without exposing unrelated source files. + const configSource = join(resolvedRepositoryRoot, ".gitleaks.toml"); + const configInfo = await lstat(configSource).catch(() => undefined); + if (configInfo) { + if (!configInfo.isFile() || configInfo.isSymbolicLink()) { + return { staged: false, reason: "repository Gitleaks configuration is not a regular file" }; + } + const configDestination = join(resolvedStagingRoot, ".gitleaks.toml"); + if (!files.includes(".gitleaks.toml")) await copyFile(configSource, configDestination); + } + + return { staged: true }; +} + +export function parseGitleaksJson(raw: string, root = ""): Finding[] { + const parsed = safeJson(raw); + const findings: Finding[] = []; + for (const value of asArray(parsed)) { + const item = asRecord(value); + if (!item) continue; + const ruleId = asString(item.RuleID); + const description = asString(item.Description) ?? ruleId ?? "Potential secret detected"; + const file = relativeLike(asString(item.File), root); + const startLine = asNumber(item.StartLine); + const fingerprint = asString(item.Fingerprint); + findings.push({ + id: randomUUID(), + title: description, + description: "A credential-like value was detected. SynSec intentionally omits the secret value from normalized output.", + category: "secret", + severity: "high", + confidence: 0.97, + scanner: { name: "gitleaks", ruleId }, + location: file ? { path: file, startLine } : undefined, + fingerprint, + remediation: "Revoke or rotate the credential, remove it from the repository and Git history where necessary, and use a secret manager or environment variable instead.", + metadata: { + entropy: asNumber(item.Entropy), + commit: asString(item.Commit), + author: asString(item.Author), + date: asString(item.Date), + }, + }); + } + return findings; +} + +export class GitleaksAdapter implements ScannerAdapter { + readonly id = "gitleaks"; + readonly displayName = "Gitleaks"; + readonly capabilities = ["secret"] as const; + + checkAvailability(): Promise { + return commandAvailability("gitleaks", ["version"], this.displayName); + } + + async scan(context: ScannerContext): Promise { + const startedAt = new Date().toISOString(); + const changedFiles = normalizeGitleaksChangedFiles(context.changedFiles); + if (changedFiles && changedFiles.length === 0) { + return { + scanner: this.id, + startedAt, + completedAt: new Date().toISOString(), + target: context.target, + findings: [], + diagnostics: ["Changed-file scope is empty; Gitleaks was not invoked."], + executionScope: { + mode: "changed-files-native", + changedFileCount: 0, + interpretation: EXECUTION_INTERPRETATION, + }, + }; + } + + const temp = await mkdtemp(join(tmpdir(), "synsec-gitleaks-")); + const report = join(temp, "report.json"); + try { + let mode: "git" | "dir"; + let target: string; + let parseRoot = context.target.path; + let executionScope: ScannerExecutionScope = changedFiles + ? { + mode: "changed-files-native", + changedFileCount: changedFiles.length, + interpretation: EXECUTION_INTERPRETATION, + } + : { mode: "repository", interpretation: EXECUTION_INTERPRETATION }; + const diagnostics: string[] = []; + + if (changedFiles) { + const stagingRoot = join(temp, "scope"); + const staged = await stageChangedFiles(context.target.path, stagingRoot, changedFiles); + if (staged.staged) { + mode = "dir"; + target = stagingRoot; + parseRoot = stagingRoot; + diagnostics.push(`Gitleaks scanned ${changedFiles.length} staged changed file(s) with repository-relative paths preserved.`); + } else { + const gitRepo = await stat(join(context.target.path, ".git")).then(() => true).catch(() => false); + mode = gitRepo ? "git" : "dir"; + target = context.target.path; + executionScope = { + mode: "repository-then-filtered", + changedFileCount: changedFiles.length, + interpretation: EXECUTION_INTERPRETATION, + }; + diagnostics.push(`Gitleaks changed-file staging was unsafe or ambiguous (${staged.reason}); fell back to a full repository scan.`); + } + } else { + const gitRepo = await stat(join(context.target.path, ".git")).then(() => true).catch(() => false); + mode = gitRepo ? "git" : "dir"; + target = context.target.path; + } + + const output = await runProcess( + "gitleaks", + [mode, "--report-format", "json", "--report-path", report, "--redact=100", "--no-banner", "--exit-code", "0", target], + { cwd: context.target.path, timeoutMs: context.timeoutMs ?? 10 * 60_000, signal: context.signal }, + ); + if (output.exitCode !== 0) throw new Error(`Gitleaks scan failed (${output.exitCode}): ${output.stderr.trim()}`); + const raw = await readFile(report, "utf8").catch(() => "[]"); + if (output.stderr.trim()) diagnostics.push(output.stderr.trim()); + return { + scanner: this.id, + startedAt, + completedAt: new Date().toISOString(), + target: context.target, + findings: parseGitleaksJson(raw, parseRoot), + diagnostics, + executionScope, + }; + } finally { + await rm(temp, { recursive: true, force: true }); + } + } +} diff --git a/packages/scanners/src/grype.ts b/packages/scanners/src/grype.ts new file mode 100644 index 00000000..d403cc38 --- /dev/null +++ b/packages/scanners/src/grype.ts @@ -0,0 +1,87 @@ +import { randomUUID } from "node:crypto"; +import type { Finding, ScanResult } from "@synsec/core"; +import type { + ScannerAdapter, + ScannerAvailability, + ScannerContext, + ScannerProcessRunner, +} from "@synsec/scanner-sdk"; +import { runProcess } from "@synsec/scanner-sdk"; +import { asArray, asRecord, asString, commandAvailability, identifiersFrom, normalizeSeverity, relativeLike, safeJson } from "./utils.js"; + +export function parseGrypeJson(raw: string, root = ""): Finding[] { + const parsed = asRecord(safeJson(raw)); + if (!parsed) return []; + const findings: Finding[] = []; + for (const value of asArray(parsed.matches)) { + const match = asRecord(value); + if (!match) continue; + const vulnerability = asRecord(match.vulnerability); + const artifact = asRecord(match.artifact); + if (!vulnerability || !artifact) continue; + const vulnId = asString(vulnerability.id) ?? "Known vulnerability"; + const packageName = asString(artifact.name) ?? "unknown package"; + const packageVersion = asString(artifact.version); + const fix = asRecord(vulnerability.fix); + const fixedVersions = asArray(fix?.versions).filter((item): item is string => typeof item === "string"); + const aliases = asArray(vulnerability.relatedVulnerabilities) + .map(asRecord) + .map((entry) => asString(entry?.id)) + .filter((item): item is string => Boolean(item)); + const firstLocation = asRecord(asArray(artifact.locations)[0]); + const path = relativeLike(asString(firstLocation?.path), root); + findings.push({ + id: randomUUID(), + title: `${vulnId} in ${packageName}`, + description: asString(vulnerability.description), + category: "dependency", + severity: normalizeSeverity(vulnerability.severity), + confidence: 0.96, + scanner: { name: "grype", ruleId: vulnId }, + location: path ? { path } : undefined, + identifiers: identifiersFrom([vulnId, ...aliases]), + remediation: fixedVersions.length ? `Upgrade ${packageName} to ${fixedVersions[0]} or another listed fixed version.` : undefined, + metadata: { + package: packageName, + version: packageVersion, + type: asString(artifact.type), + purl: asString(artifact.purl), + dataSource: asString(vulnerability.dataSource), + namespace: asString(vulnerability.namespace), + fixedVersions, + fixState: asString(fix?.state), + }, + }); + } + return findings; +} + +export class GrypeAdapter implements ScannerAdapter { + readonly id = "grype"; + readonly displayName = "Grype"; + readonly capabilities = ["dependency", "container"] as const; + + constructor(private readonly processRunner: ScannerProcessRunner = runProcess) {} + + checkAvailability(): Promise { + return commandAvailability("grype", ["version"], this.displayName, this.processRunner); + } + + async scan(context: ScannerContext): Promise { + const startedAt = new Date().toISOString(); + const output = await this.processRunner( + "grype", + [`dir:${context.target.path}`, "-o", "json", "--quiet"], + { timeoutMs: context.timeoutMs ?? 10 * 60_000, signal: context.signal }, + ); + if (output.exitCode !== 0) throw new Error(`Grype scan failed (${output.exitCode}): ${output.stderr.trim()}`); + return { + scanner: this.id, + startedAt, + completedAt: new Date().toISOString(), + target: context.target, + findings: parseGrypeJson(output.stdout, context.target.path), + diagnostics: output.stderr.trim() ? [output.stderr.trim()] : [], + }; + } +} diff --git a/packages/scanners/src/index.ts b/packages/scanners/src/index.ts index 32031dfe..ee9ee0a9 100644 --- a/packages/scanners/src/index.ts +++ b/packages/scanners/src/index.ts @@ -1,201 +1,107 @@ -import { randomUUID } from "node:crypto"; -import type { Finding, ScanResult, Severity } from "@synsec/core"; -import type { - ScannerAdapter, - ScannerAvailability, - ScannerContext, -} from "@synsec/scanner-sdk"; -import { runProcess } from "@synsec/scanner-sdk"; - -type UnknownRecord = Record; - -function asRecord(value: unknown): UnknownRecord | undefined { - return typeof value === "object" && value !== null ? (value as UnknownRecord) : undefined; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function asNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; +import { AsyncLocalStorage } from "node:async_hooks"; +import type { ScannerAdapter } from "@synsec/scanner-sdk"; +import { BetterleaksAdapter } from "./betterleaks.js"; +import { CheckovAdapter } from "./checkov.js"; +import { GitleaksAdapter } from "./gitleaks.js"; +import { GrypeAdapter } from "./grype.js"; +import { OpengrepAdapter } from "./opengrep.js"; +import { OsvScannerAdapter } from "./osv.js"; +import { ScorecardAdapter } from "./scorecard.js"; +import { SyftAdapter } from "./syft.js"; +import { TrivyAdapter } from "./trivy.js"; + +export { BetterleaksAdapter, parseBetterleaksJson } from "./betterleaks.js"; +export { CheckovAdapter, buildCheckovArguments, parseCheckovJson } from "./checkov.js"; +export { GitleaksAdapter, normalizeGitleaksChangedFiles, parseGitleaksJson } from "./gitleaks.js"; +export { GrypeAdapter, parseGrypeJson } from "./grype.js"; +export { OpengrepAdapter, parseOpengrepJson } from "./opengrep.js"; +export { OsvScannerAdapter, buildOsvArguments, parseOsvJson } from "./osv.js"; +export { parseSarifJson } from "./sarif.js"; +export { ScorecardAdapter, parseScorecardJson } from "./scorecard.js"; +export { SyftAdapter, parseSyftJson } from "./syft.js"; +export { TrivyAdapter, normalizeTrivyChangedFiles, parseTrivyJson } from "./trivy.js"; +export { + createOciIsolatedScanners, + createOciIsolatedDependencyScanners, + type OciIsolatedScannerOptions, + type OciIsolatedDependencyScannerOptions, +} from "./oci-isolated.js"; + +const NATIVE_CHANGED_FILE_SCANNERS = new Set([ + "opengrep", + "betterleaks", + "gitleaks", + "checkov", + "trivy", + "osv-scanner", +]); + +export type BuiltInScannerFactory = () => ScannerAdapter[]; +const scopedScannerFactory = new AsyncLocalStorage(); + +/** + * Whether a built-in adapter can ask its underlying scanner to execute against a bounded + * changed-file target rather than scanning the whole repository and filtering findings later. + * Individual adapters may still fail closed to repository execution for ambiguous local inputs. + */ +export function scannerSupportsNativeChangedFiles(scannerId: string): boolean { + return NATIVE_CHANGED_FILE_SCANNERS.has(scannerId); } -function asArray(value: unknown): unknown[] { - return Array.isArray(value) ? value : []; +function defaultBuiltInScanners(): ScannerAdapter[] { + return [ + new OpengrepAdapter(), + new BetterleaksAdapter(), + new GitleaksAdapter(), + new OsvScannerAdapter(), + new TrivyAdapter(), + new GrypeAdapter(), + new CheckovAdapter(), + new SyftAdapter(), + new ScorecardAdapter(), + ]; } -function normalizeSeverity(value: unknown): Severity { - const severity = asString(value)?.toLowerCase(); - if ( - severity === "critical" || - severity === "high" || - severity === "medium" || - severity === "low" || - severity === "info" - ) { - return severity; +function validateFactoryOutput(scanners: ScannerAdapter[]): ScannerAdapter[] { + if (!Array.isArray(scanners) || scanners.length === 0) { + throw new Error("Scoped scanner factory must provide at least one scanner adapter."); } - return "unknown"; -} - -function trivyLocation(target: string | undefined, line?: number) { - if (!target) return undefined; - return line ? { path: target, startLine: line } : { path: target }; -} - -function parseTrivyVulnerability(item: UnknownRecord, target?: string): Finding { - const cve = asString(item.VulnerabilityID); - const title = asString(item.Title) ?? cve ?? "Dependency vulnerability"; - const pkg = asString(item.PkgName); - const installed = asString(item.InstalledVersion); - const fixed = asString(item.FixedVersion); - - const remediation = fixed - ? `Upgrade ${pkg ?? "the affected dependency"} to ${fixed} or later.` - : undefined; - - return { - id: randomUUID(), - title: pkg ? `${title} in ${pkg}` : title, - description: asString(item.Description), - category: "dependency", - severity: normalizeSeverity(item.Severity), - confidence: 0.95, - scanner: { - name: "trivy", - ruleId: cve, - }, - location: trivyLocation(target), - identifiers: cve ? { cve: [cve] } : undefined, - remediation, - metadata: { - package: pkg, - installedVersion: installed, - fixedVersion: fixed, - primaryUrl: asString(item.PrimaryURL), - }, - }; -} - -function parseTrivySecret(item: UnknownRecord, target?: string): Finding { - const ruleId = asString(item.RuleID); - const startLine = asNumber(item.StartLine); - return { - id: randomUUID(), - title: asString(item.Title) ?? ruleId ?? "Potential secret detected", - description: asString(item.Category), - category: "secret", - severity: normalizeSeverity(item.Severity), - confidence: 0.9, - scanner: { - name: "trivy", - ruleId, - }, - location: trivyLocation(target, startLine), - evidence: asString(item.Match), - remediation: "Revoke or rotate the exposed credential, then remove it from the repository and history where appropriate.", - }; -} - -function parseTrivyMisconfiguration(item: UnknownRecord, target?: string): Finding { - const ruleId = asString(item.ID) ?? asString(item.AVDID); - return { - id: randomUUID(), - title: asString(item.Title) ?? ruleId ?? "Configuration issue", - description: asString(item.Description) ?? asString(item.Message), - category: "misconfiguration", - severity: normalizeSeverity(item.Severity), - confidence: 0.9, - scanner: { - name: "trivy", - ruleId, - }, - location: trivyLocation(target), - remediation: asString(item.Resolution), - metadata: { - namespace: asString(item.Namespace), - primaryUrl: asString(item.PrimaryURL), - }, - }; -} - -function parseTrivyJson(raw: string): Finding[] { - const parsed = asRecord(JSON.parse(raw)); - if (!parsed) return []; - - const findings: Finding[] = []; - - for (const resultValue of asArray(parsed.Results)) { - const result = asRecord(resultValue); - if (!result) continue; - const target = asString(result.Target); - - for (const value of asArray(result.Vulnerabilities)) { - const item = asRecord(value); - if (item) findings.push(parseTrivyVulnerability(item, target)); - } - - for (const value of asArray(result.Secrets)) { - const item = asRecord(value); - if (item) findings.push(parseTrivySecret(item, target)); - } - - for (const value of asArray(result.Misconfigurations)) { - const item = asRecord(value); - if (item) findings.push(parseTrivyMisconfiguration(item, target)); + const ids = new Set(); + for (const scanner of scanners) { + if (!scanner || typeof scanner.id !== "string" || !scanner.id.trim()) { + throw new Error("Scoped scanner factory returned an invalid scanner adapter."); } + if (ids.has(scanner.id)) throw new Error("Scoped scanner factory returned duplicate scanner ids."); + ids.add(scanner.id); } - - return findings; + return scanners; } -export class TrivyAdapter implements ScannerAdapter { - readonly id = "trivy"; - readonly displayName = "Trivy"; - readonly capabilities = ["dependency", "secret", "iac", "container"] as const; - - async checkAvailability(): Promise { - try { - const output = await runProcess("trivy", ["--version"], { timeoutMs: 10_000 }); - if (output.exitCode !== 0) { - return { available: false, reason: output.stderr.trim() || "Trivy returned a non-zero exit code." }; - } - return { available: true, version: output.stdout.trim() }; - } catch (error) { - return { - available: false, - reason: error instanceof Error ? error.message : "Trivy is not available.", - }; - } - } - - async scan(context: ScannerContext): Promise { - const startedAt = new Date().toISOString(); - const output = await runProcess( - "trivy", - ["fs", "--format", "json", "--scanners", "vuln,secret,misconfig", context.target.path], - { - timeoutMs: context.timeoutMs ?? 10 * 60_000, - signal: context.signal, - }, - ); - - if (output.exitCode !== 0) { - throw new Error(`Trivy scan failed (${output.exitCode}): ${output.stderr.trim()}`); - } - - return { - scanner: this.id, - startedAt, - completedAt: new Date().toISOString(), - target: context.target, - findings: parseTrivyJson(output.stdout), - diagnostics: output.stderr.trim() ? [output.stderr.trim()] : [], - }; - } +/** + * Return the scanner set active for the current asynchronous execution context. + * + * Normal CLI/local scans continue to use the host-backed built-ins. Production hosting code can + * establish a narrower process-boundary-specific factory for one asynchronous operation without + * mutating global adapter state or bleeding configuration into concurrent scans. + */ +export function builtInScanners(): ScannerAdapter[] { + const factory = scopedScannerFactory.getStore(); + return factory ? validateFactoryOutput(factory()) : defaultBuiltInScanners(); } -export function builtInScanners(): ScannerAdapter[] { - return [new TrivyAdapter()]; +/** + * Run one asynchronous operation with a context-local scanner factory. + * + * This is an execution-composition primitive, not an isolation assertion by itself. Callers are + * responsible for supplying adapters whose process runner actually enforces the claimed boundary. + * AsyncLocalStorage keeps concurrent hosted jobs from replacing one another's scanner set. + */ +export function withBuiltInScannerFactory( + factory: BuiltInScannerFactory, + operation: () => Promise, +): Promise { + if (typeof factory !== "function" || typeof operation !== "function") { + throw new Error("Scoped scanner factory and operation are required."); + } + return scopedScannerFactory.run(factory, operation); } diff --git a/packages/scanners/src/oci-isolated.ts b/packages/scanners/src/oci-isolated.ts new file mode 100644 index 00000000..371aed01 --- /dev/null +++ b/packages/scanners/src/oci-isolated.ts @@ -0,0 +1,41 @@ +import type { ScannerAdapter } from "@synsec/scanner-sdk"; +import { + createOciScannerProcessRunner, + type OciScannerSandboxOptions, +} from "@synsec/scanner-sdk/oci-sandbox"; +import { CheckovAdapter } from "./checkov.js"; +import { GrypeAdapter } from "./grype.js"; +import { SyftAdapter } from "./syft.js"; + +export interface OciIsolatedScannerOptions extends OciScannerSandboxOptions {} +export interface OciIsolatedDependencyScannerOptions extends OciIsolatedScannerOptions {} + +/** + * Build the scanner subset whose adapters support the enforced OCI process boundary for both + * availability and scan execution. + * + * The digest-pinned image must contain `checkov`, `grype`, and `syft`. Because the sandbox deliberately + * uses network=none, production images must also contain any vulnerability database/cache material + * required by the pinned Grype version. Checkov's bundled IaC checks do not require network access; + * SynSec never widens the container to bridge networking to make an unprepared scanner image succeed. + * + * This helper intentionally returns only adapters that are fully runner-injectable. It must not be + * merged with host-backed built-ins and represented as complete scanner isolation. + */ +export function createOciIsolatedScanners( + options: OciIsolatedScannerOptions, +): ScannerAdapter[] { + const runner = createOciScannerProcessRunner(options); + return [new CheckovAdapter(runner), new GrypeAdapter(runner), new SyftAdapter(runner)]; +} + +/** + * Backward-compatible dependency/SBOM-only composition for callers that intentionally do not want + * IaC scanning. This remains an enforced OCI path and does not imply broader scanner isolation. + */ +export function createOciIsolatedDependencyScanners( + options: OciIsolatedDependencyScannerOptions, +): ScannerAdapter[] { + const runner = createOciScannerProcessRunner(options); + return [new GrypeAdapter(runner), new SyftAdapter(runner)]; +} diff --git a/packages/scanners/src/opengrep.ts b/packages/scanners/src/opengrep.ts new file mode 100644 index 00000000..946413ec --- /dev/null +++ b/packages/scanners/src/opengrep.ts @@ -0,0 +1,107 @@ +import { randomUUID } from "node:crypto"; +import { resolve } from "node:path"; +import type { Finding, ScanResult } from "@synsec/core"; +import type { ScannerAdapter, ScannerAvailability, ScannerContext } from "@synsec/scanner-sdk"; +import { runProcess } from "@synsec/scanner-sdk"; +import { asArray, asRecord, asString, commandAvailability, identifiersFrom, normalizeSeverity, relativeLike, safeJson, strings } from "./utils.js"; + +function metadataIdentifiers(metadata: Record | undefined): string[] { + if (!metadata) return []; + const values: string[] = []; + for (const key of ["cwe", "cve"]) { + const value = metadata[key]; + if (typeof value === "string") values.push(value); + else values.push(...strings(value)); + } + return values.flatMap((value) => value.split(/[,;]\s*/)).map((value) => value.trim()).filter(Boolean); +} + +export function parseOpengrepJson(raw: string, root = ""): Finding[] { + const parsed = asRecord(safeJson(raw)); + if (!parsed) return []; + const findings: Finding[] = []; + for (const value of asArray(parsed.results)) { + const result = asRecord(value); + if (!result) continue; + const extra = asRecord(result.extra); + const start = asRecord(result.start); + const end = asRecord(result.end); + const metadata = asRecord(extra?.metadata); + const ruleId = asString(result.check_id); + const message = asString(extra?.message) ?? ruleId ?? "Static analysis finding"; + const path = relativeLike(asString(result.path), root); + const fingerprint = asString(extra?.fingerprint); + const fix = asString(extra?.fix); + findings.push({ + id: randomUUID(), + title: message, + description: asString(metadata?.description) ?? message, + category: "sast", + severity: normalizeSeverity(extra?.severity), + confidence: 0.9, + scanner: { name: "opengrep", ruleId }, + location: path ? { + path, + startLine: typeof start?.line === "number" ? start.line : undefined, + endLine: typeof end?.line === "number" ? end.line : undefined, + startColumn: typeof start?.col === "number" ? start.col : undefined, + endColumn: typeof end?.col === "number" ? end.col : undefined, + } : undefined, + identifiers: identifiersFrom(metadataIdentifiers(metadata)), + evidence: asString(extra?.lines), + remediation: fix ?? asString(metadata?.fix), + fingerprint, + metadata: { + technology: metadata?.technology, + references: metadata?.references, + owasp: metadata?.owasp, + likelihood: metadata?.likelihood, + impact: metadata?.impact, + confidence: metadata?.confidence, + }, + }); + } + return findings; +} + +export class OpengrepAdapter implements ScannerAdapter { + readonly id = "opengrep"; + readonly displayName = "Opengrep"; + readonly capabilities = ["sast"] as const; + + checkAvailability(): Promise { + return commandAvailability("opengrep", ["--version"], this.displayName); + } + + async scan(context: ScannerContext): Promise { + const startedAt = new Date().toISOString(); + if (context.changedFiles && context.changedFiles.length === 0) { + return { + scanner: this.id, + startedAt, + completedAt: new Date().toISOString(), + target: context.target, + findings: [], + diagnostics: ["Changed-file scope is empty; Opengrep was not invoked."], + }; + } + + const targets = context.changedFiles + ? context.changedFiles.map((path) => resolve(context.target.path, path)) + : [context.target.path]; + const output = await runProcess( + "opengrep", + ["scan", "--json", "--config", "auto", "--metrics", "off", "--taint-intrafile", ...targets], + { timeoutMs: context.timeoutMs ?? 15 * 60_000, signal: context.signal }, + ); + if (output.exitCode !== 0) throw new Error(`Opengrep scan failed (${output.exitCode}): ${output.stderr.trim()}`); + return { + scanner: this.id, + startedAt, + completedAt: new Date().toISOString(), + target: context.target, + findings: parseOpengrepJson(output.stdout, context.target.path), + diagnostics: output.stderr.trim() ? [output.stderr.trim()] : [], + }; + } +} diff --git a/packages/scanners/src/osv.ts b/packages/scanners/src/osv.ts new file mode 100644 index 00000000..47b60bcf --- /dev/null +++ b/packages/scanners/src/osv.ts @@ -0,0 +1,242 @@ +import { randomUUID } from "node:crypto"; +import { lstatSync, realpathSync } from "node:fs"; +import { basename, isAbsolute, relative, resolve, sep } from "node:path"; +import type { Finding, ScanResult, Severity } from "@synsec/core"; +import type { ScannerAdapter, ScannerAvailability, ScannerContext } from "@synsec/scanner-sdk"; +import { runProcess } from "@synsec/scanner-sdk"; +import { asArray, asRecord, asString, commandAvailability, cvssSeverity, identifiersFrom, normalizeSeverity, relativeLike, safeJson, strings } from "./utils.js"; + +const MAX_CHANGED_FILES = 100; +const EXECUTION_INTERPRETATION = "scanner-execution-scope-not-coverage-proof" as const; +const OSV_LOCKFILE_NAMES = new Set([ + "Cargo.lock", + "Gemfile.lock", + "buildscript-gradle.lockfile", + "bun.lock", + "composer.lock", + "go.mod", + "gradle.lockfile", + "mix.lock", + "package-lock.json", + "pnpm-lock.yaml", + "poetry.lock", + "pom.xml", + "pubspec.lock", + "requirements.txt", + "uv.lock", + "yarn.lock", +]); + +function directSeverity(vuln: Record): Severity { + const databaseSpecific = asRecord(vuln.database_specific); + const direct = normalizeSeverity(databaseSpecific?.severity); + if (direct !== "unknown") return direct; + + let best = 0; + for (const value of asArray(vuln.severity)) { + const entry = asRecord(value); + const score = asString(entry?.score); + if (!score) continue; + const numeric = Number.parseFloat(score); + if (Number.isFinite(numeric)) best = Math.max(best, numeric); + } + return best > 0 ? cvssSeverity(best) : "unknown"; +} + +function firstFixedVersion(vuln: Record): string | undefined { + for (const affectedValue of asArray(vuln.affected)) { + const affected = asRecord(affectedValue); + if (!affected) continue; + for (const rangeValue of asArray(affected.ranges)) { + const range = asRecord(rangeValue); + if (!range) continue; + for (const eventValue of asArray(range.events)) { + const event = asRecord(eventValue); + const fixed = asString(event?.fixed); + if (fixed) return fixed; + } + } + } + return undefined; +} + +function normalizedChangedPath(value: string): string | undefined { + const path = value.replace(/\\/g, "/").replace(/^\.\//, "").trim(); + if ( + !path || + isAbsolute(path) || + /^[A-Za-z]:\//.test(path) || + path === ".." || + path.startsWith("../") || + path.includes("/../") || + path.includes("\0") + ) return undefined; + return path; +} + +function isSupportedOsvDependencyArtifact(path: string): boolean { + const name = basename(path); + return OSV_LOCKFILE_NAMES.has(name) + || /^requirements(?:[-_.][A-Za-z0-9_.-]+)?\.txt$/i.test(name) + || /(?:^|\.)spdx(?:\.json|\.ya?ml|\.rdf|\.rdf\.xml)?$/i.test(name) + || /(?:^|\.)cdx\.(?:json|xml)$/i.test(name) + || /^bom\.(?:json|xml)$/i.test(name); +} + +function safeNativeLockfiles(context: ScannerContext): string[] | undefined { + const changed = context.changedFiles; + if (!changed || changed.length === 0) return undefined; + if (changed.length > MAX_CHANGED_FILES) return undefined; + + let root: string; + try { + root = realpathSync(context.target.path); + } catch { + return undefined; + } + const rootPrefix = root.endsWith(sep) ? root : `${root}${sep}`; + const result: string[] = []; + const seen = new Set(); + + for (const raw of changed) { + const normalized = normalizedChangedPath(raw); + if (!normalized || !isSupportedOsvDependencyArtifact(normalized)) return undefined; + if (seen.has(normalized)) continue; + + const absolute = resolve(root, normalized); + const relativePath = relative(root, absolute); + if (!relativePath || relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) { + return undefined; + } + + try { + const stat = lstatSync(absolute); + if (!stat.isFile() || stat.isSymbolicLink()) return undefined; + const real = realpathSync(absolute); + if (!real.startsWith(rootPrefix)) return undefined; + } catch { + return undefined; + } + + seen.add(normalized); + result.push(absolute); + } + return result.length > 0 ? result : undefined; +} + +interface OsvScanPlan { + args: string[]; + nativeChangedFiles: boolean; +} + +function buildOsvScanPlan(context: ScannerContext): OsvScanPlan { + const lockfiles = safeNativeLockfiles(context); + if (!lockfiles) { + return { + args: ["scan", "--format", "json", "source", "-r", context.target.path], + nativeChangedFiles: false, + }; + } + return { + args: ["scan", "--format", "json", "source", ...lockfiles.map((path) => `--lockfile=${path}`)], + nativeChangedFiles: true, + }; +} + +/** + * OSV-Scanner can safely narrow execution only when the entire changed-file scope is made up of + * recognized dependency lockfiles/SBOMs that are regular files inside the repository. Any source, + * manifest/config ambiguity, missing file, symlink, or oversized scope falls back to recursive + * repository scanning rather than pretending dependency coverage is complete. + */ +export function buildOsvArguments(context: ScannerContext): string[] { + return buildOsvScanPlan(context).args; +} + +export function parseOsvJson(raw: string, root: string): Finding[] { + const parsed = asRecord(safeJson(raw)); + if (!parsed) return []; + const findings: Finding[] = []; + + for (const resultValue of asArray(parsed.results)) { + const result = asRecord(resultValue); + if (!result) continue; + const source = asRecord(result.source); + const sourcePath = relativeLike(asString(source?.path), root); + + for (const packageValue of asArray(result.packages)) { + const packageResult = asRecord(packageValue); + if (!packageResult) continue; + const pkg = asRecord(packageResult.package); + const name = asString(pkg?.name) ?? "unknown package"; + const version = asString(pkg?.version); + const ecosystem = asString(pkg?.ecosystem); + + for (const vulnerabilityValue of asArray(packageResult.vulnerabilities)) { + const vulnerability = asRecord(vulnerabilityValue); + if (!vulnerability) continue; + const id = asString(vulnerability.id) ?? "OSV vulnerability"; + const aliases = strings(vulnerability.aliases); + const fixed = firstFixedVersion(vulnerability); + const allIds = [id, ...aliases]; + findings.push({ + id: randomUUID(), + title: `${asString(vulnerability.summary) ?? id} in ${name}`, + description: asString(vulnerability.details), + category: "dependency", + severity: directSeverity(vulnerability), + confidence: 0.99, + scanner: { name: "osv-scanner", ruleId: id }, + location: sourcePath ? { path: sourcePath } : undefined, + identifiers: identifiersFrom(allIds), + remediation: fixed ? `Upgrade ${name} to ${fixed} or a later non-vulnerable version.` : `Review ${id} and upgrade or replace ${name} when a non-vulnerable version is available.`, + metadata: { + package: name, + version, + ecosystem, + fixedVersion: fixed, + published: asString(vulnerability.published), + modified: asString(vulnerability.modified), + }, + }); + } + } + } + return findings; +} + +export class OsvScannerAdapter implements ScannerAdapter { + readonly id = "osv-scanner"; + readonly displayName = "OSV-Scanner"; + readonly capabilities = ["dependency"] as const; + + checkAvailability(): Promise { + return commandAvailability("osv-scanner", ["--version"], this.displayName); + } + + async scan(context: ScannerContext): Promise { + const startedAt = new Date().toISOString(); + const plan = buildOsvScanPlan(context); + const output = await runProcess( + "osv-scanner", + plan.args, + { timeoutMs: context.timeoutMs ?? 10 * 60_000, signal: context.signal }, + ); + if (output.exitCode !== 0 && output.exitCode !== 1) { + throw new Error(`OSV-Scanner failed (${output.exitCode}): ${output.stderr.trim()}`); + } + return { + scanner: this.id, + startedAt, + completedAt: new Date().toISOString(), + target: context.target, + findings: parseOsvJson(output.stdout, context.target.path), + diagnostics: output.stderr.trim() ? [output.stderr.trim()] : [], + executionScope: context.changedFiles === undefined ? undefined : { + mode: plan.nativeChangedFiles ? "changed-files-native" : "repository-then-filtered", + changedFileCount: context.changedFiles.length, + interpretation: EXECUTION_INTERPRETATION, + }, + }; + } +} diff --git a/packages/scanners/src/sarif.ts b/packages/scanners/src/sarif.ts new file mode 100644 index 00000000..f710ca57 --- /dev/null +++ b/packages/scanners/src/sarif.ts @@ -0,0 +1,139 @@ +import { randomUUID } from "node:crypto"; +import type { Finding, FindingCategory, Severity } from "@synsec/core"; +import { asArray, asNumber, asRecord, asString, identifiersFrom, relativeLike, safeJson } from "./utils.js"; + +const categories = new Set([ + "sast", + "dependency", + "secret", + "misconfiguration", + "iac", + "container", + "supply-chain", + "repository-posture", + "license", + "other", +]); + +function severityFrom(value: unknown): Severity { + const normalized = asString(value)?.toLowerCase(); + if (normalized === "critical" || normalized === "high" || normalized === "medium" || normalized === "low" || normalized === "info" || normalized === "unknown") return normalized; + if (normalized === "error") return "high"; + if (normalized === "warning") return "medium"; + if (normalized === "note") return "low"; + if (normalized === "none") return "info"; + return "unknown"; +} + +function categoryFrom(value: unknown): FindingCategory { + const category = asString(value) as FindingCategory | undefined; + return category && categories.has(category) ? category : "other"; +} + +function text(value: unknown): string | undefined { + if (typeof value === "string") return value; + const record = asRecord(value); + return asString(record?.text) ?? asString(record?.markdown); +} + +function nativeFingerprint(result: Record): string | undefined { + const partial = asRecord(result.partialFingerprints); + if (!partial) return undefined; + for (const value of Object.values(partial)) { + if (typeof value === "string" && value.trim()) return value; + } + return undefined; +} + +function sarifRepositoryPath(uri: string | undefined, root: string): string | undefined { + if (!uri) return undefined; + if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(uri)) { + if (!uri.toLowerCase().startsWith("file:")) return undefined; + try { + let pathname = decodeURIComponent(new URL(uri).pathname).replace(/\\/g, "/"); + if (/^\/[A-Za-z]:\//.test(pathname)) pathname = pathname.slice(1); + return relativeLike(pathname, root); + } catch { + return undefined; + } + } + return relativeLike(uri, root); +} + +function firstLocation(result: Record, root: string): Finding["location"] { + const location = asRecord(asArray(result.locations)[0]); + const physical = asRecord(location?.physicalLocation); + const artifact = asRecord(physical?.artifactLocation); + const region = asRecord(physical?.region); + const path = sarifRepositoryPath(asString(artifact?.uri), root); + if (!path) return undefined; + return { + path, + startLine: asNumber(region?.startLine), + endLine: asNumber(region?.endLine), + startColumn: asNumber(region?.startColumn), + endColumn: asNumber(region?.endColumn), + }; +} + +function ruleMap(run: Record): Map> { + const tool = asRecord(run.tool); + const driver = asRecord(tool?.driver); + const map = new Map>(); + for (const value of asArray(driver?.rules)) { + const rule = asRecord(value); + const id = asString(rule?.id); + if (rule && id) map.set(id, rule); + } + return map; +} + +export function parseSarifJson(raw: string, scannerOverride?: string, root = ""): Finding[] { + const parsed = asRecord(safeJson(raw)); + if (!parsed || asString(parsed.version) !== "2.1.0") { + throw new Error("SARIF import requires a SARIF 2.1.0 document."); + } + + const findings: Finding[] = []; + for (const runValue of asArray(parsed.runs)) { + const run = asRecord(runValue); + if (!run) continue; + const tool = asRecord(run.tool); + const driver = asRecord(tool?.driver); + const scannerName = scannerOverride ?? asString(driver?.name) ?? "sarif-import"; + const rules = ruleMap(run); + + for (const resultValue of asArray(run.results)) { + const result = asRecord(resultValue); + if (!result) continue; + const ruleId = asString(result.ruleId); + const rule = ruleId ? rules.get(ruleId) : undefined; + const properties = asRecord(result.properties); + const ruleProperties = asRecord(rule?.properties); + const message = text(result.message); + const title = message ?? text(rule?.shortDescription) ?? ruleId ?? "Imported SARIF finding"; + const description = text(rule?.fullDescription) ?? text(rule?.shortDescription); + const identifiers = asArray(ruleProperties?.identifiers).filter((item): item is string => typeof item === "string"); + const confidence = asNumber(properties?.confidence); + const finding: Finding = { + id: randomUUID(), + title, + description, + category: categoryFrom(properties?.category ?? ruleProperties?.category), + severity: severityFrom(properties?.severity ?? result.level ?? ruleProperties?.severity), + confidence: confidence !== undefined && confidence >= 0 && confidence <= 1 ? confidence : 0.8, + scanner: { name: scannerName, ruleId }, + location: firstLocation(result, root), + identifiers: identifiersFrom(identifiers), + remediation: asString(properties?.remediation) ?? text(rule?.help), + fingerprint: nativeFingerprint(result), + metadata: { + sarifRuleIndex: asNumber(result.ruleIndex), + sarifToolVersion: asString(driver?.semanticVersion) ?? asString(driver?.version), + }, + }; + findings.push(finding); + } + } + return findings; +} diff --git a/packages/scanners/src/scorecard.ts b/packages/scanners/src/scorecard.ts new file mode 100644 index 00000000..4b2c1d8d --- /dev/null +++ b/packages/scanners/src/scorecard.ts @@ -0,0 +1,95 @@ +import { randomUUID } from "node:crypto"; +import type { Finding, ScanResult, Severity } from "@synsec/core"; +import type { ScannerAdapter, ScannerAvailability, ScannerContext } from "@synsec/scanner-sdk"; +import { runProcess } from "@synsec/scanner-sdk"; +import { asArray, asNumber, asRecord, asString, commandAvailability, safeJson } from "./utils.js"; + +function severityForScore(score: number | undefined): Severity { + if (score === undefined || score < 0) return "unknown"; + if (score <= 3) return "high"; + if (score <= 6) return "medium"; + if (score <= 8) return "low"; + return "info"; +} + +function confidenceForScore(score: number | undefined): number { + if (score === undefined || score < 0) return 0.6; + return 0.9; +} + +export function parseScorecardJson(raw: string): Finding[] { + const parsed = asRecord(safeJson(raw)); + if (!parsed) return []; + + const findings: Finding[] = []; + for (const value of asArray(parsed.checks)) { + const check = asRecord(value); + if (!check) continue; + const name = asString(check.name) ?? "Repository posture check"; + const score = asNumber(check.score); + + // A perfect check is evidence of good posture rather than a vulnerability. + // Preserve aggregate/report metadata elsewhere instead of manufacturing a finding. + if (score === 10) continue; + + const documentation = asRecord(check.documentation); + const reason = asString(check.reason); + const details = asArray(check.details).filter((item): item is string => typeof item === "string"); + const shortDoc = asString(documentation?.short); + const docUrl = asString(documentation?.url); + + findings.push({ + id: randomUUID(), + title: `${name} repository posture check scored ${score ?? "unknown"}/10`, + description: reason ?? shortDoc ?? `OpenSSF Scorecard reported a non-perfect result for ${name}.`, + category: "repository-posture", + severity: severityForScore(score), + confidence: confidenceForScore(score), + scanner: { name: "scorecard", ruleId: name }, + remediation: shortDoc, + metadata: { + score, + reason, + details, + documentation: docUrl, + }, + }); + } + return findings; +} + +export class ScorecardAdapter implements ScannerAdapter { + readonly id = "scorecard"; + readonly displayName = "OpenSSF Scorecard"; + readonly capabilities = ["repository-posture"] as const; + + checkAvailability(): Promise { + return commandAvailability("scorecard", ["--version"], this.displayName); + } + + async scan(context: ScannerContext): Promise { + const startedAt = new Date().toISOString(); + const output = await runProcess( + "scorecard", + [ + `--local=${context.target.path}`, + "--format=json", + "--show-details", + ], + { timeoutMs: context.timeoutMs ?? 15 * 60_000, signal: context.signal }, + ); + + if (output.exitCode !== 0) { + throw new Error(`OpenSSF Scorecard scan failed (${output.exitCode}): ${output.stderr.trim()}`); + } + + return { + scanner: this.id, + startedAt, + completedAt: new Date().toISOString(), + target: context.target, + findings: parseScorecardJson(output.stdout), + diagnostics: output.stderr.trim() ? [output.stderr.trim()] : [], + }; + } +} diff --git a/packages/scanners/src/syft.ts b/packages/scanners/src/syft.ts new file mode 100644 index 00000000..38b9e14e --- /dev/null +++ b/packages/scanners/src/syft.ts @@ -0,0 +1,116 @@ +import type { SbomArtifact, SbomPackage, ScanResult } from "@synsec/core"; +import type { + ScannerAdapter, + ScannerAvailability, + ScannerContext, + ScannerProcessRunner, +} from "@synsec/scanner-sdk"; +import { runProcess } from "@synsec/scanner-sdk"; +import { asArray, asRecord, asString, commandAvailability, relativeLike, safeJson } from "./utils.js"; + +function licenseValues(value: unknown): string[] | undefined { + const values = asArray(value) + .flatMap((entry) => { + if (typeof entry === "string") return [entry]; + const record = asRecord(entry); + if (!record) return []; + const expression = asString(record.spdxExpression); + const raw = asString(record.value); + return expression ? [expression] : raw ? [raw] : []; + }) + .map((item) => item.trim()) + .filter(Boolean); + return values.length > 0 ? [...new Set(values)] : undefined; +} + +function locationValues(value: unknown, root: string): string[] | undefined { + const values = asArray(value) + .map(asRecord) + .map((location) => relativeLike(asString(location?.path), root)) + .filter((item): item is string => Boolean(item)); + return values.length > 0 ? [...new Set(values)] : undefined; +} + +function packageFrom(value: unknown, root: string): SbomPackage | undefined { + const item = asRecord(value); + if (!item) return undefined; + const name = asString(item.name); + if (!name) return undefined; + + const pkg: SbomPackage = { name }; + const version = asString(item.version); + const type = asString(item.type); + const purl = asString(item.purl); + const licenses = licenseValues(item.licenses); + const locations = locationValues(item.locations, root); + if (version) pkg.version = version; + if (type) pkg.type = type; + if (purl) pkg.purl = purl; + if (licenses) pkg.licenses = licenses; + if (locations) pkg.locations = locations; + return pkg; +} + +export function parseSyftJson(raw: string, root: string, generatedAt = new Date().toISOString()): SbomArtifact { + const parsed = asRecord(safeJson(raw)); + if (!parsed) throw new Error("Syft returned an unsupported JSON document."); + + const packages = asArray(parsed.artifacts) + .map((value) => packageFrom(value, root)) + .filter((value): value is SbomPackage => Boolean(value)); + + const descriptor = asRecord(parsed.descriptor); + const source = asRecord(parsed.source); + const distro = asRecord(parsed.distro); + + return { + type: "sbom", + format: "syft-json", + producer: "syft", + generatedAt, + packageCount: packages.length, + packages, + metadata: { + syftVersion: asString(descriptor?.version), + sourceId: asString(source?.id), + sourceName: asString(source?.name), + sourceVersion: asString(source?.version), + distroName: asString(distro?.name), + distroVersion: asString(distro?.version), + }, + }; +} + +export class SyftAdapter implements ScannerAdapter { + readonly id = "syft"; + readonly displayName = "Syft"; + readonly capabilities = ["sbom"] as const; + + constructor(private readonly processRunner: ScannerProcessRunner = runProcess) {} + + checkAvailability(): Promise { + return commandAvailability("syft", ["version"], this.displayName, this.processRunner); + } + + async scan(context: ScannerContext): Promise { + const startedAt = new Date().toISOString(); + const output = await this.processRunner( + "syft", + [`dir:${context.target.path}`, "-o", "syft-json"], + { timeoutMs: context.timeoutMs ?? 10 * 60_000, signal: context.signal }, + ); + if (output.exitCode !== 0) { + throw new Error(`Syft scan failed (${output.exitCode}): ${output.stderr.trim()}`); + } + const completedAt = new Date().toISOString(); + return { + scanner: this.id, + startedAt, + completedAt, + target: context.target, + findings: [], + diagnostics: output.stderr.trim() ? [output.stderr.trim()] : [], + artifacts: [parseSyftJson(output.stdout, context.target.path, completedAt)], + }; + } +} diff --git a/packages/scanners/src/trivy.ts b/packages/scanners/src/trivy.ts new file mode 100644 index 00000000..513e16e3 --- /dev/null +++ b/packages/scanners/src/trivy.ts @@ -0,0 +1,237 @@ +import { randomUUID } from "node:crypto"; +import { copyFile, lstat, mkdir, mkdtemp, rm } from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import type { Finding, ScannerExecutionScope, ScanResult } from "@synsec/core"; +import type { ScannerAdapter, ScannerAvailability, ScannerContext } from "@synsec/scanner-sdk"; +import { runProcess } from "@synsec/scanner-sdk"; +import { asArray, asNumber, asRecord, asString, commandAvailability, normalizeSeverity, relativeLike, safeJson } from "./utils.js"; + +const MAX_CHANGED_FILES = 500; +const EXECUTION_INTERPRETATION = "scanner-execution-scope-not-coverage-proof" as const; + +export function normalizeTrivyChangedFiles(files: readonly string[] | undefined): string[] | undefined { + if (files === undefined) return undefined; + if (files.length === 0) return []; + if (files.length > MAX_CHANGED_FILES) { + throw new Error(`Trivy changed-file scope exceeds the ${MAX_CHANGED_FILES}-file adapter limit.`); + } + + const result: string[] = []; + const seen = new Set(); + for (const value of files) { + const path = value.replace(/\\/g, "/").replace(/^\.\//, "").trim(); + if ( + !path || + isAbsolute(path) || + /^[A-Za-z]:\//.test(path) || + path === ".." || + path.startsWith("../") || + path.includes("/../") || + path.includes("\0") + ) { + throw new Error("Trivy changed-file scope contains an unsafe repository path."); + } + if (!seen.has(path)) { + seen.add(path); + result.push(path); + } + } + return result; +} + +async function stageChangedFiles( + repositoryRoot: string, + stagingRoot: string, + files: readonly string[], +): Promise<{ staged: true } | { staged: false; reason: string }> { + await mkdir(stagingRoot, { recursive: true }); + const resolvedRepositoryRoot = resolve(repositoryRoot); + const resolvedStagingRoot = resolve(stagingRoot); + + for (const path of files) { + const source = resolve(resolvedRepositoryRoot, path); + const sourceRelative = relative(resolvedRepositoryRoot, source); + if (!sourceRelative || sourceRelative === ".." || sourceRelative.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(sourceRelative)) { + return { staged: false, reason: "changed scope escaped the repository root" }; + } + const info = await lstat(source).catch(() => undefined); + if (!info?.isFile() || info.isSymbolicLink()) { + return { staged: false, reason: "changed scope contains a missing, symlink, or non-regular file" }; + } + + const destination = resolve(resolvedStagingRoot, path); + const destinationRelative = relative(resolvedStagingRoot, destination); + if (!destinationRelative || destinationRelative === ".." || destinationRelative.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(destinationRelative)) { + return { staged: false, reason: "staged scope escaped the temporary root" }; + } + await mkdir(dirname(destination), { recursive: true }); + await copyFile(source, destination); + } + return { staged: true }; +} + +function location(target: string | undefined, root: string, line?: number) { + const path = relativeLike(target, root); + if (!path) return undefined; + return line ? { path, startLine: line } : { path }; +} + +function vulnerability(item: Record, target: string | undefined, root: string): Finding { + const id = asString(item.VulnerabilityID); + const pkg = asString(item.PkgName); + const fixed = asString(item.FixedVersion); + const title = asString(item.Title) ?? id ?? "Dependency vulnerability"; + return { + id: randomUUID(), + title: pkg ? `${title} in ${pkg}` : title, + description: asString(item.Description), + category: "dependency", + severity: normalizeSeverity(item.Severity), + confidence: 0.95, + scanner: { name: "trivy", ruleId: id }, + location: location(target, root), + identifiers: id ? { cve: [id] } : undefined, + remediation: fixed ? `Upgrade ${pkg ?? "the affected dependency"} to ${fixed} or later.` : undefined, + metadata: { + package: pkg, + installedVersion: asString(item.InstalledVersion), + fixedVersion: fixed, + primaryUrl: asString(item.PrimaryURL), + }, + }; +} + +function secret(item: Record, target: string | undefined, root: string): Finding { + const ruleId = asString(item.RuleID); + return { + id: randomUUID(), + title: asString(item.Title) ?? ruleId ?? "Potential secret detected", + description: "A credential-like value was detected. SynSec intentionally omits Trivy's matched value from normalized output.", + category: "secret", + severity: normalizeSeverity(item.Severity), + confidence: 0.9, + scanner: { name: "trivy", ruleId }, + location: location(target, root, asNumber(item.StartLine)), + remediation: "Revoke or rotate the exposed credential, then remove it from the repository and history where appropriate.", + }; +} + +function misconfiguration(item: Record, target: string | undefined, root: string): Finding { + const ruleId = asString(item.ID) ?? asString(item.AVDID); + return { + id: randomUUID(), + title: asString(item.Title) ?? ruleId ?? "Configuration issue", + description: asString(item.Description) ?? asString(item.Message), + category: "misconfiguration", + severity: normalizeSeverity(item.Severity), + confidence: 0.9, + scanner: { name: "trivy", ruleId }, + location: location(target, root), + remediation: asString(item.Resolution), + metadata: { namespace: asString(item.Namespace), primaryUrl: asString(item.PrimaryURL) }, + }; +} + +export function parseTrivyJson(raw: string, root = ""): Finding[] { + const parsed = asRecord(safeJson(raw)); + if (!parsed) return []; + const findings: Finding[] = []; + for (const value of asArray(parsed.Results)) { + const result = asRecord(value); + if (!result) continue; + const target = asString(result.Target); + for (const entry of asArray(result.Vulnerabilities)) { + const item = asRecord(entry); + if (item) findings.push(vulnerability(item, target, root)); + } + for (const entry of asArray(result.Secrets)) { + const item = asRecord(entry); + if (item) findings.push(secret(item, target, root)); + } + for (const entry of asArray(result.Misconfigurations)) { + const item = asRecord(entry); + if (item) findings.push(misconfiguration(item, target, root)); + } + } + return findings; +} + +export class TrivyAdapter implements ScannerAdapter { + readonly id = "trivy"; + readonly displayName = "Trivy"; + readonly capabilities = ["dependency", "secret", "iac", "container"] as const; + + checkAvailability(): Promise { + return commandAvailability("trivy", ["--version"], this.displayName); + } + + async scan(context: ScannerContext): Promise { + const startedAt = new Date().toISOString(); + const changedFiles = normalizeTrivyChangedFiles(context.changedFiles); + if (changedFiles && changedFiles.length === 0) { + return { + scanner: this.id, + startedAt, + completedAt: new Date().toISOString(), + target: context.target, + findings: [], + diagnostics: ["Changed-file scope is empty; Trivy was not invoked."], + executionScope: { + mode: "changed-files-native", + changedFileCount: 0, + interpretation: EXECUTION_INTERPRETATION, + }, + }; + } + + const temp = await mkdtemp(join(tmpdir(), "synsec-trivy-")); + try { + let target = context.target.path; + let parseRoot = context.target.path; + let executionScope: ScannerExecutionScope = changedFiles + ? { + mode: "changed-files-native", + changedFileCount: changedFiles.length, + interpretation: EXECUTION_INTERPRETATION, + } + : { mode: "repository", interpretation: EXECUTION_INTERPRETATION }; + const diagnostics: string[] = []; + if (changedFiles) { + const stagingRoot = join(temp, "scope"); + const staged = await stageChangedFiles(context.target.path, stagingRoot, changedFiles); + if (staged.staged) { + target = stagingRoot; + parseRoot = stagingRoot; + diagnostics.push(`Trivy scanned ${changedFiles.length} staged changed file(s) with repository-relative paths preserved.`); + } else { + executionScope = { + mode: "repository-then-filtered", + changedFileCount: changedFiles.length, + interpretation: EXECUTION_INTERPRETATION, + }; + diagnostics.push(`Trivy changed-file staging was unsafe or ambiguous (${staged.reason}); fell back to a full repository scan.`); + } + } + + const output = await runProcess("trivy", ["fs", "--format", "json", "--scanners", "vuln,secret,misconfig", target], { + cwd: context.target.path, + timeoutMs: context.timeoutMs ?? 10 * 60_000, + signal: context.signal, + }); + if (output.exitCode !== 0) throw new Error(`Trivy scan failed (${output.exitCode}): ${output.stderr.trim()}`); + if (output.stderr.trim()) diagnostics.push(output.stderr.trim()); + return { + scanner: this.id, + startedAt, + completedAt: new Date().toISOString(), + target: context.target, + findings: parseTrivyJson(output.stdout, parseRoot), + diagnostics, + executionScope, + }; + } finally { + await rm(temp, { recursive: true, force: true }); + } + } +} diff --git a/packages/scanners/src/utils.ts b/packages/scanners/src/utils.ts new file mode 100644 index 00000000..57d9144a --- /dev/null +++ b/packages/scanners/src/utils.ts @@ -0,0 +1,113 @@ +import type { FindingIdentifiers, Severity } from "@synsec/core"; +import { runProcess, sanitizeOperationalText } from "@synsec/scanner-sdk"; +import type { ScannerAvailability, ScannerProcessRunner } from "@synsec/scanner-sdk"; + +export type UnknownRecord = Record; + +export function asRecord(value: unknown): UnknownRecord | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as UnknownRecord) + : undefined; +} + +export function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +export function asNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +export function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +export function strings(value: unknown): string[] { + return asArray(value).filter((item): item is string => typeof item === "string"); +} + +export function normalizeSeverity(value: unknown): Severity { + const severity = asString(value)?.trim().toLowerCase(); + if (severity === "critical" || severity === "high" || severity === "medium" || severity === "low" || severity === "info") return severity; + if (severity === "error") return "high"; + if (severity === "warning" || severity === "warn") return "medium"; + if (severity === "note") return "low"; + return "unknown"; +} + +export function cvssSeverity(score: number | undefined): Severity { + if (score === undefined || !Number.isFinite(score)) return "unknown"; + if (score >= 9) return "critical"; + if (score >= 7) return "high"; + if (score >= 4) return "medium"; + if (score > 0) return "low"; + return "info"; +} + +export function identifiersFrom(values: string[]): FindingIdentifiers | undefined { + const unique = [...new Set(values.map((value) => value.trim()).filter(Boolean))]; + if (unique.length === 0) return undefined; + const result: FindingIdentifiers = {}; + const cve = unique.filter((value) => /^CVE-/i.test(value)); + const cwe = unique.filter((value) => /^CWE-/i.test(value)); + const ghsa = unique.filter((value) => /^GHSA-/i.test(value)); + const osv = unique.filter((value) => !/^CVE-/i.test(value) && !/^CWE-/i.test(value) && !/^GHSA-/i.test(value)); + if (cve.length) result.cve = cve; + if (cwe.length) result.cwe = cwe; + if (ghsa.length) result.ghsa = ghsa; + if (osv.length) result.osv = osv; + return result; +} + +function normalizedPath(value: string): string { + return value.replace(/\\/g, "/"); +} + +function absoluteLike(value: string): boolean { + return value.startsWith("/") || /^[A-Za-z]:\//.test(value) || value.startsWith("//"); +} + +export function relativeLike(path: string | undefined, root: string): string | undefined { + if (!path) return undefined; + const base = normalizedPath(root).replace(/\/$/, ""); + const candidate = normalizedPath(path).trim(); + if (!candidate) return undefined; + if (candidate === base) return "."; + if (candidate.startsWith(`${base}/`)) return candidate.slice(base.length + 1); + + // Scanner output is untrusted. Do not preserve absolute host paths outside + // the repository or traversal-shaped paths in normalized reports. + if (absoluteLike(candidate)) return undefined; + const relative = candidate.replace(/^\.\//, ""); + if (relative === ".." || relative.startsWith("../") || relative.includes("/../")) return undefined; + return relative; +} + +export function safeJson(raw: string): unknown { + const trimmed = raw.trim(); + return trimmed ? (JSON.parse(trimmed) as unknown) : undefined; +} + +export async function commandAvailability( + command: string, + args: string[], + displayName: string, + runner: ScannerProcessRunner = runProcess, +): Promise { + try { + const output = await runner(command, args, { timeoutMs: 10_000 }); + if (output.exitCode !== 0) { + return { + available: false, + reason: sanitizeOperationalText(output.stderr.trim() || `${displayName} returned a non-zero exit code.`), + }; + } + const version = sanitizeOperationalText(output.stdout.trim() || output.stderr.trim()); + return { available: true, version: version || undefined }; + } catch (error) { + return { + available: false, + reason: sanitizeOperationalText(error instanceof Error ? error.message : `${displayName} is not available.`), + }; + } +} diff --git a/packages/workflows/package.json b/packages/workflows/package.json new file mode 100644 index 00000000..8a9e08c0 --- /dev/null +++ b/packages/workflows/package.json @@ -0,0 +1,20 @@ +{ + "name": "@synsec/workflows", + "version": "0.2.0", + "private": true, + "type": "module", + "exports": { + ".": "./dist/index.js", + "./routing": "./dist/routing.js", + "./user-defined": "./dist/user-defined.js", + "./remediation": "./dist/remediation.js" + }, + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@synsec/core": "0.1.0" + } +} diff --git a/packages/workflows/src/index.ts b/packages/workflows/src/index.ts new file mode 100644 index 00000000..5b8bdf57 --- /dev/null +++ b/packages/workflows/src/index.ts @@ -0,0 +1,196 @@ +import type { CorrelatedFinding, FindingCategory } from "@synsec/core"; + +export type WorkflowCapability = + | "read-normalized-findings" + | "read-repository-inventory" + | "read-bounded-source-context" + | "read-dependency-metadata" + | "read-redacted-secret-metadata" + | "read-infrastructure-config" + | "read-scan-reports" + | "read-lifecycle-state" + | "propose-remediation" + | "propose-tests"; + +export interface WorkflowDefinition { + id: string; + version: 1; + displayName: string; + description: string; + reviewInstructions: string; + categories: readonly FindingCategory[] | "all"; + capabilities: readonly WorkflowCapability[]; + sourceContextAllowed: boolean; + repositoryWriteRequiresApproval: true; + externalNetworkAssessment: "forbidden"; +} + +const workflows: readonly WorkflowDefinition[] = [ + { + id: "repository-review", + version: 1, + displayName: "Repository Review", + description: "Review normalized findings across the repository and explain the strongest evidence first.", + reviewInstructions: "Prioritize deterministic scanner evidence, actual repository reachability signals, and nearby mitigations. Do not infer an exploitable path merely from a vulnerability class or suspicious API name.", + categories: "all", + capabilities: [ + "read-normalized-findings", + "read-repository-inventory", + "read-bounded-source-context", + "propose-remediation", + "propose-tests", + ], + sourceContextAllowed: true, + repositoryWriteRequiresApproval: true, + externalNetworkAssessment: "forbidden", + }, + { + id: "remediation-pr", + version: 1, + displayName: "Remediation PR", + description: "Prepare a bounded repository-only remediation patch set for explicit human approval before any branch, commit, or pull-request write.", + reviewInstructions: "Bind every proposed change to deterministic finding evidence and the exact scanned commit. Keep changes minimal and repository-relative, include focused regression tests where appropriate, and never treat proposal generation as approval. Do not delete files, modify .git metadata, widen to unrelated findings, access live targets, or perform repository writes until an exact patch-set approval is supplied.", + categories: "all", + capabilities: [ + "read-normalized-findings", + "read-repository-inventory", + "read-lifecycle-state", + "read-bounded-source-context", + "propose-remediation", + "propose-tests", + ], + sourceContextAllowed: true, + repositoryWriteRequiresApproval: true, + externalNetworkAssessment: "forbidden", + }, + { + id: "dependency-review", + version: 1, + displayName: "Dependency Review", + description: "Review known vulnerable dependencies, package identity, fix availability, and available reachability evidence.", + reviewInstructions: "Distinguish package presence from observed application use. Treat dependencyUsage.status=observed-import as evidence of an import, not proof that a vulnerable function is reachable. Prefer fixed-version guidance already supplied by deterministic scanners.", + categories: ["dependency", "container", "supply-chain", "license"], + capabilities: [ + "read-normalized-findings", + "read-dependency-metadata", + "read-bounded-source-context", + "propose-remediation", + "propose-tests", + ], + sourceContextAllowed: true, + repositoryWriteRequiresApproval: true, + externalNetworkAssessment: "forbidden", + }, + { + id: "secrets-review", + version: 1, + displayName: "Secrets Review", + description: "Review redacted secret findings and recommend rotation/removal without exposing secret values to the model layer.", + reviewInstructions: "Never request, reconstruct, guess, validate, or reproduce a credential value. Work only from redacted metadata. Recommend proportionate revocation, rotation, history cleanup, and secret-management controls.", + categories: ["secret"], + capabilities: [ + "read-normalized-findings", + "read-redacted-secret-metadata", + "propose-remediation", + ], + sourceContextAllowed: false, + repositoryWriteRequiresApproval: true, + externalNetworkAssessment: "forbidden", + }, + { + id: "infrastructure-review", + version: 1, + displayName: "Infrastructure Review", + description: "Review IaC, deployment, misconfiguration, and repository-posture findings.", + reviewInstructions: "Separate policy or posture heuristics from concrete vulnerable configuration. Account for deployment context when present and avoid treating a low Scorecard check as direct exploit evidence.", + categories: ["iac", "misconfiguration", "repository-posture"], + capabilities: [ + "read-normalized-findings", + "read-infrastructure-config", + "read-bounded-source-context", + "propose-remediation", + "propose-tests", + ], + sourceContextAllowed: true, + repositoryWriteRequiresApproval: true, + externalNetworkAssessment: "forbidden", + }, + { + id: "fix-verification", + version: 1, + displayName: "Fix Verification", + description: "Verify remediation against before/after scan evidence without treating model inference as proof that a finding is fixed.", + reviewInstructions: "Treat deterministic remediation verification and scanner reruns as authoritative. A missing finding is only fixed when the after scan covered the affected scope and reran a detecting scanner. Otherwise report the result as inconclusive. Source context may explain a change but must not override deterministic coverage gaps.", + categories: "all", + capabilities: [ + "read-normalized-findings", + "read-scan-reports", + "read-lifecycle-state", + "read-bounded-source-context", + "propose-tests", + ], + sourceContextAllowed: true, + repositoryWriteRequiresApproval: true, + externalNetworkAssessment: "forbidden", + }, + { + id: "report-writing", + version: 1, + displayName: "Report Writing", + description: "Turn normalized evidence and lifecycle state into concise developer-facing security reports.", + reviewInstructions: "Summarize deterministic evidence first, clearly distinguish scanner facts from model interpretation, preserve uncertainty, and reference affected locations without reproducing secret values. Do not claim exploitability or remediation success beyond the available evidence.", + categories: "all", + capabilities: [ + "read-normalized-findings", + "read-scan-reports", + "read-lifecycle-state", + ], + sourceContextAllowed: false, + repositoryWriteRequiresApproval: true, + externalNetworkAssessment: "forbidden", + }, +] as const; + +export function builtInWorkflows(): readonly WorkflowDefinition[] { + return workflows; +} + +export function getWorkflow(id: string): WorkflowDefinition | undefined { + return workflows.find((workflow) => workflow.id === id); +} + +export function workflowFindings( + findings: readonly CorrelatedFinding[], + workflow: WorkflowDefinition, +): CorrelatedFinding[] { + if (workflow.categories === "all") return [...findings]; + const categories = new Set(workflow.categories); + return findings.filter((finding) => categories.has(finding.primary.category)); +} + +export function assertWorkflowCapabilitiesAllowed( + workflow: WorkflowDefinition, + requested: readonly WorkflowCapability[], +): void { + const allowed = new Set(workflow.capabilities); + const denied = [...new Set(requested)].filter((capability) => !allowed.has(capability)); + if (denied.length > 0) { + throw new Error( + `Workflow ${workflow.id} does not permit capabilities: ${denied.sort().join(", ")}.`, + ); + } +} + +export function assertWorkflowSourceContextAllowed( + workflow: WorkflowDefinition, + sourceContextRequested: boolean, +): void { + if (sourceContextRequested && !workflow.sourceContextAllowed) { + throw new Error( + `Workflow ${workflow.id} does not permit source context. This boundary prevents sensitive values from being unnecessarily sent to a model.`, + ); + } + if (sourceContextRequested) { + assertWorkflowCapabilitiesAllowed(workflow, ["read-bounded-source-context"]); + } +} diff --git a/packages/workflows/src/remediation.ts b/packages/workflows/src/remediation.ts new file mode 100644 index 00000000..e9cf8ea1 --- /dev/null +++ b/packages/workflows/src/remediation.ts @@ -0,0 +1,275 @@ +import { createHash } from "node:crypto"; +import { posix } from "node:path"; +import type { WorkflowDefinition } from "./index.js"; +import { assertWorkflowCapabilitiesAllowed } from "./index.js"; + +const MAX_CHANGES = 200; +const MAX_PATCH_BYTES = 256 * 1024; +const MAX_TOTAL_PATCH_BYTES = 2 * 1024 * 1024; +const MAX_FINDING_IDS = 500; +const MAX_SUMMARY_LENGTH = 2_000; +const MAX_APPROVER_LENGTH = 200; + +export type RemediationChangeOperation = "create" | "modify"; + +export interface RemediationChangeInput { + path: string; + operation: RemediationChangeOperation; + patch: string; +} + +export interface RemediationProposalInput { + targetCommitSha: string; + findingIds: readonly string[]; + summary: string; + changes: readonly RemediationChangeInput[]; +} + +export interface RemediationChange { + path: string; + operation: RemediationChangeOperation; + patch: string; + patchSha256: string; +} + +export interface RemediationProposal { + version: 1; + proposalId: string; + workflowId: string; + targetCommitSha: string; + findingIds: string[]; + summary: string; + changes: RemediationChange[]; + requiresApproval: true; + externalNetworkAssessment: "forbidden"; +} + +export interface RemediationApprovalInput { + proposalId: string; + approvedBy: string; + approvedAt?: string; +} + +export interface RemediationApproval { + version: 1; + proposalId: string; + approvedBy: string; + approvedAt: string; +} + +export interface ApprovedRemediationExecution { + proposal: RemediationProposal; + approval: RemediationApproval; + targetCommitSha: string; +} + +function commitSha(value: string): string { + const normalized = value.trim().toLowerCase(); + if (!/^[a-f0-9]{40,64}$/.test(normalized)) { + throw new Error("Remediation target commit must be a 40-64 character hexadecimal object id."); + } + return normalized; +} + +function safePath(value: string): string { + const normalized = value.trim().replaceAll("\\", "/"); + if (!normalized || normalized.startsWith("/") || normalized.includes("\0")) { + throw new Error("Remediation paths must be non-empty repository-relative paths."); + } + const canonical = posix.normalize(normalized); + if ( + canonical === "." || + canonical === ".." || + canonical.startsWith("../") || + canonical.startsWith(".git/") || + canonical === ".git" + ) { + throw new Error("Remediation paths must stay inside the repository and may not address .git metadata."); + } + if (canonical.length > 512) throw new Error("Remediation path exceeds 512 characters."); + return canonical; +} + +function findingId(value: string): string { + const normalized = value.trim(); + if (!normalized || normalized.length > 256 || /[\r\n\0]/.test(normalized)) { + throw new Error("Remediation finding ids must be non-empty single-line identifiers up to 256 characters."); + } + return normalized; +} + +function boundedPatch(value: string): string { + if (!value.trim()) throw new Error("Remediation patches must not be empty."); + const bytes = Buffer.byteLength(value, "utf8"); + if (bytes > MAX_PATCH_BYTES) throw new Error(`Remediation patch exceeds the ${MAX_PATCH_BYTES}-byte per-file limit.`); + if (value.includes("\0")) throw new Error("Remediation patches may not contain NUL bytes."); + return value; +} + +function digest(value: string): string { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +function proposalDigest(input: Omit): string { + const canonical = JSON.stringify({ + version: input.version, + workflowId: input.workflowId, + targetCommitSha: input.targetCommitSha, + findingIds: input.findingIds, + summary: input.summary, + changes: input.changes.map((change) => ({ + path: change.path, + operation: change.operation, + patchSha256: change.patchSha256, + })), + requiresApproval: true, + externalNetworkAssessment: "forbidden", + }); + return digest(canonical); +} + +function assertProposalIntegrity(proposal: RemediationProposal): void { + if (proposal.version !== 1 || proposal.requiresApproval !== true || proposal.externalNetworkAssessment !== "forbidden") { + throw new Error("Remediation proposal has an invalid safety contract."); + } + if (proposal.changes.length === 0 || proposal.changes.length > MAX_CHANGES) { + throw new Error("Remediation proposal has an invalid change count."); + } + let totalPatchBytes = 0; + const seenPaths = new Set(); + for (const change of proposal.changes) { + const path = safePath(change.path); + if (path !== change.path || seenPaths.has(path)) throw new Error("Remediation proposal contains invalid or duplicate paths."); + seenPaths.add(path); + if (change.operation !== "create" && change.operation !== "modify") { + throw new Error("Remediation proposal contains an unsupported change operation."); + } + const patch = boundedPatch(change.patch); + totalPatchBytes += Buffer.byteLength(patch, "utf8"); + if (totalPatchBytes > MAX_TOTAL_PATCH_BYTES) throw new Error("Remediation proposal exceeds the total patch limit."); + if (digest(patch) !== change.patchSha256) { + throw new Error("Remediation patch contents no longer match the approved patch hash."); + } + } + if (proposalDigest({ + version: proposal.version, + workflowId: proposal.workflowId, + targetCommitSha: commitSha(proposal.targetCommitSha), + findingIds: proposal.findingIds.map(findingId), + summary: proposal.summary, + changes: proposal.changes, + requiresApproval: true, + externalNetworkAssessment: "forbidden", + }) !== proposal.proposalId) { + throw new Error("Remediation proposal contents no longer match its proposal id."); + } +} + +/** + * Build an immutable, approval-required remediation proposal for one exact repository commit. + * + * The proposal is intentionally a local repository-write artifact: it cannot name an external + * target, cannot modify .git metadata, cannot delete files, and cannot silently widen beyond the + * explicitly listed bounded patch set. Approval is not accepted here; proposal creation and write + * authorization remain separate actions. + */ +export function createRemediationProposal( + workflow: WorkflowDefinition, + input: RemediationProposalInput, +): RemediationProposal { + assertWorkflowCapabilitiesAllowed(workflow, ["propose-remediation"]); + if (workflow.repositoryWriteRequiresApproval !== true) { + throw new Error("Remediation workflows must require approval for repository writes."); + } + if (workflow.externalNetworkAssessment !== "forbidden") { + throw new Error("Remediation workflows must forbid external network assessment."); + } + if (!Array.isArray(input.changes) || input.changes.length === 0 || input.changes.length > MAX_CHANGES) { + throw new Error(`Remediation proposals must contain between 1 and ${MAX_CHANGES} file changes.`); + } + if (!Array.isArray(input.findingIds) || input.findingIds.length === 0 || input.findingIds.length > MAX_FINDING_IDS) { + throw new Error(`Remediation proposals must reference between 1 and ${MAX_FINDING_IDS} finding ids.`); + } + const summary = input.summary.trim(); + if (!summary || summary.length > MAX_SUMMARY_LENGTH) { + throw new Error(`Remediation summary must contain between 1 and ${MAX_SUMMARY_LENGTH} characters.`); + } + + const seenPaths = new Set(); + let totalPatchBytes = 0; + const changes = input.changes.map((change): RemediationChange => { + const path = safePath(change.path); + if (seenPaths.has(path)) throw new Error(`Remediation proposal contains duplicate path: ${path}.`); + seenPaths.add(path); + if (change.operation !== "create" && change.operation !== "modify") { + throw new Error("Remediation changes currently support only create and modify operations."); + } + const patch = boundedPatch(change.patch); + totalPatchBytes += Buffer.byteLength(patch, "utf8"); + if (totalPatchBytes > MAX_TOTAL_PATCH_BYTES) { + throw new Error(`Remediation proposal exceeds the ${MAX_TOTAL_PATCH_BYTES}-byte total patch limit.`); + } + return { path, operation: change.operation, patch, patchSha256: digest(patch) }; + }); + + const findingIds = [...new Set(input.findingIds.map(findingId))].sort(); + const partial: Omit = { + version: 1, + workflowId: workflow.id, + targetCommitSha: commitSha(input.targetCommitSha), + findingIds, + summary, + changes, + requiresApproval: true, + externalNetworkAssessment: "forbidden", + }; + return { ...partial, proposalId: proposalDigest(partial) }; +} + +export function approveRemediationProposal( + proposal: RemediationProposal, + input: RemediationApprovalInput, +): RemediationApproval { + if (input.proposalId !== proposal.proposalId) { + throw new Error("Remediation approval does not match the proposed patch set."); + } + assertProposalIntegrity(proposal); + const approvedBy = input.approvedBy.trim(); + if (!approvedBy || approvedBy.length > MAX_APPROVER_LENGTH || /[\r\n\0]/.test(approvedBy)) { + throw new Error(`Remediation approver must be a single-line identifier up to ${MAX_APPROVER_LENGTH} characters.`); + } + const approvedAt = (input.approvedAt ?? new Date().toISOString()).trim(); + if (!Number.isFinite(Date.parse(approvedAt))) throw new Error("Remediation approvedAt must be an ISO timestamp."); + return { version: 1, proposalId: proposal.proposalId, approvedBy, approvedAt }; +} + +/** + * Revalidate approval immediately before a repository writer acts. + * + * The caller must supply the repository's current head SHA. A moved head fails closed instead of + * applying a previously reviewed patch to different source. The returned execution object still + * performs no write; a GitHub/local writer must consume it explicitly. + */ +export function authorizeRemediationExecution(input: { + proposal: RemediationProposal; + approval: RemediationApproval; + currentHeadSha: string; +}): ApprovedRemediationExecution { + if (input.approval.version !== 1 || input.approval.proposalId !== input.proposal.proposalId) { + throw new Error("Remediation approval is for a different proposal."); + } + approveRemediationProposal(input.proposal, { + proposalId: input.approval.proposalId, + approvedBy: input.approval.approvedBy, + approvedAt: input.approval.approvedAt, + }); + const currentHeadSha = commitSha(input.currentHeadSha); + if (currentHeadSha !== input.proposal.targetCommitSha) { + throw new Error("Repository head moved after remediation was proposed; regenerate and reapprove the patch set."); + } + return { + proposal: input.proposal, + approval: input.approval, + targetCommitSha: currentHeadSha, + }; +} diff --git a/packages/workflows/src/routing.ts b/packages/workflows/src/routing.ts new file mode 100644 index 00000000..3bcd28c4 --- /dev/null +++ b/packages/workflows/src/routing.ts @@ -0,0 +1,136 @@ +export type ModelTask = + | "fast-classifier" + | "security-reasoner" + | "code-reasoner" + | "report-writer" + | "verifier"; + +export type ModelPrivacy = "local" | "private-remote" | "remote"; + +export interface ModelCandidate { + id: string; + tasks: readonly ModelTask[]; + costTier: 0 | 1 | 2 | 3; + latencyTier: 0 | 1 | 2 | 3; + privacy: ModelPrivacy; + supportsSourceContext: boolean; + enabled?: boolean; +} + +export interface ModelRoutingRequest { + task: ModelTask; + sourceContextRequested: boolean; + maxCostTier?: 0 | 1 | 2 | 3; + requireLocal?: boolean; + preferLocal?: boolean; +} + +export interface ModelRoutingDecision { + candidate: ModelCandidate; + reason: string[]; +} + +export interface ModelSetRoutingDecision { + candidates: ModelCandidate[]; + reason: string[]; +} + +function privacyRank(privacy: ModelPrivacy): number { + if (privacy === "local") return 0; + if (privacy === "private-remote") return 1; + return 2; +} + +function eligible(candidate: ModelCandidate, request: ModelRoutingRequest): boolean { + if (candidate.enabled === false) return false; + if (!candidate.tasks.includes(request.task)) return false; + if (request.sourceContextRequested && !candidate.supportsSourceContext) return false; + if (request.maxCostTier !== undefined && candidate.costTier > request.maxCostTier) return false; + if (request.requireLocal && candidate.privacy !== "local") return false; + return true; +} + +function constraints(request: ModelRoutingRequest): string[] { + return [ + `task=${request.task}`, + `sourceContext=${request.sourceContextRequested ? "required" : "not-required"}`, + request.maxCostTier !== undefined ? `maxCostTier=${request.maxCostTier}` : undefined, + request.requireLocal ? "privacy=local-only" : undefined, + ].filter((value): value is string => value !== undefined); +} + +function rankedEligible(candidates: readonly ModelCandidate[], request: ModelRoutingRequest): ModelCandidate[] { + return candidates.filter((candidate) => eligible(candidate, request)).sort((left, right) => { + if (request.preferLocal) { + const privacyDifference = privacyRank(left.privacy) - privacyRank(right.privacy); + if (privacyDifference !== 0) return privacyDifference; + } + return left.costTier - right.costTier + || left.latencyTier - right.latencyTier + || privacyRank(left.privacy) - privacyRank(right.privacy) + || left.id.localeCompare(right.id); + }); +} + +export function routeModel( + candidates: readonly ModelCandidate[], + request: ModelRoutingRequest, +): ModelRoutingDecision { + const ranked = rankedEligible(candidates, request); + if (ranked.length === 0) { + throw new Error(`No model candidate satisfies routing constraints: ${constraints(request).join(", ")}.`); + } + + const candidate = ranked[0]; + if (!candidate) throw new Error("Model routing produced no candidate after eligibility filtering."); + + const reason = [ + `supports ${request.task}`, + `cost tier ${candidate.costTier}`, + `latency tier ${candidate.latencyTier}`, + `privacy ${candidate.privacy}`, + ]; + if (request.sourceContextRequested) reason.push("permits source context"); + if (request.preferLocal && candidate.privacy === "local") reason.push("local preference satisfied"); + + return { candidate, reason }; +} + +/** + * Select a deterministic set of distinct model identities for reviewer/verifier consensus. + * The request's privacy, source-context, and cost constraints are applied to every member. + * Insufficient eligible models fail closed instead of silently reducing reviewer count. + */ +export function routeModelSet( + candidates: readonly ModelCandidate[], + request: ModelRoutingRequest, + count = 2, +): ModelSetRoutingDecision { + if (!Number.isInteger(count) || count < 2 || count > 10) { + throw new Error("Consensus model count must be an integer between 2 and 10."); + } + + const seen = new Set(); + const ranked = rankedEligible(candidates, request).filter((candidate) => { + const id = candidate.id.trim(); + if (!id || seen.has(id)) return false; + seen.add(id); + return true; + }); + if (ranked.length < count) { + throw new Error( + `Only ${ranked.length} distinct model candidate(s) satisfy consensus routing constraints; ${count} required: ${constraints(request).join(", ")}.`, + ); + } + + const selected = ranked.slice(0, count); + return { + candidates: selected, + reason: [ + `selected ${count} distinct models for ${request.task}`, + request.sourceContextRequested ? "all permit source context" : "source context not required", + request.requireLocal ? "all are local" : request.preferLocal ? "local preference applied" : "standard privacy ranking applied", + "cost/latency constraints preserved for every reviewer", + ], + }; +} diff --git a/packages/workflows/src/user-defined.ts b/packages/workflows/src/user-defined.ts new file mode 100644 index 00000000..bdc44a99 --- /dev/null +++ b/packages/workflows/src/user-defined.ts @@ -0,0 +1,124 @@ +import { readFile } from "node:fs/promises"; +import type { FindingCategory } from "@synsec/core"; +import type { WorkflowCapability, WorkflowDefinition } from "./index.js"; + +const capabilities = new Set([ + "read-normalized-findings", + "read-repository-inventory", + "read-bounded-source-context", + "read-dependency-metadata", + "read-redacted-secret-metadata", + "read-infrastructure-config", + "read-scan-reports", + "read-lifecycle-state", + "propose-remediation", + "propose-tests", +]); + +const categories = new Set([ + "sast", + "dependency", + "secret", + "misconfiguration", + "iac", + "container", + "supply-chain", + "repository-posture", + "license", + "other", +]); + +function record(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("Workflow definition must be a JSON object."); + } + return value as Record; +} + +function requiredString(input: Record, key: string, maxLength: number): string { + const value = input[key]; + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`Workflow field ${key} must be a non-empty string.`); + } + if (value.length > maxLength) throw new Error(`Workflow field ${key} exceeds ${maxLength} characters.`); + return value; +} + +function parseCategories(value: unknown): readonly FindingCategory[] | "all" { + if (value === "all") return "all"; + if (!Array.isArray(value) || value.length === 0) { + throw new Error("Workflow categories must be \"all\" or a non-empty array."); + } + const parsed = [...new Set(value.map((item) => { + if (typeof item !== "string" || !categories.has(item as FindingCategory)) { + throw new Error(`Unsupported workflow category: ${String(item)}.`); + } + return item as FindingCategory; + }))]; + return parsed; +} + +function parseCapabilities(value: unknown): readonly WorkflowCapability[] { + if (!Array.isArray(value) || value.length === 0) { + throw new Error("Workflow capabilities must be a non-empty array."); + } + return [...new Set(value.map((item) => { + if (typeof item !== "string" || !capabilities.has(item as WorkflowCapability)) { + throw new Error(`Unsupported workflow capability: ${String(item)}.`); + } + return item as WorkflowCapability; + }))]; +} + +export function parseUserWorkflow(value: unknown): WorkflowDefinition { + const input = record(value); + if (input.version !== 1) throw new Error("User-defined workflows must declare version 1."); + + const id = requiredString(input, "id", 80); + if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) { + throw new Error("Workflow id must contain only lowercase letters, numbers, and hyphens."); + } + const displayName = requiredString(input, "displayName", 120); + const description = requiredString(input, "description", 1_000); + const reviewInstructions = requiredString(input, "reviewInstructions", 8_000); + const parsedCapabilities = parseCapabilities(input.capabilities); + const parsedCategories = parseCategories(input.categories); + + if (typeof input.sourceContextAllowed !== "boolean") { + throw new Error("Workflow sourceContextAllowed must be a boolean."); + } + if (input.sourceContextAllowed && !parsedCapabilities.includes("read-bounded-source-context")) { + throw new Error("A workflow may allow source context only when read-bounded-source-context is declared."); + } + if (input.repositoryWriteRequiresApproval !== true) { + throw new Error("User-defined workflows must require approval for repository writes."); + } + if (input.externalNetworkAssessment !== "forbidden") { + throw new Error("User-defined repository workflows must forbid external network assessment."); + } + + return { + id, + version: 1, + displayName, + description, + reviewInstructions, + categories: parsedCategories, + capabilities: parsedCapabilities, + sourceContextAllowed: input.sourceContextAllowed, + repositoryWriteRequiresApproval: true, + externalNetworkAssessment: "forbidden", + }; +} + +export async function readUserWorkflow(path: string): Promise { + const source = await readFile(path, "utf8"); + if (source.length > 64_000) throw new Error("Workflow definition exceeds the 64 KiB size limit."); + let parsed: unknown; + try { + parsed = JSON.parse(source) as unknown; + } catch (error) { + throw new Error(`Workflow definition is not valid JSON: ${error instanceof Error ? error.message : String(error)}`); + } + return parseUserWorkflow(parsed); +} diff --git a/packages/workflows/tsconfig.json b/packages/workflows/tsconfig.json new file mode 100644 index 00000000..ebe9ac5b --- /dev/null +++ b/packages/workflows/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [ + { "path": "../core" } + ], + "include": ["src/**/*.ts"] +} diff --git a/scripts/github-app-intake-host.mjs b/scripts/github-app-intake-host.mjs new file mode 100644 index 00000000..1121404c --- /dev/null +++ b/scripts/github-app-intake-host.mjs @@ -0,0 +1,148 @@ +#!/usr/bin/env node +import { lstat, readFile } from "node:fs/promises"; +import { isAbsolute, resolve } from "node:path"; +import pg from "pg"; +import { parseGitHubAppHostProfile } from "@synsec/github/app-host-profile"; +import { createSynSecGitHubAppIntakeHost } from "@synsec/github/app-intake-host"; +import { secretValueFromEnvironmentOrFile } from "./host-secret-source.mjs"; + +const MAX_JSON_BYTES = 1024 * 1024; +const MAX_TLS_BYTES = 1024 * 1024; + +function usage() { + return "Usage: node scripts/github-app-intake-host.mjs --profile --conformance [--tls-key --tls-cert ]"; +} + +function parseArgs(argv) { + const allowed = new Set(["--profile", "--conformance", "--tls-key", "--tls-cert"]); + const result = {}; + for (let index = 0; index < argv.length; index += 2) { + const flag = argv[index]; + const value = argv[index + 1]; + if (!allowed.has(flag) || typeof value !== "string" || !value || value.startsWith("--")) { + throw new Error(usage()); + } + if (Object.values(result).includes(undefined)) throw new Error(usage()); + const key = flag.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); + if (result[key] !== undefined) throw new Error(usage()); + result[key] = value; + } + if (!result.profile || !result.conformance) throw new Error(usage()); + if (Boolean(result.tlsKey) !== Boolean(result.tlsCert)) { + throw new Error("Local TLS requires both --tls-key and --tls-cert."); + } + return result; +} + +async function readBoundedRegular(pathValue, maximumBytes, label) { + if (typeof pathValue !== "string" || !isAbsolute(pathValue) || pathValue.includes("\0")) { + throw new Error(`${label} path must be absolute.`); + } + const path = resolve(pathValue); + const info = await lstat(path).catch(() => undefined); + if (!info?.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > maximumBytes) { + throw new Error(`${label} must be a bounded regular non-symlink file.`); + } + return readFile(path, "utf8").catch(() => { + throw new Error(`${label} could not be read.`); + }); +} + +async function readJson(path, label) { + const text = await readBoundedRegular(path, MAX_JSON_BYTES, label); + try { + return JSON.parse(text); + } catch { + throw new Error(`${label} must contain valid JSON.`); + } +} + +async function databaseUrlFromEnvironment(profile) { + const value = await secretValueFromEnvironmentOrFile( + process.env, + profile.postgresUrlEnvironment, + "Configured PostgreSQL connection credential", + ); + if (!/^postgres(?:ql)?:\/\//i.test(value)) { + throw new Error("Configured PostgreSQL connection credential must contain a PostgreSQL URL."); + } + return value; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const [profileInput, conformanceReport] = await Promise.all([ + readJson(args.profile, "GitHub App host profile"), + readJson(args.conformance, "GitHub App shared-state conformance report"), + ]); + const profile = parseGitHubAppHostProfile(profileInput); + const tls = args.tlsKey + ? { + key: await readBoundedRegular(args.tlsKey, MAX_TLS_BYTES, "GitHub App TLS key"), + cert: await readBoundedRegular(args.tlsCert, MAX_TLS_BYTES, "GitHub App TLS certificate"), + } + : undefined; + if (profile.tlsMode === "local" && !tls) { + throw new Error("Host profile requires local TLS but no TLS key/certificate files were supplied."); + } + if (profile.tlsMode !== "local" && tls) { + throw new Error("TLS key/certificate files are accepted only when the host profile uses local TLS."); + } + + const pool = new pg.Pool({ + connectionString: await databaseUrlFromEnvironment(profile), + max: Math.max(4, Math.min(32, profile.replicaCount * 4)), + application_name: `synsec-intake:${profile.replicaId}`, + }); + + let host; + let stopping; + const stop = async () => { + if (stopping) return stopping; + stopping = (async () => { + try { + if (host) await host.close(); + } finally { + await pool.end(); + } + })(); + return stopping; + }; + + try { + host = await createSynSecGitHubAppIntakeHost({ + profile: profileInput, + pool, + conformanceReport, + ...(tls ? { tls } : {}), + onWebhookError(error) { + process.stderr.write(`${error.message}\n`); + }, + }); + const address = await host.start(); + process.stdout.write(`${JSON.stringify({ + status: "started", + releaseId: profile.releaseId, + replicaId: profile.replicaId, + protocol: address.protocol, + host: address.host, + port: address.port, + interpretation: host.interpretation, + })}\n`); + + for (const signal of ["SIGTERM", "SIGINT"]) { + process.once(signal, () => { + void stop().then(() => process.exit(0), () => process.exit(1)); + }); + } + } catch (error) { + await stop().catch(() => undefined); + throw error; + } +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : "GitHub App intake host failed."; + process.stderr.write(`${message}\n`); + process.exitCode = 1; +}); diff --git a/scripts/github-app-worker-host.mjs b/scripts/github-app-worker-host.mjs new file mode 100644 index 00000000..e479645d --- /dev/null +++ b/scripts/github-app-worker-host.mjs @@ -0,0 +1,143 @@ +#!/usr/bin/env node +import { lstat, readFile } from "node:fs/promises"; +import { isAbsolute, resolve } from "node:path"; +import pg from "pg"; +import { parseConfig } from "@synsec/config"; +import { parseGitHubAppHostProfile } from "@synsec/github/app-host-profile"; +import { createSynSecGitHubAppWorkerHost } from "@synsec/github/app-worker-host"; +import { secretValueFromEnvironmentOrFile } from "./host-secret-source.mjs"; + +const MAX_JSON_BYTES = 1024 * 1024; +const IDLE_DELAY_MS = 1_000; + +function usage() { + return "Usage: node scripts/github-app-worker-host.mjs --profile --conformance --config "; +} + +function parseArgs(argv) { + const allowed = new Set(["--profile", "--conformance", "--config"]); + const result = {}; + if (argv.length % 2 !== 0) throw new Error(usage()); + for (let index = 0; index < argv.length; index += 2) { + const flag = argv[index]; + const value = argv[index + 1]; + if (!allowed.has(flag) || typeof value !== "string" || !value || value.startsWith("--")) { + throw new Error(usage()); + } + const key = flag.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); + if (result[key] !== undefined) throw new Error(usage()); + result[key] = value; + } + if (!result.profile || !result.conformance || !result.config) throw new Error(usage()); + return result; +} + +async function readJson(pathValue, label) { + if (typeof pathValue !== "string" || !isAbsolute(pathValue) || pathValue.includes("\0")) { + throw new Error(`${label} path must be absolute.`); + } + const path = resolve(pathValue); + const info = await lstat(path).catch(() => undefined); + if (!info?.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > MAX_JSON_BYTES) { + throw new Error(`${label} must be a bounded regular non-symlink file.`); + } + const text = await readFile(path, "utf8").catch(() => { + throw new Error(`${label} could not be read.`); + }); + try { + return JSON.parse(text); + } catch { + throw new Error(`${label} must contain valid JSON.`); + } +} + +async function databaseUrlFromEnvironment(profile) { + const value = await secretValueFromEnvironmentOrFile( + process.env, + profile.postgresUrlEnvironment, + "Configured PostgreSQL connection credential", + ); + if (!/^postgres(?:ql)?:\/\//i.test(value)) { + throw new Error("Configured PostgreSQL connection credential must contain a PostgreSQL URL."); + } + return value; +} + +function delay(ms) { + return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const [profileInput, conformanceReport, configInput] = await Promise.all([ + readJson(args.profile, "GitHub App host profile"), + readJson(args.conformance, "GitHub App shared-state conformance report"), + readJson(args.config, "SynSec worker configuration"), + ]); + const profile = parseGitHubAppHostProfile(profileInput); + const config = parseConfig(configInput); + const pool = new pg.Pool({ + connectionString: await databaseUrlFromEnvironment(profile), + max: Math.max(4, Math.min(32, profile.replicaCount * 4)), + application_name: `synsec-worker:${profile.replicaId}`, + }); + + let host; + let stopping = false; + let stopPromise; + const stop = async () => { + if (stopPromise) return stopPromise; + stopping = true; + stopPromise = (async () => { + try { + if (host) await host.close(); + } finally { + await pool.end(); + } + })(); + return stopPromise; + }; + + for (const signal of ["SIGTERM", "SIGINT"]) { + process.once(signal, () => { + void stop().then(() => process.exit(0), () => process.exit(1)); + }); + } + + try { + host = await createSynSecGitHubAppWorkerHost({ + profile: profileInput, + pool, + conformanceReport, + config, + toolVersion: profile.releaseId, + }); + process.stdout.write(`${JSON.stringify({ + status: "started", + releaseId: profile.releaseId, + replicaId: profile.replicaId, + scanners: [...config.scanners].sort(), + interpretation: host.interpretation, + })}\n`); + + while (!stopping) { + const result = await host.runOnce(); + if (result.status === "draining") break; + if (result.status !== "idle") { + // Do not emit repository, installation, finding, token, or backend diagnostics from the host loop. + process.stdout.write(`${JSON.stringify({ status: result.status })}\n`); + } + if (result.status === "idle" || result.status === "retry_scheduled") await delay(IDLE_DELAY_MS); + } + await stop(); + } catch (error) { + await stop().catch(() => undefined); + throw error; + } +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : "GitHub App worker host failed."; + process.stderr.write(`${message}\n`); + process.exitCode = 1; +}); diff --git a/scripts/host-secret-source.mjs b/scripts/host-secret-source.mjs new file mode 100644 index 00000000..cdc32999 --- /dev/null +++ b/scripts/host-secret-source.mjs @@ -0,0 +1,48 @@ +import { lstat, readFile } from "node:fs/promises"; +import { isAbsolute, resolve } from "node:path"; + +const MAX_SECRET_BYTES = 8192; + +function normalizeSecretText(value) { + return value.endsWith("\r\n") ? value.slice(0, -2) : value.endsWith("\n") ? value.slice(0, -1) : value; +} + +async function readBoundedSecretFile(pathValue, label) { + if (typeof pathValue !== "string" || !isAbsolute(pathValue) || pathValue.includes("\0")) { + throw new Error(`${label} file path must be absolute.`); + } + const path = resolve(pathValue); + const info = await lstat(path).catch(() => undefined); + if (!info?.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > MAX_SECRET_BYTES) { + throw new Error(`${label} file must be a bounded regular non-symlink file.`); + } + const text = await readFile(path, "utf8").catch(() => { + throw new Error(`${label} file could not be read.`); + }); + const normalized = normalizeSecretText(text); + if (!normalized || Buffer.byteLength(normalized, "utf8") > MAX_SECRET_BYTES || normalized.includes("\0")) { + throw new Error(`${label} file content is invalid.`); + } + return normalized; +} + +export async function secretValueFromEnvironmentOrFile(environment, name, label) { + const direct = environment[name]; + const fileName = `${name}_FILE`; + const file = environment[fileName]; + const hasDirect = typeof direct === "string" && direct.length > 0; + const hasFile = typeof file === "string" && file.length > 0; + + if (hasDirect && hasFile) { + throw new Error(`${label} must be supplied by exactly one of ${name} or ${fileName}.`); + } + if (!hasDirect && !hasFile) { + throw new Error(`${label} is missing.`); + } + + const value = hasFile ? await readBoundedSecretFile(file, label) : direct; + if (typeof value !== "string" || !value || Buffer.byteLength(value, "utf8") > MAX_SECRET_BYTES || value.includes("\0")) { + throw new Error(`${label} is invalid.`); + } + return value; +} diff --git a/scripts/release-readiness.mjs b/scripts/release-readiness.mjs new file mode 100644 index 00000000..b59edd4e --- /dev/null +++ b/scripts/release-readiness.mjs @@ -0,0 +1,139 @@ +import { access, readFile } from "node:fs/promises"; +import { constants as fsConstants } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REQUIRED_CI_MARKERS = [ + "matrix:\n node: [20, 24]", + "npm run build", + "npm run typecheck", + "npm test", + "postgres:16-alpine", + "tests/postgres-shared-state-conformance.test.mjs", + "tests/postgres-hosted-installation-ownership.test.mjs", + "tests/oci-scanner-sandbox.test.mjs", +]; + +const REQUIRED_OPERATOR_DOCS = [ + "docs/GITHUB_APP_UPGRADES.md", + "docs/GITHUB_APP_SERVICE_MAINTENANCE.md", + "docs/HOSTED_INSTALLATION_OWNERSHIP.md", + "docs/HOSTED_INSTALLATION_REVERIFICATION_SWEEPS.md", +]; + +function issue(code, message, severity) { + return { code, message, severity }; +} + +async function exists(path) { + try { + await access(path, fsConstants.F_OK); + return true; + } catch { + return false; + } +} + +function isStableSemver(value) { + return typeof value === "string" && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value); +} + +export async function assessReleaseReadiness(rootDirectory) { + const root = resolve(rootDirectory); + const errors = []; + const blockers = []; + + let packageJson; + try { + packageJson = JSON.parse(await readFile(resolve(root, "package.json"), "utf8")); + } catch { + errors.push(issue("package-json-invalid", "package.json must exist and contain valid JSON.", "error")); + } + + if (packageJson) { + if (!isStableSemver(packageJson.version)) { + errors.push(issue("package-version-invalid", "Root package version must be a stable semantic version.", "error")); + } + if (packageJson.engines?.node !== ">=20") { + errors.push(issue("node-engine-policy-mismatch", "Root Node.js engine policy must remain >=20 for the current release line.", "error")); + } + if (packageJson.private !== true) { + errors.push(issue("root-package-publishable", "The monorepo root must remain private to prevent accidental npm publication.", "error")); + } + } + + const workflowPath = resolve(root, ".github/workflows/ci.yml"); + let workflow; + try { + workflow = await readFile(workflowPath, "utf8"); + } catch { + errors.push(issue("ci-workflow-missing", "The required CI workflow is missing or unreadable.", "error")); + } + if (workflow) { + for (const marker of REQUIRED_CI_MARKERS) { + if (!workflow.includes(marker)) { + errors.push(issue("ci-coverage-incomplete", `CI is missing required release validation marker: ${marker}`, "error")); + } + } + } + + for (const document of REQUIRED_OPERATOR_DOCS) { + if (!(await exists(resolve(root, document)))) { + errors.push(issue("operator-doc-missing", `Required operator documentation is missing: ${document}`, "error")); + } + } + + const hasNpmLock = await exists(resolve(root, "package-lock.json")); + if (!hasNpmLock) { + blockers.push(issue( + "dependency-lockfile-missing", + "No package-lock.json is committed; dependency installation is not yet reproducible and release tagging must remain blocked.", + "blocker", + )); + } else if (workflow && !workflow.includes("npm ci")) { + blockers.push(issue( + "ci-not-using-lockfile", + "A package-lock.json exists but CI is not using npm ci; reproducible installation is not enforced.", + "blocker", + )); + } + + const result = { + schemaVersion: 1, + releaseVersion: packageJson?.version ?? null, + ready: errors.length === 0 && blockers.length === 0, + errors, + blockers, + evidence: { + nodeMatrix: workflow ? workflow.includes("node: [20, 24]") : false, + postgresConformance: workflow ? workflow.includes("tests/postgres-shared-state-conformance.test.mjs") : false, + enforcedOciIsolation: workflow ? workflow.includes("tests/oci-scanner-sandbox.test.mjs") : false, + dependencyLockfile: hasNpmLock, + }, + }; + return result; +} + +function renderText(report) { + const lines = [ + `SynSec release readiness: ${report.ready ? "READY" : "NOT READY"}`, + `Version: ${report.releaseVersion ?? "unknown"}`, + ]; + for (const entry of report.errors) lines.push(`ERROR ${entry.code}: ${entry.message}`); + for (const entry of report.blockers) lines.push(`BLOCKER ${entry.code}: ${entry.message}`); + return `${lines.join("\n")}\n`; +} + +async function main() { + const args = new Set(process.argv.slice(2)); + const strict = args.has("--strict"); + const json = args.has("--json"); + const report = await assessReleaseReadiness(process.cwd()); + process.stdout.write(json ? `${JSON.stringify(report, null, 2)}\n` : renderText(report)); + if (report.errors.length > 0 || (strict && report.blockers.length > 0)) process.exitCode = 1; +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : undefined; +if (invokedPath === fileURLToPath(import.meta.url)) { + await main(); +} diff --git a/tests/ai-consensus.test.mjs b/tests/ai-consensus.test.mjs new file mode 100644 index 00000000..7dd394c2 --- /dev/null +++ b/tests/ai-consensus.test.mjs @@ -0,0 +1,148 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { buildReviewConsensus, reviewFindingWithConsensus } from "../packages/ai/dist/consensus.js"; + +function review(model, verdict, confidence, severity = "high", gateAnswer = "yes") { + return { + model, + verdict, + confidence, + severity, + summary: `${model} summary`, + rationale: `${model} rationale`, + gate: [ + { id: "concrete", question: "Concrete?", answer: gateAnswer, note: "evidence" }, + { id: "reachable", question: "Reachable?", answer: "unknown", note: "unknown" }, + ], + }; +} + +const finding = { + id: "f-1", + title: "Unsafe input", + description: "Untrusted input reaches a sensitive operation.", + category: "sast", + severity: "high", + confidence: 0.9, + scanner: { name: "opengrep", ruleId: "unsafe-input" }, + location: { path: "src/app.ts", startLine: 10, endLine: 10 }, +}; + +test("buildReviewConsensus returns a majority verdict without treating it as scanner evidence", () => { + const consensus = buildReviewConsensus([ + review("model-a", "confirmed", 0.9, "high"), + review("model-b", "confirmed", 0.7, "medium"), + review("model-c", "uncertain", 0.6, "critical", "unknown"), + ]); + + assert.equal(consensus.verdict, "confirmed"); + assert.equal(consensus.agreement, "majority"); + assert.equal(consensus.severity, "critical"); + assert.equal(consensus.confidence, 0.8); + assert.deepEqual(consensus.agreeingModels, ["model-a", "model-b"]); + assert.deepEqual(consensus.dissentingModels, ["model-c"]); + assert.equal(consensus.interpretation, "model-consensus-not-scanner-evidence"); + assert.deepEqual(consensus.gate[0], { + id: "concrete", + question: "Concrete?", + answer: "yes", + yes: 2, + no: 0, + unknown: 1, + }); +}); + +test("split reviewer verdicts fail closed to uncertain", () => { + const consensus = buildReviewConsensus([ + review("model-a", "confirmed", 0.9), + review("model-b", "false-positive", 0.9, "low", "no"), + ]); + assert.equal(consensus.verdict, "uncertain"); + assert.equal(consensus.agreement, "split"); + assert.deepEqual(consensus.agreeingModels, []); + assert.deepEqual(consensus.dissentingModels, ["model-a", "model-b"]); + assert.equal(consensus.gate[0].answer, "unknown"); +}); + +test("insufficient unique reviewers never fabricate consensus", () => { + const consensus = buildReviewConsensus([ + review("same-model", "confirmed", 0.95), + review("same-model", "confirmed", 0.95), + ]); + assert.equal(consensus.verdict, "uncertain"); + assert.equal(consensus.severity, "unknown"); + assert.equal(consensus.confidence, 0); + assert.equal(consensus.agreement, "insufficient"); + assert.equal(consensus.reviewerCount, 1); +}); + +test("unanimous false-positive consensus preserves the reviewers' bounded severity", () => { + const consensus = buildReviewConsensus([ + review("model-a", "false-positive", 0.8, "low", "no"), + review("model-b", "false-positive", 0.6, "info", "no"), + ]); + assert.equal(consensus.verdict, "false-positive"); + assert.equal(consensus.agreement, "unanimous"); + assert.equal(consensus.severity, "low"); + assert.equal(consensus.confidence, 0.7); +}); + +test("reviewFindingWithConsensus bounds independent reviewer execution and isolates failures", async () => { + let active = 0; + let maximumActive = 0; + const reviewer = async (_finding, provider) => { + active += 1; + maximumActive = Math.max(maximumActive, active); + await new Promise((resolve) => setTimeout(resolve, 5)); + active -= 1; + if (provider.model === "model-c") throw new Error(`provider failed with ${provider.apiKey}`); + return review(provider.model, "confirmed", provider.model === "model-a" ? 0.9 : 0.7); + }; + + const result = await reviewFindingWithConsensus(finding, [ + { baseUrl: "https://models.invalid", model: "model-a", apiKey: "secret-a" }, + { baseUrl: "https://models.invalid", model: "model-b", apiKey: "secret-b" }, + { baseUrl: "https://models.invalid", model: "model-c", apiKey: "secret-c" }, + { baseUrl: "https://models.invalid", model: "model-a", apiKey: "duplicate" }, + ], undefined, undefined, { concurrency: 2, reviewer }); + + assert.equal(maximumActive <= 2, true); + assert.deepEqual(result.reviews.map((item) => item.model), ["model-a", "model-b"]); + assert.equal(result.consensus.agreement, "unanimous"); + assert.equal(result.consensus.verdict, "confirmed"); + assert.equal(result.failures.length, 1); + assert.equal(result.failures[0].model, "model-c"); + assert.equal(result.failures[0].message.includes("secret-c"), false); + assert.match(result.failures[0].message, /\[REDACTED\]/); +}); + +test("reviewFindingWithConsensus fails closed when successful reviewers are below the minimum", async () => { + const result = await reviewFindingWithConsensus(finding, [ + { baseUrl: "https://models.invalid", model: "model-a" }, + { baseUrl: "https://models.invalid", model: "model-b" }, + ], undefined, undefined, { + minimumReviewers: 2, + reviewer: async (_finding, provider) => { + if (provider.model === "model-b") throw new Error("unavailable"); + return review(provider.model, "confirmed", 0.9); + }, + }); + assert.equal(result.consensus.agreement, "insufficient"); + assert.equal(result.consensus.verdict, "uncertain"); +}); + +test("multi-review boundary prohibits source context for secret findings before reviewer execution", async () => { + let called = false; + await assert.rejects( + () => reviewFindingWithConsensus( + { ...finding, category: "secret" }, + [{ baseUrl: "https://models.invalid", model: "model-a" }, { baseUrl: "https://models.invalid", model: "model-b" }], + { path: "src/app.ts", startLine: 1, endLine: 1, excerpt: "secret material" }, + undefined, + { reviewer: async () => { called = true; return review("model-a", "confirmed", 0.9); } }, + ), + /Source context is prohibited for secret findings/, + ); + assert.equal(called, false); +}); diff --git a/tests/ai.test.mjs b/tests/ai.test.mjs new file mode 100644 index 00000000..3742cbbb --- /dev/null +++ b/tests/ai.test.mjs @@ -0,0 +1,93 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { reviewFinding } from "../packages/ai/dist/index.js"; + +test("AI review uses the OpenAI-compatible boundary and normalizes the seven-question gate", async () => { + let observedBody = ""; + const server = http.createServer((request, response) => { + let body = ""; + request.setEncoding("utf8"); + request.on("data", (chunk) => { body += chunk; }); + request.on("end", () => { + observedBody = body; + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + verdict: "likely", + confidence: 0.88, + severity: "high", + summary: "Evidence supports the scanner finding", + rationale: "The provided evidence is concrete but reachability is not fully established.", + gate: [ + { id: "concrete", answer: "yes", note: "A source location is present." }, + { id: "evidence", answer: "yes", note: "Scanner evidence is present." }, + ], + remediation: "Use the safer API described by the scanner.", + }), + }, + }], + })); + }); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const address = server.address(); + assert.ok(address && typeof address === "object"); + const review = await reviewFinding({ + id: "fixture", + title: "Fixture finding", + category: "sast", + severity: "high", + confidence: 0.9, + scanner: { name: "fixture", ruleId: "FIXTURE-1" }, + location: { path: "src/app.ts", startLine: 5 }, + }, { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + model: "fixture-model", + apiKey: "test-key", + }, undefined, "Prefer deterministic fixture evidence and do not overstate reachability."); + + assert.equal(review.verdict, "likely"); + assert.equal(review.model, "fixture-model"); + assert.equal(review.gate.length, 7); + assert.equal(review.gate.find((item) => item.id === "concrete")?.answer, "yes"); + assert.equal(review.gate.find((item) => item.id === "reachable")?.answer, "unknown"); + assert.match(observedBody, /fixture-model/); + assert.match(observedBody, /No source excerpt was provided/); + assert.match(observedBody, /Prefer deterministic fixture evidence/); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } +}); + +test("AI provider boundary refuses source excerpts for secret findings", async () => { + await assert.rejects( + reviewFinding({ + id: "secret-fixture", + title: "Potential token", + category: "secret", + severity: "high", + confidence: 0.99, + scanner: { name: "betterleaks", ruleId: "token" }, + location: { path: "src/config.ts", startLine: 4 }, + metadata: { + validationStatus: "unknown", + accidentalSecretField: "must-not-cross-provider-boundary", + }, + }, { + baseUrl: "http://127.0.0.1:1/v1", + model: "fixture-model", + }, { + path: "src/config.ts", + startLine: 1, + endLine: 5, + excerpt: "SECRET_SHOULD_NEVER_BE_SENT", + truncated: true, + }), + /Source context is prohibited for secret findings/, + ); +}); diff --git a/tests/app-worker-host.test.mjs b/tests/app-worker-host.test.mjs new file mode 100644 index 00000000..5e56f91f --- /dev/null +++ b/tests/app-worker-host.test.mjs @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { defaultConfig } from "@synsec/config"; +import { builtInScanners, withBuiltInScannerFactory } from "@synsec/scanners"; +import { + assertGitHubAppOciWorkerConfig, + createSynSecGitHubAppWorkerHost, +} from "@synsec/github/app-worker-host"; + +function scanner(id) { + return { + id, + displayName: id, + async checkAvailability() { return { available: true }; }, + async scan() { throw new Error("test scanner should not execute"); }, + }; +} + +function isolatedConfig(overrides = {}) { + return { + ...structuredClone(defaultConfig), + scanners: ["checkov", "grype", "syft"], + ai: { ...defaultConfig.ai, enabled: false }, + ...overrides, + }; +} + +function hostProfile() { + return { + releaseId: "0.2.0-test", + replicaId: "worker-1", + replicaCount: 2, + appId: 12345, + credentialDirectory: "/run/secrets/synsec-github", + postgresUrlEnvironment: "SYNSEC_DATABASE_URL", + listenHost: "127.0.0.1", + port: 8443, + tlsMode: "terminated-upstream", + workspaceDirectory: "/var/lib/synsec/workspaces", + scannerRuntimeCommand: "docker", + scannerImage: `registry.example/synsec-scanners@sha256:${"a".repeat(64)}`, + operatorStatusPath: "/_synsec/operator", + }; +} + +test("scoped scanner factories remain isolated across concurrent async operations", async () => { + let release; + const gate = new Promise((resolve) => { release = resolve; }); + + const first = withBuiltInScannerFactory(() => [scanner("isolated-a")], async () => { + await gate; + return builtInScanners().map((value) => value.id); + }); + const second = withBuiltInScannerFactory(() => [scanner("isolated-b")], async () => { + release(); + await Promise.resolve(); + return builtInScanners().map((value) => value.id); + }); + + assert.deepEqual(await first, ["isolated-a"]); + assert.deepEqual(await second, ["isolated-b"]); + assert.ok(builtInScanners().some((value) => value.id === "opengrep")); +}); + +test("scoped scanner factories reject empty and duplicate adapter sets", async () => { + await assert.rejects( + withBuiltInScannerFactory(() => [], async () => builtInScanners()), + /at least one scanner adapter/, + ); + await assert.rejects( + withBuiltInScannerFactory(() => [scanner("same"), scanner("same")], async () => builtInScanners()), + /duplicate scanner ids/, + ); +}); + +test("hosted OCI worker accepts only the currently enforced scanner subset", () => { + assert.doesNotThrow(() => assertGitHubAppOciWorkerConfig(isolatedConfig())); + assert.doesNotThrow(() => assertGitHubAppOciWorkerConfig(isolatedConfig({ scanners: ["checkov"] }))); + assert.throws( + () => assertGitHubAppOciWorkerConfig(isolatedConfig({ scanners: ["checkov", "opengrep"] })), + /without enforced hosted isolation support/, + ); + assert.throws( + () => assertGitHubAppOciWorkerConfig(isolatedConfig({ scanners: ["grype", "grype"] })), + /duplicate scanner ids/, + ); + assert.throws( + () => assertGitHubAppOciWorkerConfig(isolatedConfig({ ai: { ...defaultConfig.ai, enabled: true } })), + /does not enable AI review/, + ); +}); + +test("worker host rejects missing PostgreSQL conformance before credentials or database access", async () => { + let credentialLoads = 0; + let databaseCalls = 0; + const pool = { + async query() { databaseCalls += 1; throw new Error("database must not be reached"); }, + async connect() { databaseCalls += 1; throw new Error("database must not be reached"); }, + }; + + await assert.rejects( + createSynSecGitHubAppWorkerHost({ + profile: hostProfile(), + pool, + conformanceReport: {}, + config: isolatedConfig(), + async loadCredentials() { + credentialLoads += 1; + throw new Error("credentials must not be reached"); + }, + }), + /shared-state evidence is not ready/, + ); + assert.equal(credentialLoads, 0); + assert.equal(databaseCalls, 0); +}); + +test("worker host rejects unsupported scanners before credentials or database access", async () => { + let credentialLoads = 0; + let databaseCalls = 0; + const pool = { + async query() { databaseCalls += 1; throw new Error("database must not be reached"); }, + async connect() { databaseCalls += 1; throw new Error("database must not be reached"); }, + }; + + await assert.rejects( + createSynSecGitHubAppWorkerHost({ + profile: hostProfile(), + pool, + conformanceReport: {}, + config: isolatedConfig({ scanners: ["trivy"] }), + async loadCredentials() { + credentialLoads += 1; + throw new Error("credentials must not be reached"); + }, + }), + /without enforced hosted isolation support/, + ); + assert.equal(credentialLoads, 0); + assert.equal(databaseCalls, 0); +}); diff --git a/tests/baseline-scope.test.mjs b/tests/baseline-scope.test.mjs new file mode 100644 index 00000000..c00b6c64 --- /dev/null +++ b/tests/baseline-scope.test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildReport } from "@synsec/report"; +import { applyEvidenceAwareBaseline } from "@synsec/report/baseline"; + +function scan(scanner, findings) { + return { + scanner, + startedAt: "2026-08-22T19:00:00.000Z", + completedAt: "2026-08-22T19:00:01.000Z", + target: { path: "/repo" }, + diagnostics: [], + findings, + }; +} + +function finding(id, scanner, path) { + return { + id, + title: id, + category: "sast", + severity: "high", + confidence: 1, + scanner: { name: scanner, ruleId: id }, + ...(path ? { location: { path, startLine: 1 } } : {}), + }; +} + +test("changed-file baseline does not mark findings outside the scanned path fixed", () => { + const baseline = buildReport({ + target: { path: "/repo" }, + scans: [scan("fixture", [finding("old", "fixture", "src/untouched.ts")])], + scope: { mode: "repository" }, + }); + const current = buildReport({ + target: { path: "/repo" }, + scans: [scan("fixture", [])], + scope: { mode: "changed-files", baseRef: "base-sha", changedFiles: ["src/changed.ts"] }, + }); + const compared = applyEvidenceAwareBaseline(current, baseline); + assert.deepEqual(compared.baseline.fixed, []); +}); + +test("changed-file baseline can mark an absent covered finding fixed when its scanner reran", () => { + const baseline = buildReport({ + target: { path: "/repo" }, + scans: [scan("fixture", [finding("old", "fixture", "src/changed.ts")])], + }); + const current = buildReport({ + target: { path: "/repo" }, + scans: [scan("fixture", [])], + scope: { mode: "changed-files", changedFiles: ["SRC\\changed.ts"] }, + }); + const compared = applyEvidenceAwareBaseline(current, baseline); + assert.equal(compared.baseline.fixed.length, 1); +}); + +test("baseline does not call an absent finding fixed when its detecting scanner did not rerun", () => { + const baseline = buildReport({ + target: { path: "/repo" }, + scans: [scan("scanner-a", [finding("old", "scanner-a", "src/app.ts")])], + }); + const current = buildReport({ + target: { path: "/repo" }, + scans: [scan("scanner-b", [])], + scope: { mode: "repository" }, + }); + const compared = applyEvidenceAwareBaseline(current, baseline); + assert.deepEqual(compared.baseline.fixed, []); +}); + +test("baseline still reports new and persisting findings deterministically", () => { + const baselineFinding = finding("persist", "fixture", "src/app.ts"); + const baseline = buildReport({ target: { path: "/repo" }, scans: [scan("fixture", [baselineFinding])] }); + const current = buildReport({ + target: { path: "/repo" }, + scans: [scan("fixture", [baselineFinding, finding("new", "fixture", "src/new.ts")])], + scope: { mode: "repository" }, + }); + const compared = applyEvidenceAwareBaseline(current, baseline); + assert.equal(compared.baseline.new.length, 1); + assert.equal(compared.baseline.persisting.length, 1); + assert.deepEqual(compared.baseline.fixed, []); +}); diff --git a/tests/call-graph.test.mjs b/tests/call-graph.test.mjs new file mode 100644 index 00000000..c1175943 --- /dev/null +++ b/tests/call-graph.test.mjs @@ -0,0 +1,100 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { buildCallGraph, findCallNeighborhood } from "../packages/repository/dist/call-graph.js"; + +async function fixture(files) { + const root = await mkdtemp(join(tmpdir(), "synsec-call-graph-")); + const index = []; + for (const [path, content] of Object.entries(files)) { + const absolute = join(root, path); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, content, "utf8"); + index.push({ path, size: Buffer.byteLength(content) }); + } + return { root, index }; +} + +test("buildCallGraph resolves direct same-file JavaScript calls conservatively", async () => { + const { root, index } = await fixture({ + "src/service.ts": [ + "export function validate(input: string) {", + " return input.length > 0;", + "}", + "", + "export async function handle(input: string) {", + " if (!validate(input)) return false;", + " await externalClient.send(input);", + " return persist(input);", + "}", + "", + "const persist = (input: string) => {", + " return Boolean(input);", + "};", + ].join("\n"), + }); + + try { + const graph = await buildCallGraph(root, index); + assert.equal(graph.interpretation, "lexical-call-evidence-only"); + assert.deepEqual(graph.nodes.map((node) => node.name), ["validate", "handle", "persist"]); + + const validate = graph.nodes.find((node) => node.name === "validate"); + const handle = graph.nodes.find((node) => node.name === "handle"); + const persist = graph.nodes.find((node) => node.name === "persist"); + assert.ok(validate && handle && persist); + + assert.ok(graph.edges.some((edge) => edge.from === handle.id && edge.callee === "validate" && edge.target === validate.id)); + assert.ok(graph.edges.some((edge) => edge.from === handle.id && edge.callee === "persist" && edge.target === persist.id)); + assert.ok(graph.edges.some((edge) => edge.from === handle.id && edge.callee === "externalClient.send" && edge.resolution === "external-or-unresolved")); + + const neighborhood = findCallNeighborhood(graph, handle.id, 2); + assert.deepEqual(neighborhood.callees.map((item) => item.id).sort(), [persist.id, validate.id].sort()); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("buildCallGraph understands Python def indentation and direct calls", async () => { + const { root, index } = await fixture({ + "app.py": [ + "def load_user(user_id):", + " return db.fetch(user_id)", + "", + "def handler(user_id):", + " user = load_user(user_id)", + " return render(user)", + "", + "def render(user):", + " return str(user)", + ].join("\n"), + }); + + try { + const graph = await buildCallGraph(root, index); + const handler = graph.nodes.find((node) => node.name === "handler"); + const loadUser = graph.nodes.find((node) => node.name === "load_user"); + const render = graph.nodes.find((node) => node.name === "render"); + assert.ok(handler && loadUser && render); + assert.ok(graph.edges.some((edge) => edge.from === handler.id && edge.target === loadUser.id)); + assert.ok(graph.edges.some((edge) => edge.from === handler.id && edge.target === render.id)); + assert.ok(graph.edges.some((edge) => edge.from === loadUser.id && edge.callee === "db.fetch" && !edge.target)); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("call graph skips oversized source files rather than reading them", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-call-graph-large-")); + try { + const graph = await buildCallGraph(root, [{ path: "large.ts", size: 600_000 }]); + assert.equal(graph.nodes.length, 0); + assert.equal(graph.skippedFiles.length, 1); + assert.match(graph.skippedFiles[0].reason, /exceeds/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/checkov-incremental.test.mjs b/tests/checkov-incremental.test.mjs new file mode 100644 index 00000000..52449cb7 --- /dev/null +++ b/tests/checkov-incremental.test.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { CheckovAdapter, buildCheckovArguments } from "../packages/scanners/dist/index.js"; + +const context = { + target: { path: "/repo" }, +}; + +test("Checkov defaults to repository directory scanning without changed-file scope", () => { + assert.deepEqual(buildCheckovArguments(context), [ + "-d", + "/repo", + "-o", + "json", + "--quiet", + "--compact", + ]); +}); + +test("Checkov uses repeated file arguments for bounded changed-file scope", () => { + assert.deepEqual(buildCheckovArguments({ + ...context, + changedFiles: ["infra/main.tf", "deploy/app.yaml", "infra/main.tf"], + }), [ + "-o", + "json", + "--quiet", + "--compact", + "-f", + "infra/main.tf", + "-f", + "deploy/app.yaml", + ]); +}); + +test("Checkov changed-file execution independently rejects path escape and absolute paths", () => { + for (const path of ["../outside.tf", "infra/../../outside.tf", "/tmp/outside.tf", "C:/outside.tf"]) { + assert.throws(() => buildCheckovArguments({ ...context, changedFiles: [path] }), /unsafe repository path/); + } +}); + +test("Checkov changed-file execution bounds adapter scope and treats an empty list as full scan", () => { + assert.equal(buildCheckovArguments({ ...context, changedFiles: [] })[0], "-d"); + assert.throws(() => buildCheckovArguments({ + ...context, + changedFiles: Array.from({ length: 501 }, (_, index) => `infra/file-${index}.tf`), + }), /500-file adapter limit/); +}); + +test("Checkov uses one injected runner for availability and scan execution", async () => { + const calls = []; + const runner = async (command, args, options = {}) => { + calls.push({ command, args, options }); + if (args[0] === "--version") { + return { exitCode: 0, stdout: "Checkov 3.2.0\n", stderr: "", signal: null, timedOut: false, truncated: false }; + } + return { + exitCode: 1, + stdout: JSON.stringify({ check_type: "terraform", results: { failed_checks: [] } }), + stderr: "", + signal: null, + timedOut: false, + truncated: false, + }; + }; + const adapter = new CheckovAdapter(runner); + + assert.deepEqual(await adapter.checkAvailability(), { available: true, version: "Checkov 3.2.0" }); + const result = await adapter.scan({ ...context, changedFiles: ["infra/main.tf"] }); + assert.equal(result.scanner, "checkov"); + assert.deepEqual(result.findings, []); + assert.equal(calls.length, 2); + assert.deepEqual(calls[0].args, ["--version"]); + assert.deepEqual(calls[1].args, ["-o", "json", "--quiet", "--compact", "-f", "infra/main.tf"]); + assert.equal(calls[1].options.cwd, "/repo"); +}); diff --git a/tests/cli-ai-options.test.mjs b/tests/cli-ai-options.test.mjs new file mode 100644 index 00000000..f54045c6 --- /dev/null +++ b/tests/cli-ai-options.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { resolveAiReviewSelection } from "../apps/cli/dist/ai-options.js"; + +const base = { baseUrl: "https://ai.example.invalid/v1" }; + +test("AI review selection preserves existing single-model behavior", () => { + const selection = resolveAiReviewSelection({ + ...base, + configuredModel: "reviewer-a", + apiKey: "secret-key", + }); + assert.equal(selection.mode, "single"); + assert.deepEqual(selection.models, ["reviewer-a"]); + assert.equal(selection.minimumReviewers, 1); + assert.equal(selection.concurrency, 1); + assert.equal(selection.providers[0].apiKey, "secret-key"); +}); + +test("AI review selection supports bounded deduplicated consensus models", () => { + const selection = resolveAiReviewSelection({ + ...base, + multipleModels: " reviewer-a,reviewer-b,reviewer-a,reviewer-c ", + minimumReviewers: 3, + concurrency: 2, + }); + assert.equal(selection.mode, "consensus"); + assert.deepEqual(selection.models, ["reviewer-a", "reviewer-b", "reviewer-c"]); + assert.equal(selection.minimumReviewers, 3); + assert.equal(selection.concurrency, 2); + assert.deepEqual(selection.providers.map((provider) => provider.model), selection.models); +}); + +test("AI review selection rejects ambiguous or insufficient model input", () => { + assert.throws(() => resolveAiReviewSelection({ + ...base, + singleModel: "a", + multipleModels: "b,c", + }), /either --ai-model or --ai-models/); + assert.throws(() => resolveAiReviewSelection({ ...base, multipleModels: "same,same" }), /at least two unique/); + assert.throws(() => resolveAiReviewSelection({ ...base }), /no model is configured/); +}); + +test("AI review selection rejects excessive models and invalid consensus controls", () => { + assert.throws(() => resolveAiReviewSelection({ + ...base, + multipleModels: Array.from({ length: 11 }, (_, index) => `m${index}`).join(","), + }), /at most 10/); + assert.throws(() => resolveAiReviewSelection({ + ...base, + multipleModels: "a,b", + minimumReviewers: 3, + }), /--ai-min-reviewers/); + assert.throws(() => resolveAiReviewSelection({ + ...base, + multipleModels: "a,b,c,d,e", + concurrency: 5, + }), /--ai-review-concurrency/); +}); diff --git a/tests/cli-lifecycle-review-deadlines.test.mjs b/tests/cli-lifecycle-review-deadlines.test.mjs new file mode 100644 index 00000000..20794ec7 --- /dev/null +++ b/tests/cli-lifecycle-review-deadlines.test.mjs @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import test from "node:test"; + +import { writeLifecycleStore } from "@synsec/lifecycle"; + +const execFileAsync = promisify(execFile); + +async function runCli(args) { + return execFileAsync(process.execPath, ["apps/cli/dist/lifecycle-review-deadlines-cli.js", ...args], { + cwd: process.cwd(), + env: { ...process.env, NO_COLOR: "1" }, + }); +} + +function fixtureStore() { + return { + schemaVersion: 1, + records: { + overdue: { + fingerprint: "overdue", + state: "accepted-risk", + updatedAt: "2026-08-01T00:00:00.000Z", + reviewAt: "2026-08-20T00:00:00.000Z", + note: "must never appear in governance output", + owner: "security-team", + lastSeenPath: "src/private.ts", + }, + soon: { + fingerprint: "soon", + state: "false-positive", + updatedAt: "2026-08-01T00:00:00.000Z", + reviewAt: "2026-08-25T00:00:00.000Z", + }, + unscheduled: { + fingerprint: "unscheduled", + state: "accepted-risk", + updatedAt: "2026-08-01T00:00:00.000Z", + }, + scannerState: { + fingerprint: "scannerState", + state: "confirmed", + updatedAt: "2026-08-01T00:00:00.000Z", + reviewAt: "2026-08-01T00:00:00.000Z", + }, + }, + }; +} + +test("lifecycle review CLI emits minimized deterministic JSON", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-lifecycle-reviews-")); + const storePath = join(root, "lifecycle.json"); + try { + await writeLifecycleStore(storePath, fixtureStore()); + const result = await runCli([ + storePath, + "--now", "2026-08-23T00:00:00.000Z", + "--due-soon-days", "3", + "--json", + ]); + const assessment = JSON.parse(result.stdout); + assert.deepEqual(assessment.summary, { + reviewable: 3, + unscheduled: 1, + overdue: 1, + dueSoon: 1, + scheduled: 0, + }); + assert.deepEqual(assessment.items.map((item) => item.fingerprint), ["overdue", "soon"]); + assert.doesNotMatch(result.stdout, /must never appear/); + assert.doesNotMatch(result.stdout, /security-team/); + assert.doesNotMatch(result.stdout, /src\/private\.ts/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("lifecycle review CLI summary-only JSON omits paths, fingerprints, and review timestamps", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-lifecycle-review-summary-")); + const storePath = join(root, "private-lifecycle.json"); + try { + await writeLifecycleStore(storePath, fixtureStore()); + const result = await runCli([ + storePath, + "--now", "2026-08-23T00:00:00.000Z", + "--due-soon-days", "3", + "--summary-only", + "--json", + ]); + const summary = JSON.parse(result.stdout); + assert.equal(summary.ready, true); + assert.deepEqual(summary.violations, []); + assert.deepEqual(summary.summary, { + reviewable: 3, + overdue: 1, + dueSoon: 1, + scheduled: 0, + unscheduled: 1, + }); + assert.doesNotMatch(result.stdout, /private-lifecycle|"fingerprint"|"reviewAt"|"overdue"\s*:\s*"/); + assert.doesNotMatch(result.stdout, /must never appear|security-team|src\/private\.ts/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("lifecycle review CLI has distinct policy exit codes", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-lifecycle-review-policy-")); + const storePath = join(root, "lifecycle.json"); + try { + await writeLifecycleStore(storePath, fixtureStore()); + + await assert.rejects( + runCli([storePath, "--now", "2026-08-23T00:00:00.000Z", "--fail-overdue"]), + (error) => error?.code === 2, + ); + await assert.rejects( + runCli([storePath, "--now", "2026-08-19T00:00:00.000Z", "--fail-unscheduled"]), + (error) => error?.code === 3, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("lifecycle review CLI rejects symlink inputs before reading their target", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-lifecycle-review-symlink-")); + const targetPath = join(root, "target.json"); + const linkPath = join(root, "lifecycle.json"); + try { + await writeLifecycleStore(targetPath, fixtureStore()); + await symlink(targetPath, linkPath); + await assert.rejects( + runCli([linkPath, "--summary-only", "--json"]), + (error) => { + assert.equal(error?.code, 1); + assert.match(error?.stderr ?? "", /non-symlink regular file/); + assert.doesNotMatch(error?.stdout ?? "", /overdue|unscheduled|fingerprint/); + return true; + }, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("lifecycle review CLI rejects unsupported options without echoing values", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-lifecycle-review-options-")); + const storePath = join(root, "lifecycle.json"); + try { + await writeLifecycleStore(storePath, fixtureStore()); + await assert.rejects( + runCli([storePath, "--database-url=postgres://user:super-secret@example.invalid/db"]), + (error) => { + assert.equal(error?.code, 1); + assert.doesNotMatch(error?.stderr ?? "", /super-secret/); + return true; + }, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/cli-triage-collaboration.test.mjs b/tests/cli-triage-collaboration.test.mjs new file mode 100644 index 00000000..ffb1b583 --- /dev/null +++ b/tests/cli-triage-collaboration.test.mjs @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import test from "node:test"; +import { buildReport } from "@synsec/report"; + +const exec = promisify(execFile); +const cli = new URL("../apps/cli/dist/index.js", import.meta.url); + +function reportFor(root) { + return buildReport({ + target: { path: root }, + scans: [{ + scanner: "fixture", + startedAt: "2026-08-22T19:00:00.000Z", + completedAt: "2026-08-22T19:00:01.000Z", + target: { path: root }, + diagnostics: [], + findings: [{ + id: "TRIAGE-1", + title: "Needs human review", + category: "sast", + severity: "high", + confidence: 1, + scanner: { name: "fixture", ruleId: "TRIAGE-1" }, + location: { path: "src/app.ts", startLine: 2 }, + }], + }], + scope: { mode: "repository" }, + }); +} + +test("CLI triage can assign ownership and append bounded review comments", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-cli-collab-")); + try { + const report = reportFor(root); + const fingerprint = report.findings[0].fingerprint; + const reportPath = join(root, "report.json"); + const lifecyclePath = join(root, "lifecycle.json"); + const commentsPath = join(root, "review-comments.json"); + await writeFile(reportPath, JSON.stringify(report), "utf8"); + + const owner = await exec(process.execPath, [ + cli.pathname, "triage", reportPath, fingerprint, "owner", "--note", "appsec", "--store", lifecyclePath, + ]); + assert.match(owner.stdout, /Assigned .* -> appsec/); + const lifecycle = JSON.parse(await readFile(lifecyclePath, "utf8")); + assert.equal(lifecycle.records[fingerprint].owner, "appsec"); + + const comment = await exec(process.execPath, [ + cli.pathname, "triage", reportPath, fingerprint, "comment", "--note", "verify authorization boundary", "--store", lifecyclePath, + ]); + assert.match(comment.stdout, /Added review comment/); + const comments = JSON.parse(await readFile(commentsPath, "utf8")); + assert.equal(comments.comments[fingerprint].length, 1); + assert.equal(comments.comments[fingerprint][0].body, "verify authorization boundary"); + + const listed = await exec(process.execPath, [ + cli.pathname, "triage", reportPath, "--list", "--store", lifecyclePath, + ]); + assert.match(listed.stdout, /owner:appsec/); + assert.match(listed.stdout, /comments:1/); + assert.match(listed.stdout, /Review comments:/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("CLI triage refuses ownership/comments for fingerprints absent from the report", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-cli-collab-reject-")); + try { + const reportPath = join(root, "report.json"); + await writeFile(reportPath, JSON.stringify(reportFor(root)), "utf8"); + await assert.rejects( + exec(process.execPath, [cli.pathname, "triage", reportPath, "not-a-real-fingerprint", "comment", "--note", "should fail"]), + (error) => { + assert.match(String(error.stderr), /fingerprint is not present/); + return true; + }, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/cli-triage-review-deadline.test.mjs b/tests/cli-triage-review-deadline.test.mjs new file mode 100644 index 00000000..aba80dfe --- /dev/null +++ b/tests/cli-triage-review-deadline.test.mjs @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { execFile } from "node:child_process"; +import test from "node:test"; + +import { readLifecycleStore } from "@synsec/lifecycle"; +import { buildReport, writeReport } from "@synsec/report"; + +const execFileAsync = promisify(execFile); + +function report() { + return buildReport({ + target: { path: "/repo" }, + scans: [{ + scanner: "fixture", + startedAt: "2026-08-22T20:00:00.000Z", + completedAt: "2026-08-22T20:00:01.000Z", + target: { path: "/repo" }, + diagnostics: [], + findings: [{ + id: "REVIEW-CLI-1", + title: "Review this accepted risk periodically", + category: "sast", + severity: "medium", + confidence: 1, + scanner: { name: "fixture", ruleId: "REVIEW-CLI-1" }, + location: { path: "src/app.ts", startLine: 9 }, + }], + }], + scope: { mode: "repository" }, + }); +} + +async function runCli(args) { + return execFileAsync(process.execPath, ["apps/cli/dist/index.js", ...args], { + cwd: process.cwd(), + env: { ...process.env, NO_COLOR: "1" }, + }); +} + +test("CLI can set, list, and clear a human review deadline without changing accepted-risk state", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-cli-review-deadline-")); + const reportPath = join(root, "report.json"); + const lifecyclePath = join(root, "lifecycle.json"); + try { + const current = report(); + const fingerprint = current.findings[0].fingerprint; + await writeReport(reportPath, current); + + await runCli(["triage", reportPath, fingerprint, "accepted-risk", "--store", lifecyclePath, "--note", "temporary vendor constraint"]); + await runCli(["triage", reportPath, fingerprint, "review-at", "--store", lifecyclePath, "--note", "2026-11-01T12:00:00.000Z"]); + + let store = await readLifecycleStore(lifecyclePath); + assert.equal(store.records[fingerprint].state, "accepted-risk"); + assert.equal(store.records[fingerprint].reviewAt, "2026-11-01T12:00:00.000Z"); + assert.equal(store.records[fingerprint].note, "temporary vendor constraint"); + + const listed = await runCli(["triage", reportPath, "--list", "--store", lifecyclePath]); + assert.match(listed.stdout, /accepted-risk/); + assert.match(listed.stdout, /review:2026-11-01T12:00:00.000Z/); + + await runCli(["triage", reportPath, fingerprint, "review-at", "--store", lifecyclePath, "--note", "clear"]); + store = await readLifecycleStore(lifecyclePath); + assert.equal(store.records[fingerprint].state, "accepted-risk"); + assert.equal(store.records[fingerprint].reviewAt, undefined); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs new file mode 100644 index 00000000..1ddf11ef --- /dev/null +++ b/tests/cli.test.mjs @@ -0,0 +1,281 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createServer } from "node:http"; +import { promisify } from "node:util"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildReport } from "../packages/report/dist/index.js"; + +const exec = promisify(execFile); +const cli = new URL("../apps/cli/dist/index.js", import.meta.url); + +test("CLI reports its version", async () => { + const { stdout } = await exec(process.execPath, [cli.pathname, "version"]); + assert.equal(stdout.trim(), "0.2.0"); +}); + +test("CLI lists capability-scoped defensive workflows", async () => { + const { stdout } = await exec(process.execPath, [cli.pathname, "workflows"]); + assert.match(stdout, /repository-review/); + assert.match(stdout, /dependency-review/); + assert.match(stdout, /secrets-review/); + assert.match(stdout, /external network assessment: forbidden/); +}); + +test("CLI help documents finding lifecycle, remediation verification, and multi-model review", async () => { + const { stdout } = await exec(process.execPath, [cli.pathname, "help"]); + assert.match(stdout, /synsec triage /); + assert.match(stdout, /synsec verify /); + assert.match(stdout, /false-positive/); + assert.match(stdout, /accepted-risk/); + assert.match(stdout, /--ai-models /); + assert.match(stdout, /--ai-min-reviewers /); + assert.match(stdout, /model inference only/); +}); + +test("CLI init writes a safe default configuration", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-cli-test-")); + try { + await exec(process.execPath, [cli.pathname, "init", root]); + const parsed = JSON.parse(await readFile(join(root, "synsec.config.json"), "utf8")); + assert.equal(parsed.schemaVersion, 1); + assert.equal(parsed.ai.enabled, false); + assert.equal(parsed.ai.sendSourceContext, false); + assert.ok(parsed.scanners.includes("opengrep")); + assert.ok(parsed.scanners.includes("syft")); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("CLI imports SARIF into a native SynSec report", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-sarif-test-")); + try { + const input = join(root, "external.sarif"); + const output = join(root, "imported.json"); + await writeFile(input, JSON.stringify({ + version: "2.1.0", + runs: [{ + tool: { driver: { name: "FixtureScanner", rules: [{ id: "FIX-1", shortDescription: { text: "Fixture issue" } }] } }, + results: [{ ruleId: "FIX-1", level: "warning", message: { text: "Fixture issue" } }], + }], + }), "utf8"); + + const { stdout } = await exec(process.execPath, [ + cli.pathname, + "import-sarif", + input, + "--root", + root, + "--output", + output, + ]); + assert.match(stdout, /Imported 1 SARIF finding/); + const report = JSON.parse(await readFile(output, "utf8")); + assert.equal(report.findingCount, 1); + assert.equal(report.findings[0].primary.scanner.name, "FixtureScanner"); + assert.equal(report.findings[0].primary.severity, "medium"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("CLI triage persists explicit lifecycle decisions and lists them", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-triage-test-")); + try { + const input = join(root, "external.sarif"); + const reportPath = join(root, "report.json"); + const storePath = join(root, "lifecycle.json"); + await writeFile(input, JSON.stringify({ + version: "2.1.0", + runs: [{ + tool: { driver: { name: "FixtureScanner", rules: [{ id: "FIX-2", shortDescription: { text: "Review me" } }] } }, + results: [{ ruleId: "FIX-2", level: "error", message: { text: "Review me" } }], + }], + }), "utf8"); + + await exec(process.execPath, [ + cli.pathname, + "import-sarif", + input, + "--root", + root, + "--output", + reportPath, + ]); + const report = JSON.parse(await readFile(reportPath, "utf8")); + const fingerprint = report.findings[0].fingerprint; + + const updated = await exec(process.execPath, [ + cli.pathname, + "triage", + reportPath, + fingerprint, + "confirmed", + "--note", + "reviewed", + "--store", + storePath, + ]); + assert.match(updated.stdout, /-> confirmed/); + + const stored = JSON.parse(await readFile(storePath, "utf8")); + assert.equal(stored.records[fingerprint].state, "confirmed"); + assert.equal(stored.records[fingerprint].note, "reviewed"); + + const listed = await exec(process.execPath, [ + cli.pathname, + "triage", + reportPath, + "--list", + "--store", + storePath, + ]); + assert.match(listed.stdout, /confirmed/); + assert.match(listed.stdout, /Review me/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("CLI verify confirms a remediation only when the detecting scanner reran over repository scope", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-verify-test-")); + try { + const scan = { + scanner: "fixture", + startedAt: "2026-01-01T00:00:00.000Z", + completedAt: "2026-01-01T00:00:01.000Z", + target: { path: root }, + diagnostics: [], + findings: [{ + id: "A", + title: "Finding A", + category: "sast", + severity: "high", + confidence: 1, + scanner: { name: "fixture", ruleId: "A" }, + location: { path: "src/A.ts", startLine: 1 }, + }], + }; + const before = buildReport({ target: { path: root }, scans: [scan], scope: { mode: "repository" } }); + const after = buildReport({ + target: { path: root }, + scans: [{ ...scan, findings: [] }], + scope: { mode: "repository" }, + }); + const beforePath = join(root, "before.json"); + const afterPath = join(root, "after.json"); + const outputPath = join(root, "verification.json"); + await writeFile(beforePath, JSON.stringify(before), "utf8"); + await writeFile(afterPath, JSON.stringify(after), "utf8"); + + const result = await exec(process.execPath, [ + cli.pathname, + "verify", + beforePath, + afterPath, + "--output", + outputPath, + ]); + assert.match(result.stdout, /1 fixed/); + assert.match(result.stdout, /\[FIXED\] Finding A/); + const verification = JSON.parse(await readFile(outputPath, "utf8")); + assert.equal(verification.summary.fixed, 1); + assert.equal(verification.summary.inconclusive, 0); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("CLI review can run bounded multi-model consensus without treating it as scanner evidence", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-cli-consensus-")); + const requestedModels = []; + const server = createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const body = JSON.parse(Buffer.concat(chunks).toString("utf8")); + requestedModels.push(body.model); + const verdict = body.model === "reviewer-c" ? "likely" : "confirmed"; + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + verdict, + confidence: 0.9, + severity: "high", + summary: `Reviewed by ${body.model}`, + rationale: "Repository-local defensive review fixture.", + gate: [], + remediation: "Use a safer repository-local implementation.", + }), + }, + }], + })); + }); + await new Promise((resolvePromise) => server.listen(0, "127.0.0.1", resolvePromise)); + try { + const address = server.address(); + assert.ok(address && typeof address === "object"); + const report = buildReport({ + target: { path: root }, + scans: [{ + scanner: "fixture", + startedAt: "2026-08-22T20:00:00.000Z", + completedAt: "2026-08-22T20:00:01.000Z", + target: { path: root }, + diagnostics: [], + findings: [{ + id: "fixture-finding", + title: "Review fixture", + category: "sast", + severity: "high", + confidence: 0.95, + scanner: { name: "fixture", ruleId: "FIX-REVIEW" }, + location: { path: "src/app.ts", startLine: 3 }, + }], + }], + scope: { mode: "repository" }, + }); + const reportPath = join(root, "report.json"); + const outputPath = join(root, "consensus.json"); + await writeFile(reportPath, JSON.stringify(report), "utf8"); + + const result = await exec(process.execPath, [ + cli.pathname, + "review", + reportPath, + "--root", + root, + "--ai-base-url", + `http://127.0.0.1:${address.port}`, + "--ai-models", + "reviewer-a,reviewer-b,reviewer-c", + "--ai-min-reviewers", + "2", + "--ai-review-concurrency", + "2", + "--output", + outputPath, + ]); + assert.match(result.stdout, /AI consensus review/); + assert.deepEqual([...requestedModels].sort(), ["reviewer-a", "reviewer-b", "reviewer-c"]); + + const output = JSON.parse(await readFile(outputPath, "utf8")); + assert.equal(output.schemaVersion, 2); + assert.equal(output.reviewMode, "consensus"); + assert.equal(output.interpretation, "model-consensus-not-scanner-evidence"); + assert.deepEqual(output.models, ["reviewer-a", "reviewer-b", "reviewer-c"]); + const entry = output.reviews[report.findings[0].fingerprint]; + assert.equal(entry.reviews.length, 3); + assert.equal(entry.failures.length, 0); + assert.equal(entry.consensus.verdict, "confirmed"); + assert.equal(entry.consensus.agreement, "majority"); + assert.equal(entry.consensus.interpretation, "model-consensus-not-scanner-evidence"); + } finally { + await new Promise((resolvePromise, reject) => server.close((error) => error ? reject(error) : resolvePromise())); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/config.test.mjs b/tests/config.test.mjs new file mode 100644 index 00000000..1aaee425 --- /dev/null +++ b/tests/config.test.mjs @@ -0,0 +1,69 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { defaultConfig, parseConfig, resolveAiModel } from "../packages/config/dist/index.js"; + +test("default config prefers the maintained scanner set and keeps AI off", () => { + assert.equal(defaultConfig.ai.enabled, false); + assert.equal(defaultConfig.ai.sendSourceContext, false); + assert.ok(defaultConfig.scanners.includes("betterleaks")); + assert.ok(defaultConfig.scanners.includes("opengrep")); + assert.equal(defaultConfig.reports.markdown, ".synsec/report.md"); +}); + +test("parseConfig merges user values with safe defaults", () => { + const config = parseConfig({ + schemaVersion: 1, + scanners: ["trivy"], + parallelism: 2, + failOn: "high", + reports: { markdown: "security.md" }, + ai: { + enabled: true, + sendSourceContext: false, + baseUrl: "http://localhost:8080/v1", + model: "router/default", + workflowModels: { + "dependency-review": "router/dependency", + "secrets-review": "router/secrets", + ignored: 42, + }, + }, + }); + assert.deepEqual(config.scanners, ["trivy"]); + assert.equal(config.parallelism, 2); + assert.equal(config.failOn, "high"); + assert.equal(config.ai.enabled, true); + assert.equal(config.ai.sendSourceContext, false); + assert.equal(config.reports.json, ".synsec/report.json"); + assert.equal(config.reports.markdown, "security.md"); + assert.equal(config.ai.workflowModels["dependency-review"], "router/dependency"); + assert.equal(config.ai.workflowModels.ignored, undefined); +}); + +test("AI model routing prefers explicit override, then workflow route, then configured and environment defaults", () => { + const config = parseConfig({ + ai: { + enabled: true, + model: "router/default", + workflowModels: { "dependency-review": "router/dependency" }, + }, + }).ai; + + assert.equal(resolveAiModel(config, { + workflowId: "dependency-review", + overrideModel: "router/forced", + environmentModel: "router/env", + }), "router/forced"); + assert.equal(resolveAiModel(config, { + workflowId: "dependency-review", + environmentModel: "router/env", + }), "router/dependency"); + assert.equal(resolveAiModel(config, { + workflowId: "repository-review", + environmentModel: "router/env", + }), "router/default"); + assert.equal(resolveAiModel({ ...defaultConfig.ai }, { + workflowId: "repository-review", + environmentModel: "router/env", + }), "router/env"); +}); diff --git a/tests/core.test.mjs b/tests/core.test.mjs index f58e6f93..f51fb748 100644 --- a/tests/core.test.mjs +++ b/tests/core.test.mjs @@ -2,27 +2,31 @@ import assert from "node:assert/strict"; import test from "node:test"; import { correlateFindings } from "../packages/core/dist/index.js"; -test("correlates duplicate findings from multiple scanners", () => { - const base = { - category: "dependency", - severity: "high", - confidence: 0.8, - location: { path: "package-lock.json" }, - identifiers: { cve: ["CVE-2026-1234"] }, - title: "Example vulnerable dependency", - }; - +test("correlates the same dependency advisory across scanners despite different rule IDs, titles, and alias sets", () => { const correlated = correlateFindings([ { - ...base, id: "one", + category: "dependency", + severity: "high", + confidence: 0.8, + location: { path: "package-lock.json" }, + identifiers: { cve: ["CVE-2026-1234"] }, + title: "First scanner advisory title", scanner: { name: "trivy", ruleId: "CVE-2026-1234" }, + metadata: { package: "demo-package" }, + fingerprint: "native-trivy-fingerprint", }, { - ...base, id: "two", + category: "dependency", + severity: "critical", confidence: 0.95, - scanner: { name: "grype", ruleId: "CVE-2026-1234" }, + location: { path: "package-lock.json" }, + identifiers: { cve: ["CVE-2026-1234"], ghsa: ["GHSA-demo-demo-demo"] }, + title: "Second scanner uses a different title", + scanner: { name: "grype", ruleId: "GHSA-demo-demo-demo" }, + metadata: { package: "demo-package" }, + fingerprint: "native-grype-fingerprint", }, ]); @@ -30,4 +34,32 @@ test("correlates duplicate findings from multiple scanners", () => { assert.equal(correlated[0].primary.id, "two"); assert.equal(correlated[0].duplicates.length, 1); assert.equal(correlated[0].sources.length, 2); + assert.notEqual(correlated[0].fingerprint, "native-trivy-fingerprint"); +}); + +test("correlates secret scanner findings at the same source location without hashing secret content", () => { + const correlated = correlateFindings([ + { + id: "one", + title: "Potential API token", + category: "secret", + severity: "high", + confidence: 0.9, + scanner: { name: "trivy", ruleId: "generic-token" }, + location: { path: "src/config.ts", startLine: 7 }, + }, + { + id: "two", + title: "Credential detected", + category: "secret", + severity: "high", + confidence: 0.98, + scanner: { name: "betterleaks", ruleId: "vendor-token" }, + location: { path: "src/config.ts", startLine: 7 }, + }, + ]); + + assert.equal(correlated.length, 1); + assert.equal(correlated[0].primary.id, "two"); + assert.equal(correlated[0].sources.length, 2); }); diff --git a/tests/coverage-context.test.mjs b/tests/coverage-context.test.mjs new file mode 100644 index 00000000..4da8e4fe --- /dev/null +++ b/tests/coverage-context.test.mjs @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { findingCoverageContext, parseLcovCoverage } from "@synsec/repository/coverage-context"; + +function finding(path, line) { + return { + id: "fixture", + title: "Fixture", + category: "sast", + severity: "medium", + confidence: 1, + scanner: { name: "fixture" }, + location: { path, startLine: line }, + }; +} + +test("LCOV coverage maps observed test hits to finding lines without claiming runtime reachability", () => { + const coverage = parseLcovCoverage([ + "TN:", + "SF:src/a.ts", + "DA:10,3", + "DA:11,0", + "end_of_record", + "SF:src/b.ts", + "DA:2,1", + "end_of_record", + "", + ].join("\n")); + + assert.equal(coverage.fileCount, 2); + assert.equal(coverage.lineCount, 3); + assert.equal(coverage.interpretation, "observed-test-coverage-not-runtime-reachability"); + assert.deepEqual(findingCoverageContext(coverage, finding("src/a.ts", 10)), { + path: "src/a.ts", + line: 10, + status: "executed", + hits: 3, + interpretation: "observed-test-coverage-not-runtime-reachability", + }); + assert.equal(findingCoverageContext(coverage, finding("src/a.ts", 11)).status, "not-executed"); + assert.equal(findingCoverageContext(coverage, finding("src/a.ts", 12)).status, "no-data"); +}); + +test("LCOV parsing combines duplicate line records deterministically", () => { + const coverage = parseLcovCoverage("SF:src/a.ts\nDA:10,2\nDA:10,5\nend_of_record\n"); + assert.equal(coverage.lineCount, 1); + assert.deepEqual(coverage.files[0].lines, [{ line: 10, hits: 7 }]); +}); + +test("LCOV absolute paths are accepted only inside an explicitly bounded repository root", () => { + const root = process.platform === "win32" ? "C:\\repo" : "/repo"; + const inside = process.platform === "win32" ? "C:\\repo\\src\\a.ts" : "/repo/src/a.ts"; + const outside = process.platform === "win32" ? "C:\\other\\secret.ts" : "/other/secret.ts"; + + const withoutRoot = parseLcovCoverage(`SF:${inside}\nDA:1,1\nend_of_record\n`); + assert.equal(withoutRoot.fileCount, 0); + + const withRoot = parseLcovCoverage([ + `SF:${inside}`, + "DA:1,1", + "end_of_record", + `SF:${outside}`, + "DA:2,1", + "end_of_record", + ].join("\n"), { repositoryRoot: root }); + assert.equal(withRoot.fileCount, 1); + assert.equal(withRoot.files[0].path, "src/a.ts"); +}); + +test("LCOV parser ignores path traversal records and rejects malformed numeric data", () => { + const traversal = parseLcovCoverage("SF:../outside.ts\nDA:1,1\nend_of_record\n"); + assert.equal(traversal.fileCount, 0); + + assert.throws(() => parseLcovCoverage("SF:src/a.ts\nDA:not-a-line,1\nend_of_record\n"), /line number/); + assert.throws(() => parseLcovCoverage("SF:src/a.ts\nDA:1,-1\nend_of_record\n"), /hit count/); +}); + +test("finding coverage returns no-data when a finding has no concrete covered location", () => { + const coverage = parseLcovCoverage("SF:src/a.ts\nDA:1,1\nend_of_record\n"); + const result = findingCoverageContext(coverage, { + id: "repo", + title: "Repository-level", + category: "posture", + severity: "low", + confidence: 1, + scanner: { name: "fixture" }, + }); + assert.equal(result.status, "no-data"); +}); diff --git a/tests/dashboard-review-deadlines.test.mjs b/tests/dashboard-review-deadlines.test.mjs new file mode 100644 index 00000000..17a0340a --- /dev/null +++ b/tests/dashboard-review-deadlines.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { renderProjectDashboardIndex } from "@synsec/dashboard"; +import { buildReport } from "@synsec/report"; + +function dashboardInput() { + const report = buildReport({ + target: { path: "/repo" }, + scans: [{ + scanner: "fixture", + startedAt: "2026-08-23T00:00:00.000Z", + completedAt: "2026-08-23T00:00:01.000Z", + target: { path: "/repo" }, + diagnostics: [], + findings: [], + }], + }); + return { + report, + triage: { + schemaVersion: 1, + reportId: report.reportId, + items: [], + summary: { current: 0, assigned: 0, unassigned: 0, commented: 0 }, + interpretation: "triage-metadata-not-scanner-evidence", + }, + posture: { + schemaVersion: 1, + indexedFileCount: 0, + routeCount: 0, + routeAuth: { + "authorization-signal-observed": 0, + "authentication-signal-observed": 0, + "no-auth-signal-observed": 0, + }, + routeSinkKinds: { process: 0, filesystem: 0, database: 0, network: 0 }, + routesWithSinkSignals: 0, + routesWithoutAuthSignals: 0, + interpretation: "bounded-lexical-posture-only", + }, + reviewDeadlines: { + schemaVersion: 1, + generatedAt: "2026-08-23T00:00:00.000Z", + dueSoonWindowMs: 604800000, + items: [{ + fingerprint: "review-fingerprint", + state: "accepted-risk", + reviewAt: "2026-08-22T00:00:00.000Z", + status: "overdue", + }], + summary: { reviewable: 4, unscheduled: 2, overdue: 1, dueSoon: 1, scheduled: 0 }, + }, + }; +} + +test("dashboard shows aggregate lifecycle review health without item details", () => { + const html = renderProjectDashboardIndex(dashboardInput()); + assert.match(html, /1<\/div>
overdue exception reviews/); + assert.match(html, /1 due soon · 2 unscheduled/); + assert.match(html, /governance metadata, not scanner evidence/); + assert.doesNotMatch(html, /review-fingerprint/); + assert.doesNotMatch(html, /2026-08-22T00:00:00\.000Z/); + assert.doesNotMatch(html, /accepted-risk/); +}); diff --git a/tests/dashboard-route-security-review.test.mjs b/tests/dashboard-route-security-review.test.mjs new file mode 100644 index 00000000..13084844 --- /dev/null +++ b/tests/dashboard-route-security-review.test.mjs @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { renderProjectDashboardIndex } from "@synsec/dashboard"; +import { buildReport } from "@synsec/report"; + +function inputWithRouteReviews() { + const report = buildReport({ + target: { path: "/repo" }, + scans: [{ + scanner: "fixture", + startedAt: "2026-08-23T00:00:00.000Z", + completedAt: "2026-08-23T00:00:01.000Z", + target: { path: "/repo" }, + diagnostics: [], + findings: [], + }], + }); + const base = { + method: "POST", + route: "/secret-bearing-route-ghp_abcdefghijklmnopqrstuvwxyz1234567890", + handler: "credentialBearingHandler", + sinkKinds: ["process"], + protectionStatus: "not-assessed", + callScope: "same-file", + interpretation: "structural-route-security-review-context-only", + }; + return { + report, + triage: { + schemaVersion: 1, + reportId: report.reportId, + items: [], + summary: { current: 0, assigned: 0, unassigned: 0, commented: 0 }, + interpretation: "triage-metadata-not-scanner-evidence", + }, + posture: { + schemaVersion: 1, + indexedFileCount: 0, + routeCount: 0, + routeAuth: { + "authorization-signal-observed": 0, + "authentication-signal-observed": 0, + "no-auth-signal-observed": 0, + }, + routeSinkKinds: { process: 0, filesystem: 0, database: 0, network: 0 }, + routesWithSinkSignals: 0, + routesWithoutAuthSignals: 0, + interpretation: "bounded-lexical-posture-only", + }, + routeSecurityReviews: [ + { ...base, signal: "sensitive-sink-auth-context-unavailable" }, + { ...base, signal: "sensitive-sink-without-auth-signal", protectionStatus: "no-auth-signal-observed" }, + { ...base, signal: "sensitive-sink-with-authorization-signal", protectionStatus: "authorization-signal-observed" }, + { ...base, signal: "sensitive-sink-with-authentication-signal", protectionStatus: "authentication-signal-observed" }, + ], + }; +} + +test("dashboard renders only validated aggregate route-security review counts", () => { + const html = renderProjectDashboardIndex(inputWithRouteReviews()); + assert.match(html, /2<\/div>
sensitive-sink auth reviews/); + assert.match(html, /1 authorization signal · 1 authentication signal/); + assert.match(html, /validated structural review context, not protection or exploitability verdicts/); + assert.doesNotMatch(html, /credentialBearingHandler/); + assert.doesNotMatch(html, /secret-bearing-route/); + assert.doesNotMatch(html, /ghp_abcdefghijklmnopqrstuvwxyz1234567890/); +}); + +test("dashboard rejects inconsistent route-security metadata before rendering", () => { + const input = inputWithRouteReviews(); + input.routeSecurityReviews[0].signal = "sensitive-sink-with-authorization-signal"; + assert.throws( + () => renderProjectDashboardIndex(input), + /inconsistent protection metadata/, + ); +}); diff --git a/tests/dashboard.test.mjs b/tests/dashboard.test.mjs new file mode 100644 index 00000000..90be143d --- /dev/null +++ b/tests/dashboard.test.mjs @@ -0,0 +1,156 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { renderProjectDashboardIndex, writeProjectDashboard } from "@synsec/dashboard"; +import { buildReport } from "@synsec/report"; +import { buildReportHistory } from "@synsec/report/history"; + +function input() { + const report = buildReport({ + target: { path: "/repo" }, + scans: [{ + scanner: "fixture", + startedAt: "2026-08-22T19:00:00.000Z", + completedAt: "2026-08-22T19:00:01.000Z", + target: { path: "/repo" }, + diagnostics: ["secret scanner diagnostic must stay out"], + findings: [{ + id: "A", + title: "Finding A", + category: "sast", + severity: "high", + confidence: 1, + scanner: { name: "fixture" }, + location: { path: "src/private.ts", startLine: 4 }, + evidence: "private source evidence", + }], + artifacts: [{ + type: "sbom", + format: "syft-json", + producer: "fixture", + generatedAt: "2026-08-22T19:00:01.000Z", + packageCount: 1, + packages: [{ name: "dependency", version: "1.0.0", purl: "pkg:npm/dependency@1.0.0" }], + }], + }], + }); + return { + report, + triage: { + schemaVersion: 1, + reportId: report.reportId, + items: [{ + fingerprint: report.findings[0].fingerprint, + title: "Finding A", + severity: "high", + state: "confirmed", + updatedAt: "2026-08-22T19:01:00.000Z", + owner: "appsec", + comments: [], + }], + summary: { current: 1, assigned: 1, unassigned: 0, commented: 0 }, + interpretation: "triage-metadata-not-scanner-evidence", + }, + posture: { + schemaVersion: 1, + indexedFileCount: 10, + routeCount: 2, + routeAuth: { + "authorization-signal-observed": 1, + "authentication-signal-observed": 0, + "no-auth-signal-observed": 1, + }, + routeSinkKinds: { process: 0, filesystem: 0, database: 1, network: 0 }, + routesWithSinkSignals: 1, + routesWithoutAuthSignals: 1, + interpretation: "bounded-lexical-posture-only", + }, + }; +} + +function historyFor(current) { + const previous = { + reportId: "previous-report", + generatedAt: "2026-08-21T19:00:00.000Z", + target: { commitSha: "a".repeat(40), branch: "main" }, + securityScore: 82, + findingCount: 2, + summary: { critical: 0, high: 1, medium: 1, low: 0, info: 0, unknown: 0 }, + findings: [ + { fingerprint: "prior-a", primary: { title: "Prior A", severity: "high" } }, + { fingerprint: "prior-b", primary: { title: "Prior B", severity: "medium" } }, + ], + }; + const latest = { + reportId: current.reportId, + generatedAt: "2026-08-22T19:00:00.000Z", + target: { commitSha: "b".repeat(40), branch: "main" }, + securityScore: current.securityScore, + findingCount: current.findingCount, + summary: current.summary, + findings: current.findings.map((finding) => ({ + fingerprint: finding.fingerprint, + primary: { title: finding.primary.title, severity: finding.primary.severity }, + })), + }; + return buildReportHistory([previous, latest]); +} + +test("project dashboard index links only fixed local sanitized views", () => { + const html = renderProjectDashboardIndex(input()); + assert.match(html, /href="triage\.html"/); + assert.match(html, /href="dependencies\.html"/); + assert.match(html, /href="posture\.html"/); + assert.equal(html.includes("history.html"), false); + assert.equal(html.includes("src/private.ts"), false); + assert.equal(html.includes("private source evidence"), false); + assert.equal(html.includes("secret scanner diagnostic"), false); + assert.equal(html.includes("http://"), false); + assert.equal(html.includes("https://"), false); + assert.match(html, /security score/); +}); + +test("project dashboard writer creates a restrictive four-page local bundle", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-project-dashboard-")); + const destination = join(root, "dashboard"); + try { + const paths = await writeProjectDashboard(destination, input()); + assert.deepEqual(Object.keys(paths).sort(), ["dependencies", "directory", "index", "posture", "triage"]); + for (const path of [paths.index, paths.triage, paths.dependencies, paths.posture]) { + const html = await readFile(path, "utf8"); + assert.match(html, /SynSec/); + assert.equal(html.includes("private source evidence"), false); + assert.equal(html.includes("secret scanner diagnostic"), false); + if (process.platform !== "win32") assert.equal((await stat(path)).mode & 0o777, 0o600); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("project dashboard optionally includes the existing trend-safe history view", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-project-dashboard-history-")); + const destination = join(root, "dashboard"); + try { + const dashboardInput = input(); + dashboardInput.history = historyFor(dashboardInput.report); + const indexHtml = renderProjectDashboardIndex(dashboardInput); + assert.match(indexHtml, /href="history\.html"/); + assert.match(indexHtml, /
2<\/div>
historical scans<\/div>/); + + const paths = await writeProjectDashboard(destination, dashboardInput); + assert.ok(paths.history); + const historyHtml = await readFile(paths.history, "utf8"); + assert.match(historyHtml, /SynSec project security history/); + assert.match(historyHtml, /Trend-safe repository security history/); + assert.equal(historyHtml.includes("src/private.ts"), false); + assert.equal(historyHtml.includes("private source evidence"), false); + assert.equal(historyHtml.includes("secret scanner diagnostic"), false); + if (process.platform !== "win32") assert.equal((await stat(paths.history)).mode & 0o777, 0o600); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/dependency-usage-resolution.test.mjs b/tests/dependency-usage-resolution.test.mjs new file mode 100644 index 00000000..d37a2547 --- /dev/null +++ b/tests/dependency-usage-resolution.test.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { buildRepositoryIndex } from "@synsec/repository/analysis"; +import { findExternalDependencyUsage } from "@synsec/repository/dependency-usage"; +import { buildModuleGraph } from "@synsec/repository/module-graph"; + +test("dependency usage excludes Python imports proven to be repository-local", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-dependency-usage-local-")); + try { + await mkdir(join(root, "service"), { recursive: true }); + const init = ""; + const local = "def load():\n return {}\n"; + const app = "from service.db import load\nimport requests\n\ndef main():\n return load()\n"; + await writeFile(join(root, "service", "__init__.py"), init); + await writeFile(join(root, "service", "db.py"), local); + await writeFile(join(root, "service", "app.py"), app); + + const files = [ + { path: "service/__init__.py", size: Buffer.byteLength(init) }, + { path: "service/db.py", size: Buffer.byteLength(local) }, + { path: "service/app.py", size: Buffer.byteLength(app) }, + ]; + const index = await buildRepositoryIndex(root, files); + const graph = buildModuleGraph(index, files); + + assert.deepEqual(findExternalDependencyUsage(index, graph, "service"), { + packageName: "service", + status: "unknown", + evidence: [], + excludedRepositoryLocalImportCount: 1, + interpretation: "observed-import-evidence-not-runtime-reachability", + }); + const requests = findExternalDependencyUsage(index, graph, "requests"); + assert.equal(requests.status, "observed-import"); + assert.equal(requests.evidence.length, 1); + assert.equal(requests.evidence[0].specifier, "requests"); + assert.equal(requests.excludedRepositoryLocalImportCount, 0); + assert.equal(requests.interpretation, "observed-import-evidence-not-runtime-reachability"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("dependency usage keeps ambiguous imports as conservative external evidence", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-dependency-usage-ambiguous-")); + try { + await mkdir(join(root, "service", "db"), { recursive: true }); + const init = ""; + const db = "def load():\n return {}\n"; + const app = "from service.db import load\n"; + await writeFile(join(root, "service", "__init__.py"), init); + await writeFile(join(root, "service", "db.py"), db); + await writeFile(join(root, "service", "db", "__init__.py"), db); + await writeFile(join(root, "service", "app.py"), app); + + const files = [ + { path: "service/__init__.py", size: Buffer.byteLength(init) }, + { path: "service/db.py", size: Buffer.byteLength(db) }, + { path: "service/db/__init__.py", size: Buffer.byteLength(db) }, + { path: "service/app.py", size: Buffer.byteLength(app) }, + ]; + const index = await buildRepositoryIndex(root, files); + const graph = buildModuleGraph(index, files); + const usage = findExternalDependencyUsage(index, graph, "service"); + + assert.equal(graph.resolvedEdgeCount, 0); + assert.equal(usage.status, "observed-import"); + assert.equal(usage.evidence[0].specifier, "service.db"); + assert.equal(usage.excludedRepositoryLocalImportCount, 0); + assert.equal(usage.interpretation, "observed-import-evidence-not-runtime-reachability"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("dependency usage bounds evidence and tolerates invalid evidence limits", () => { + const index = { + schemaVersion: 1, + generatedAt: new Date(0).toISOString(), + indexedFileCount: 1, + moduleEdges: Array.from({ length: 150 }, (_, line) => ({ + from: "src/app.ts", + specifier: "lodash/fp", + kind: "import", + line: line + 1, + })), + routes: [], + authSignals: [], + sinks: [], + }; + const graph = { + schemaVersion: 1, + nodes: ["src/app.ts"], + edges: index.moduleEdges.map((edge) => ({ ...edge, resolution: "external-or-unresolved" })), + resolvedEdgeCount: 0, + unresolvedEdgeCount: 150, + }; + + assert.equal(findExternalDependencyUsage(index, graph, "lodash", 1000).evidence.length, 100); + assert.equal(findExternalDependencyUsage(index, graph, "lodash", Number.NaN).evidence.length, 10); +}); diff --git a/tests/django-route-handlers.test.mjs b/tests/django-route-handlers.test.mjs new file mode 100644 index 00000000..fc65b1c7 --- /dev/null +++ b/tests/django-route-handlers.test.mjs @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { buildRepositoryIndex } from "@synsec/repository/analysis"; +import { buildModuleGraph } from "@synsec/repository/module-graph"; +import { buildRepositoryRouteFlowAnalysis } from "@synsec/repository/route-flow-analysis"; + +async function makeRepository(filesByPath) { + const root = await mkdtemp(join(tmpdir(), "synsec-django-route-handler-")); + const files = []; + for (const [path, content] of Object.entries(filesByPath)) { + const absolute = join(root, path); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, content, "utf8"); + files.push({ path, size: Buffer.byteLength(content) }); + } + return { root, files, cleanup: () => rm(root, { recursive: true, force: true }) }; +} + +async function analyze(repo) { + const index = await buildRepositoryIndex(repo.root, repo.files); + const moduleGraph = buildModuleGraph(index, repo.files); + return await buildRepositoryRouteFlowAnalysis(repo.root, repo.files, index, moduleGraph); +} + +test("Django URLConf resolves an explicit repository-local named function view", async () => { + const repo = await makeRepository({ + "web/__init__.py": "", + "web/urls.py": [ + "from .views import create_user as create_user_view", + 'path("users/", create_user_view, name="create-user")', + ].join("\n"), + "web/views.py": [ + "def create_user(request):", + ' return persist(request.POST["name"])', + "", + "def persist(value):", + " cursor.execute(value)", + ].join("\n"), + }); + + try { + const analysis = await analyze(repo); + assert.equal(analysis.entrypoints.length, 1); + assert.equal(analysis.entrypoints[0]?.resolution, "imported-named-function"); + assert.equal(analysis.entrypoints[0]?.handler?.path, "web/views.py"); + assert.equal(analysis.entrypoints[0]?.handler?.name, "create_user"); + assert.deepEqual(analysis.routeFlows[0]?.evidence.map(({ path, line, kind, depth }) => ({ path, line, kind, depth })), [ + { path: "web/views.py", line: 5, kind: "database", depth: 1 }, + ]); + assert.equal(analysis.requestInputFlows[0]?.sourceKinds.includes("body"), true); + assert.equal(analysis.requestInputFlows[0]?.sinkKinds.includes("database"), true); + assert.equal(analysis.requestInputFlows[0]?.interpretation, "structural-request-source-call-sink-evidence-only"); + } finally { + await repo.cleanup(); + } +}); + +test("Django URLConf resolves one unique same-file function view", async () => { + const repo = await makeRepository({ + "urls.py": [ + 'path("health/", health)', + "", + "def health(request):", + " cursor.execute(query_text)", + ].join("\n"), + }); + + try { + const analysis = await analyze(repo); + assert.equal(analysis.entrypoints[0]?.resolution, "named-function"); + assert.equal(analysis.entrypoints[0]?.handler?.name, "health"); + assert.equal(analysis.routeFlows[0]?.evidence[0]?.kind, "database"); + } finally { + await repo.cleanup(); + } +}); + +test("Django dotted and class-based view expressions remain unresolved", async () => { + const repo = await makeRepository({ + "web/__init__.py": "", + "web/urls.py": [ + "from . import views", + 'path("users/", views.create_user)', + 'path("admin/", AdminView.as_view())', + ].join("\n"), + "web/views.py": "def create_user(request):\n cursor.execute(query_text)\n", + }); + + try { + const analysis = await analyze(repo); + assert.equal(analysis.entrypoints.length, 2); + assert.deepEqual(analysis.entrypoints.map(({ resolution }) => resolution), ["unresolved", "unresolved"]); + assert.deepEqual(analysis.routeFlows, []); + } finally { + await repo.cleanup(); + } +}); + +test("shadowed Django named imports remain unresolved", async () => { + const repo = await makeRepository({ + "web/__init__.py": "", + "web/urls.py": [ + "from .views import create_user", + "create_user = wrap(create_user)", + 'path("users/", create_user)', + ].join("\n"), + "web/views.py": "def create_user(request):\n cursor.execute(query_text)\n", + }); + + try { + const analysis = await analyze(repo); + assert.equal(analysis.entrypoints[0]?.resolution, "unresolved"); + assert.deepEqual(analysis.routeFlows, []); + } finally { + await repo.cleanup(); + } +}); + +test("ambiguous same-name Django view targets fail closed", async () => { + const repo = await makeRepository({ + "web/__init__.py": "", + "web/urls.py": [ + "from .views import create_user", + 'path("users/", create_user)', + "", + "def create_user(request):", + " cursor.execute(local_query)", + ].join("\n"), + "web/views.py": "def create_user(request):\n cursor.execute(imported_query)\n", + }); + + try { + const analysis = await analyze(repo); + assert.equal(analysis.entrypoints[0]?.resolution, "unresolved"); + assert.deepEqual(analysis.routeFlows, []); + } finally { + await repo.cleanup(); + } +}); diff --git a/tests/django-urlconf-composition.test.mjs b/tests/django-urlconf-composition.test.mjs new file mode 100644 index 00000000..27dd1a4f --- /dev/null +++ b/tests/django-urlconf-composition.test.mjs @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { buildRepositoryIndex } from "@synsec/repository/analysis"; +import { buildModuleGraph } from "@synsec/repository/module-graph"; +import { buildRepositoryRouteFlowAnalysis } from "@synsec/repository/route-flow-analysis"; + +async function makeRepository(filesByPath) { + const root = await mkdtemp(join(tmpdir(), "synsec-django-urlconf-composition-")); + const files = []; + for (const [path, content] of Object.entries(filesByPath)) { + const absolute = join(root, path); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, content, "utf8"); + files.push({ path, size: Buffer.byteLength(content) }); + } + return { root, files, cleanup: () => rm(root, { recursive: true, force: true }) }; +} + +async function analyze(repo, options = {}) { + const index = await buildRepositoryIndex(repo.root, repo.files); + const moduleGraph = buildModuleGraph(index, repo.files); + return await buildRepositoryRouteFlowAnalysis(repo.root, repo.files, index, moduleGraph, options); +} + +function composedFlows(analysis) { + return analysis.routeFlows.filter((flow) => flow.route.frameworkHint === "Django URLConf include"); +} + +test("Django literal include prefixes compose into exact structural route-to-sink evidence", async () => { + const repo = await makeRepository({ + "project/urls.py": 'path("api/", include("accounts.urls"))\n', + "accounts/__init__.py": "", + "accounts/urls.py": [ + "from .views import create_user", + 'path("users/", create_user)', + ].join("\n"), + "accounts/views.py": [ + "def create_user(request):", + ' return persist(request.POST["name"])', + "", + "def persist(value):", + " cursor.execute(value)", + ].join("\n"), + }); + + try { + const analysis = await analyze(repo); + const flows = composedFlows(analysis); + assert.equal(flows.length, 1); + assert.equal(flows[0]?.route.route, "api/users/"); + assert.equal(flows[0]?.handler.path, "accounts/views.py"); + assert.equal(flows[0]?.evidence[0]?.kind, "database"); + + const requestFlows = analysis.requestInputFlows.filter( + (flow) => flow.route.frameworkHint === "Django URLConf include" && flow.route.route === "api/users/", + ); + assert.equal(requestFlows.length, 1); + assert.equal(requestFlows[0]?.sourceKinds.includes("body"), true); + assert.equal(requestFlows[0]?.sinkKinds.includes("database"), true); + } finally { + await repo.cleanup(); + } +}); + +test("Django include composition follows nested literal URLConfs with an explicit depth bound", async () => { + const repo = await makeRepository({ + "project/urls.py": 'path("api/", include("service.urls"))\n', + "service/__init__.py": "", + "service/urls.py": 'path("v1/", include("service.users.urls"))\n', + "service/users/__init__.py": "", + "service/users/urls.py": [ + "from .views import detail", + 'path("users/", detail)', + ].join("\n"), + "service/users/views.py": "def detail(request):\n cursor.execute(query_text)\n", + }); + + try { + const full = await analyze(repo); + assert.equal(composedFlows(full).some((flow) => flow.route.route === "api/v1/users/"), true); + + const shallow = await analyze(repo, { maxDjangoIncludeDepth: 1 }); + assert.equal(composedFlows(shallow).some((flow) => flow.route.route === "api/v1/users/"), false); + } finally { + await repo.cleanup(); + } +}); + +test("empty Django include prefixes preserve the child route identity", async () => { + const repo = await makeRepository({ + "project/urls.py": 'path("", include("accounts.urls"))\n', + "accounts/__init__.py": "", + "accounts/urls.py": [ + "from .views import health", + 'path("health/", health)', + ].join("\n"), + "accounts/views.py": "def health(request):\n cursor.execute(query_text)\n", + }); + + try { + const analysis = await analyze(repo); + assert.equal(composedFlows(analysis).some((flow) => flow.route.route === "health/"), true); + } finally { + await repo.cleanup(); + } +}); + +test("ambiguous and dynamic Django include targets fail closed", async () => { + const repo = await makeRepository({ + "project/urls.py": [ + 'path("ambiguous/", include("accounts.urls"))', + 'path("dynamic/", include(settings.ACCOUNT_URLCONF))', + ].join("\n"), + "accounts/__init__.py": "", + "accounts/urls.py": [ + "from .views import health", + 'path("health/", health)', + ].join("\n"), + "accounts/urls/__init__.py": [ + "from ..views import health", + 'path("other/", health)', + ].join("\n"), + "accounts/views.py": "def health(request):\n cursor.execute(query_text)\n", + }); + + try { + const analysis = await analyze(repo); + assert.deepEqual(composedFlows(analysis), []); + } finally { + await repo.cleanup(); + } +}); + +test("Django include cycles without a structural root produce no composed route evidence", async () => { + const repo = await makeRepository({ + "a/urls.py": 'path("b/", include("b.urls"))\n', + "b/urls.py": [ + 'path("a/", include("a.urls"))', + "def local(request):", + " cursor.execute(query_text)", + 'path("local/", local)', + ].join("\n"), + }); + + try { + const analysis = await analyze(repo); + assert.deepEqual(composedFlows(analysis), []); + } finally { + await repo.cleanup(); + } +}); diff --git a/tests/engine-route-protection-enrichment.test.mjs b/tests/engine-route-protection-enrichment.test.mjs new file mode 100644 index 00000000..b328c19d --- /dev/null +++ b/tests/engine-route-protection-enrichment.test.mjs @@ -0,0 +1,162 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + enrichRepositorySecurityContext, + stripScannerReservedMetadata, +} from "../packages/engine/dist/index.js"; +import { buildReport } from "../packages/report/dist/index.js"; + +const route = { + path: "server.ts", + line: 5, + method: "POST", + route: "/admin/run", + frameworkHint: "Node HTTP router", + handler: "runAdminJob", +}; + +const index = { + schemaVersion: 1, + generatedAt: "2026-08-23T19:00:00.000Z", + indexedFileCount: 2, + moduleEdges: [], + routes: [route], + authSignals: [], + sinks: [{ path: "service.ts", line: 44, kind: "process", evidence: "exec(command)" }], +}; + +const routeFlows = [{ + route, + resolution: "named-function", + handler: { + id: "server.ts:runAdminJob:20", + name: "runAdminJob", + path: "server.ts", + line: 20, + endLine: 25, + }, + evidence: [{ + path: "service.ts", + line: 44, + kind: "process", + functionId: "service.ts:execJob:40", + functionName: "execJob", + depth: 2, + }], + kinds: ["process"], + maxDepth: 3, + callScope: "same-file-and-explicit-imports", + interpretation: "structural-route-call-sink-evidence-only", +}]; + +const routeProtections = [{ + route, + resolution: "named-function", + handler: { + id: "server.ts:runAdminJob:20", + name: "runAdminJob", + path: "server.ts", + line: 20, + endLine: 25, + }, + status: "authorization-signal-observed", + evidence: [{ + path: "auth.ts", + line: 12, + kind: "authorization", + source: "reachable-function", + functionName: "checkRole", + depth: 1, + }], + callScope: "same-file-and-explicit-imports", + interpretation: "structural-auth-signals-not-protection-proof", +}]; + +function scan(category = "sast", line = 44) { + return { + scanner: "fixture", + startedAt: "2026-08-23T19:00:00.000Z", + completedAt: "2026-08-23T19:00:01.000Z", + target: { path: "/repo" }, + diagnostics: [], + findings: [{ + id: "fixture", + title: "Process execution finding", + category, + severity: "high", + confidence: 0.9, + scanner: { name: "fixture" }, + location: { path: "service.ts", startLine: line }, + }], + }; +} + +test("engine enrichment attaches minimized route protection only to exact route-sink findings", () => { + const [enriched] = enrichRepositorySecurityContext([scan()], index, routeFlows, routeProtections); + const metadata = enriched.findings[0].metadata; + + assert.deepEqual(metadata.routeProtection, [{ + method: "POST", + route: "/admin/run", + frameworkHint: "Node HTTP router", + resolution: "named-function", + handler: "runAdminJob", + status: "authorization-signal-observed", + evidenceKinds: ["authorization"], + callScope: "same-file-and-explicit-imports", + interpretation: "structural-auth-signals-not-protection-proof", + }]); + const serialized = JSON.stringify(metadata.routeProtection); + assert.equal(serialized.includes("auth.ts"), false); + assert.equal(serialized.includes("checkRole"), false); + assert.equal(serialized.includes("exec(command)"), false); +}); + +test("minimized route protection metadata survives normalized report construction", () => { + const enriched = enrichRepositorySecurityContext([scan()], index, routeFlows, routeProtections); + const report = buildReport({ target: { path: "/repo" }, scans: enriched }); + const serialized = JSON.stringify(report.findings[0].primary.metadata.routeProtection); + + assert.match(serialized, /authorization-signal-observed/); + assert.match(serialized, /structural-auth-signals-not-protection-proof/); + assert.equal(serialized.includes("auth.ts"), false); + assert.equal(serialized.includes("checkRole"), false); + assert.equal(serialized.includes("exec(command)"), false); +}); + +test("scanner metadata cannot spoof engine-owned repository context", () => { + const original = scan("sast", 45); + original.findings[0].metadata = { + scannerOwned: "preserved", + dependencyUsage: { interpretation: "forged" }, + repositoryContext: { forged: true }, + routeFlow: [{ interpretation: "forged" }], + routeProtection: [{ status: "authorization-signal-observed", interpretation: "forged" }], + }; + + const bounded = stripScannerReservedMetadata([original]); + assert.deepEqual(bounded[0].findings[0].metadata, { scannerOwned: "preserved" }); + + const [enriched] = enrichRepositorySecurityContext(bounded, index, routeFlows, routeProtections); + assert.equal(enriched.findings[0].metadata.scannerOwned, "preserved"); + assert.equal(enriched.findings[0].metadata.routeFlow, undefined); + assert.equal(enriched.findings[0].metadata.routeProtection, undefined); + assert.equal(enriched.findings[0].metadata.dependencyUsage, undefined); + assert.equal(enriched.findings[0].metadata.repositoryContext.interpretation, "proximity-signals-only"); +}); + +test("engine enrichment does not attach route protection to a different finding line", () => { + const [enriched] = enrichRepositorySecurityContext([scan("sast", 45)], index, routeFlows, routeProtections); + assert.equal(enriched.findings[0].metadata?.routeProtection, undefined); +}); + +test("secret findings retain scanner metadata but cannot inject reserved engine context", () => { + const original = scan("secret"); + original.findings[0].metadata = { + scannerOwned: "preserved", + routeProtection: [{ interpretation: "forged" }], + }; + const bounded = stripScannerReservedMetadata([original]); + const [enriched] = enrichRepositorySecurityContext(bounded, index, routeFlows, routeProtections); + assert.deepEqual(enriched.findings[0].metadata, { scannerOwned: "preserved" }); +}); diff --git a/tests/engine.test.mjs b/tests/engine.test.mjs new file mode 100644 index 00000000..ca2268a3 --- /dev/null +++ b/tests/engine.test.mjs @@ -0,0 +1,189 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { defaultConfig } from "../packages/config/dist/index.js"; +import { + discoverChangedFiles, + reportMeetsFailureThreshold, + runScanEngine, + sanitizeScanDiagnostics, + scannerFailureMessage, + scannerStatuses, +} from "../packages/engine/dist/index.js"; +import { buildReport } from "../packages/report/dist/index.js"; + +const exec = promisify(execFile); + +async function git(root, ...args) { + return await exec("git", ["-C", root, ...args]); +} + +test("scan engine refuses to produce a clean report when no selected scanner exists", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-engine-test-")); + try { + await writeFile(join(root, "README.md"), "fixture\n"); + const config = structuredClone(defaultConfig); + config.scanners = ["scanner-that-does-not-exist"]; + await assert.rejects( + runScanEngine({ rootPath: root, config }), + /No selected scanner engines are available/, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("unknown scanner identities are sanitized before status or aggregate error reporting", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-engine-status-test-")); + const githubToken = "ghp_abcdefghijklmnopqrstuvwxyz1234567890"; + const configuredId = `${githubToken}\u001b[31m`; + try { + await writeFile(join(root, "README.md"), "fixture\n"); + const config = structuredClone(defaultConfig); + config.scanners = [configuredId]; + + const statuses = await scannerStatuses(config); + const unknown = statuses.find((status) => status.selected); + assert.ok(unknown); + assert.doesNotMatch(unknown.id, new RegExp(githubToken)); + assert.doesNotMatch(unknown.displayName, new RegExp(githubToken)); + assert.equal(unknown.id.includes("\u001b"), false); + assert.match(unknown.displayName, /\[REDACTED/); + + await assert.rejects( + runScanEngine({ rootPath: root, config }), + (error) => { + const message = error instanceof Error ? error.message : String(error); + assert.match(message, /No selected scanner engines are available/); + assert.doesNotMatch(message, new RegExp(githubToken)); + assert.equal(message.includes("\u001b"), false); + assert.match(message, /\[REDACTED/); + return true; + }, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("scanner failures are redacted before crossing the engine reporting boundary", () => { + const githubToken = "ghp_abcdefghijklmnopqrstuvwxyz1234567890"; + const message = scannerFailureMessage( + new Error(`scanner transport failed authorization: Bearer ${githubToken} via https://alice:password@example.invalid/api`), + ); + + assert.match(message, /scanner transport failed/); + assert.doesNotMatch(message, new RegExp(githubToken)); + assert.doesNotMatch(message, /alice:password/); + assert.match(message, /\[REDACTED/); + assert.equal(scannerFailureMessage(new Error("\u0000\u0001")), "Scanner failed without an operational diagnostic."); +}); + +test("successful scanner diagnostics are redacted and bounded without changing findings", () => { + const githubToken = "ghp_abcdefghijklmnopqrstuvwxyz1234567890"; + const finding = { + id: "fixture", + title: "Finding evidence remains scanner-owned", + category: "sast", + severity: "medium", + confidence: 0.9, + scanner: { name: "fixture" }, + }; + const scan = { + scanner: "fixture", + startedAt: "2026-01-01T00:00:00.000Z", + completedAt: "2026-01-01T00:00:01.000Z", + target: { path: "/repo" }, + findings: [finding], + diagnostics: [ + `authorization: Bearer ${githubToken}`, + "https://alice:password@example.invalid/scanner", + ...Array.from({ length: 1_005 }, (_, index) => `diagnostic ${index}`), + ], + }; + + const sanitized = sanitizeScanDiagnostics(scan); + assert.equal(sanitized.findings[0], finding); + assert.equal(sanitized.diagnostics.length, 1_001); + assert.match(sanitized.diagnostics[0], /\[REDACTED\]/); + assert.doesNotMatch(sanitized.diagnostics.join("\n"), new RegExp(githubToken)); + assert.doesNotMatch(sanitized.diagnostics.join("\n"), /alice:password/); + assert.match(sanitized.diagnostics.at(-1), /omitted after 1000 entries/); +}); + +test("changed-file discovery returns repository-relative files from the requested base", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-changed-test-")); + try { + await git(root, "init"); + await git(root, "config", "user.name", "SynSec Test"); + await git(root, "config", "user.email", "synsec-test@example.invalid"); + + await writeFile(join(root, "a.txt"), "first\n"); + await git(root, "add", "a.txt"); + await git(root, "commit", "-m", "first"); + + await writeFile(join(root, "b.txt"), "second\n"); + await git(root, "add", "b.txt"); + await git(root, "commit", "-m", "second"); + + const scope = await discoverChangedFiles(root, "HEAD~1"); + assert.equal(scope.base, "HEAD~1"); + assert.deepEqual(scope.files, ["b.txt"]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("changed-file discovery rejects unsafe base revisions without reflecting attacker-controlled text", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-changed-base-test-")); + const githubToken = "ghp_abcdefghijklmnopqrstuvwxyz1234567890"; + try { + await git(root, "init"); + await git(root, "config", "user.name", "SynSec Test"); + await git(root, "config", "user.email", "synsec-test@example.invalid"); + await writeFile(join(root, "a.txt"), "fixture\n"); + await git(root, "add", "a.txt"); + await git(root, "commit", "-m", "fixture"); + + for (const base of [`--output=${githubToken}`, `HEAD~1\n${githubToken}`]) { + await assert.rejects( + discoverChangedFiles(root, base), + (error) => { + const message = error instanceof Error ? error.message : String(error); + assert.match(message, /base revision is invalid/); + assert.doesNotMatch(message, new RegExp(githubToken)); + return true; + }, + ); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("failure threshold treats configured severity as inclusive", () => { + const scan = { + scanner: "fixture", + startedAt: "2026-01-01T00:00:00.000Z", + completedAt: "2026-01-01T00:00:01.000Z", + target: { path: "/repo" }, + diagnostics: [], + findings: [{ + id: "fixture", + title: "Medium issue", + category: "sast", + severity: "medium", + confidence: 0.9, + scanner: { name: "fixture" }, + }], + }; + const report = buildReport({ target: { path: "/repo" }, scans: [scan] }); + assert.equal(reportMeetsFailureThreshold(report, "high"), false); + assert.equal(reportMeetsFailureThreshold(report, "medium"), true); + assert.equal(reportMeetsFailureThreshold(report, "low"), true); + assert.equal(reportMeetsFailureThreshold(report, "none"), false); +}); diff --git a/tests/express-router-composition.test.mjs b/tests/express-router-composition.test.mjs new file mode 100644 index 00000000..578fc652 --- /dev/null +++ b/tests/express-router-composition.test.mjs @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { buildRepositoryIndex } from "@synsec/repository/analysis"; +import { buildCallGraph } from "@synsec/repository/call-graph"; +import { composeExpressRouterEntrypoints } from "@synsec/repository/express-router-composition"; +import { resolveImportedNodeRouteEntrypoints } from "@synsec/repository/import-route-handlers"; +import { buildModuleGraph } from "@synsec/repository/module-graph"; +import { resolveRouteEntrypoints } from "@synsec/repository/route-entrypoints"; + +async function makeRepository(filesByPath) { + const root = await mkdtemp(join(tmpdir(), "synsec-express-router-composition-")); + const files = []; + for (const [path, content] of Object.entries(filesByPath)) { + const absolute = join(root, path); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, content, "utf8"); + files.push({ path, size: Buffer.byteLength(content) }); + } + return { root, files, cleanup: () => rm(root, { recursive: true, force: true }) }; +} + +async function compose(repo, options) { + const index = await buildRepositoryIndex(repo.root, repo.files); + const moduleGraph = buildModuleGraph(index, repo.files); + const callGraph = await buildCallGraph(repo.root, repo.files); + let entrypoints = resolveRouteEntrypoints(index, callGraph); + entrypoints = await resolveImportedNodeRouteEntrypoints(repo.root, repo.files, moduleGraph, callGraph, entrypoints); + return composeExpressRouterEntrypoints(repo.root, repo.files, moduleGraph, entrypoints, options); +} + +function composed(entrypoints) { + return entrypoints.filter((entrypoint) => entrypoint.route.frameworkHint === "Express composed router"); +} + +test("Express default-imported routers compose literal app mount prefixes", async () => { + const repo = await makeRepository({ + "app.js": [ + 'import express from "express";', + 'import usersRouter from "./users.js";', + "const app = express();", + 'app.use("/api", usersRouter);', + ].join("\n"), + "users.js": [ + 'import express from "express";', + "const router = express.Router();", + 'router.get("/users/:id", getUser);', + "function getUser(req, res) {", + " database.query(sql);", + "}", + "export default router;", + ].join("\n"), + }); + try { + const routes = composed(await compose(repo)); + assert.equal(routes.length, 1); + assert.equal(routes[0]?.route.route, "/api/users/:id"); + assert.equal(routes[0]?.handler?.name, "getUser"); + assert.equal(routes[0]?.handler?.path, "users.js"); + assert.equal(routes[0]?.calls?.root, routes[0]?.handler?.id); + assert.equal(routes[0]?.compositionInterpretation, "structural-express-router-composition-not-runtime-reachability"); + } finally { await repo.cleanup(); } +}); + +test("Express nested router mounts compose only within the configured bound", async () => { + const repo = await makeRepository({ + "app.js": ['import express from "express";', 'import apiRouter from "./api.js";', "const app = express();", 'app.use("/api", apiRouter);'].join("\n"), + "api.js": ['import express from "express";', 'import usersRouter from "./users.js";', "const router = express.Router();", 'router.use("/v1", usersRouter);', "export default router;"].join("\n"), + "users.js": ['import express from "express";', "const router = express.Router();", 'router.post("/users", createUser);', "function createUser() { return true; }", "export default router;"].join("\n"), + }); + try { + const all = composed(await compose(repo)); + assert.equal(all.length, 1); + assert.equal(all[0]?.route.route, "/api/v1/users"); + assert.equal(all[0]?.composition?.mountDepth, 2); + assert.deepEqual(composed(await compose(repo, { maxMountDepth: 1 })), []); + } finally { await repo.cleanup(); } +}); + +test("Express dynamic prefixes and router factories fail closed", async () => { + const repo = await makeRepository({ + "app.js": ['import express from "express";', 'import usersRouter from "./users.js";', "const app = express();", 'const prefix = "/api";', "app.use(prefix, usersRouter);"].join("\n"), + "users.js": ['import express from "express";', "const router = makeRouter();", 'router.get("/users", listUsers);', "function listUsers() { return true; }", "export default router;"].join("\n"), + }); + try { assert.deepEqual(composed(await compose(repo)), []); } + finally { await repo.cleanup(); } +}); + +test("Express shadowed imported router bindings fail closed", async () => { + const repo = await makeRepository({ + "app.js": ['import express from "express";', 'import usersRouter from "./users.js";', "const app = express();", "usersRouter = makeRouter();", 'app.use("/api", usersRouter);'].join("\n"), + "users.js": ['import express from "express";', "const router = express.Router();", 'router.get("/users", listUsers);', "function listUsers() { return true; }", "export default router;"].join("\n"), + }); + try { assert.deepEqual(composed(await compose(repo)), []); } + finally { await repo.cleanup(); } +}); + +test("Express use-before-declaration does not create composition evidence", async () => { + const repo = await makeRepository({ + "app.js": ['import express from "express";', "const app = express();", 'app.use("/api", router);', "const router = express.Router();", 'router.get("/late", late);', "function late() { return true; }"].join("\n"), + }); + try { assert.deepEqual(composed(await compose(repo)), []); } + finally { await repo.cleanup(); } +}); diff --git a/tests/express-router-flow-integration.test.mjs b/tests/express-router-flow-integration.test.mjs new file mode 100644 index 00000000..f4bdb082 --- /dev/null +++ b/tests/express-router-flow-integration.test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { buildRepositoryIndex } from "@synsec/repository/analysis"; +import { buildModuleGraph } from "@synsec/repository/module-graph"; +import { buildRepositoryRouteFlowAnalysis } from "@synsec/repository/route-flow-analysis"; + +async function makeRepository(filesByPath) { + const root = await mkdtemp(join(tmpdir(), "synsec-express-router-flow-")); + const files = []; + for (const [path, content] of Object.entries(filesByPath)) { + const absolute = join(root, path); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, content, "utf8"); + files.push({ path, size: Buffer.byteLength(content) }); + } + return { root, files, cleanup: () => rm(root, { recursive: true, force: true }) }; +} + +test("mounted Express routes participate in exact sink correlation", async () => { + const repo = await makeRepository({ + "app.js": ['import express from "express";', 'import adminRouter from "./admin.js";', "const app = express();", 'app.use("/api", adminRouter);'].join("\n"), + "admin.js": ['import express from "express";', "const router = express.Router();", 'router.post("/admin/run", runAdmin);', "function runAdmin(req, res) {", " child_process.exec(command);", "}", "export default router;"].join("\n"), + }); + try { + const index = await buildRepositoryIndex(repo.root, repo.files); + const analysis = await buildRepositoryRouteFlowAnalysis(repo.root, repo.files, index, buildModuleGraph(index, repo.files)); + const route = analysis.entrypoints.find((entrypoint) => entrypoint.route.frameworkHint === "Express composed router"); + assert.equal(route?.route.route, "/api/admin/run"); + assert.equal( + analysis.routeFlows.some((flow) => flow.route.route === "/api/admin/run" && flow.evidence.some((item) => item.path === "admin.js" && item.line === 5 && item.kind === "process")), + true, + ); + } finally { + await repo.cleanup(); + } +}); + +test("mounted Express routes preserve structural request-source to sink evidence", async () => { + const repo = await makeRepository({ + "app.js": [ + 'import express from "express";', + 'import adminRouter from "./admin.js";', + "const app = express();", + 'app.use("/api", adminRouter);', + ].join("\n"), + "admin.js": [ + 'import express from "express";', + "const router = express.Router();", + 'router.post("/admin/run", runAdmin);', + "function runAdmin(req, res) {", + " child_process.exec(req.body.command);", + "}", + "export default router;", + ].join("\n"), + }); + try { + const index = await buildRepositoryIndex(repo.root, repo.files); + const analysis = await buildRepositoryRouteFlowAnalysis(repo.root, repo.files, index, buildModuleGraph(index, repo.files)); + const flow = analysis.requestInputFlows.find((item) => item.route.route === "/api/admin/run"); + assert.equal(flow?.route.frameworkHint, "Express composed router"); + assert.equal(flow?.interpretation, "structural-request-source-call-sink-evidence-only"); + assert.deepEqual(flow?.evidence.map((item) => ({ + sourcePath: item.source.path, + sourceLine: item.source.line, + sourceKind: item.source.kind, + sinkPath: item.sink.path, + sinkLine: item.sink.line, + sinkKind: item.sink.kind, + })), [{ + sourcePath: "admin.js", + sourceLine: 5, + sourceKind: "body", + sinkPath: "admin.js", + sinkLine: 5, + sinkKind: "process", + }]); + assert.equal(JSON.stringify(flow).includes("req.body.command"), false); + } finally { + await repo.cleanup(); + } +}); diff --git a/tests/fastapi-handler-dependencies.test.mjs b/tests/fastapi-handler-dependencies.test.mjs new file mode 100644 index 00000000..edd1b6fd --- /dev/null +++ b/tests/fastapi-handler-dependencies.test.mjs @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { buildRepositoryIndex } from "@synsec/repository/analysis"; +import { buildModuleGraph } from "@synsec/repository/module-graph"; +import { buildRepositoryRouteFlowAnalysis } from "@synsec/repository/route-flow-analysis"; + +async function makeRepository(filesByPath) { + const root = await mkdtemp(join(tmpdir(), "synsec-fastapi-handler-dependencies-")); + const files = []; + for (const [path, content] of Object.entries(filesByPath)) { + const absolute = join(root, path); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, content, "utf8"); + files.push({ path, size: Buffer.byteLength(content) }); + } + return { root, files, cleanup: () => rm(root, { recursive: true, force: true }) }; +} + +async function analyze(repo) { + const index = await buildRepositoryIndex(repo.root, repo.files); + return buildRepositoryRouteFlowAnalysis(repo.root, repo.files, index, buildModuleGraph(index, repo.files)); +} + +test("FastAPI handler parameter Depends resolves same-file dependency with bounded auth evidence", async () => { + const repo = await makeRepository({ + "app.py": [ + "from fastapi import FastAPI, Depends", + "app = FastAPI()", + "def require_user():", + " authentication(session)", + '@app.get("/account")', + "def account(user = Depends(require_user)):", + " return {'ok': True}", + ].join("\n"), + }); + try { + const analysis = await analyze(repo); + assert.equal(analysis.fastApiDependencyContexts.length, 1); + const context = analysis.fastApiDependencyContexts[0]; + assert.equal(context?.route.route, "/account"); + assert.equal(context?.handler, "account"); + assert.equal(context?.dependencies.length, 1); + assert.equal(context?.dependencies[0]?.source, "handler-parameter"); + assert.equal(context?.dependencies[0]?.parameter, "user"); + assert.equal(context?.dependencies[0]?.name, "require_user"); + assert.equal(context?.dependencies[0]?.resolution, "same-file-function"); + assert.equal(context?.authEvidence.some((item) => item.kind === "authentication" && item.line === 4), true); + assert.equal(context?.interpretation, "structural-fastapi-dependency-evidence-not-runtime-protection"); + } finally { + await repo.cleanup(); + } +}); + +test("FastAPI handler parameter Security resolves repository-local aliased import", async () => { + const repo = await makeRepository({ + "web/__init__.py": "", + "web/app.py": [ + "from fastapi import FastAPI, Security", + "from .auth import require_admin as admin_guard", + "app = FastAPI()", + '@app.get("/admin")', + "def admin(user: object = Security(admin_guard)):", + " return True", + ].join("\n"), + "web/auth.py": [ + "def require_admin():", + " authorize(current_user)", + ].join("\n"), + }); + try { + const analysis = await analyze(repo); + const dependency = analysis.fastApiDependencyContexts[0]?.dependencies[0]; + assert.equal(dependency?.source, "handler-parameter"); + assert.equal(dependency?.wrapper, "Security"); + assert.equal(dependency?.resolution, "imported-named-function"); + assert.equal(dependency?.node?.path, "web/auth.py"); + assert.equal(analysis.fastApiDependencyContexts[0]?.authEvidence.some((item) => item.kind === "authorization"), true); + } finally { + await repo.cleanup(); + } +}); + +test("FastAPI route-list and handler-parameter dependencies remain independently identified", async () => { + const repo = await makeRepository({ + "app.py": [ + "from fastapi import FastAPI, Depends, Security", + "app = FastAPI()", + "def rate_limit():", + " authentication(token)", + "def require_admin():", + " authorize(current_user)", + '@app.get("/admin", dependencies=[Depends(rate_limit)])', + "def admin(user = Security(require_admin)):", + " return True", + ].join("\n"), + }); + try { + const analysis = await analyze(repo); + const dependencies = analysis.fastApiDependencyContexts[0]?.dependencies ?? []; + assert.deepEqual(dependencies.map(({ name, source }) => ({ name, source })), [ + { name: "rate_limit", source: "route-list" }, + { name: "require_admin", source: "handler-parameter" }, + ]); + } finally { + await repo.cleanup(); + } +}); + +test("FastAPI handler dependency factories fail closed", async () => { + const repo = await makeRepository({ + "app.py": [ + "from fastapi import FastAPI, Depends", + "app = FastAPI()", + "def dependency_factory():", + " return lambda: True", + '@app.get("/dynamic")', + "def dynamic_route(user = Depends(dependency_factory())):", + " return True", + ].join("\n"), + }); + try { + const analysis = await analyze(repo); + assert.deepEqual(analysis.fastApiDependencyContexts, []); + } finally { + await repo.cleanup(); + } +}); + +test("FastAPI multiline handler dependency signatures are omitted rather than guessed", async () => { + const repo = await makeRepository({ + "app.py": [ + "from fastapi import FastAPI, Depends", + "app = FastAPI()", + "def require_user():", + " authentication(session)", + '@app.get("/account")', + "def account(", + " user = Depends(require_user),", + "):", + " return True", + ].join("\n"), + }); + try { + const analysis = await analyze(repo); + assert.deepEqual(analysis.fastApiDependencyContexts, []); + } finally { + await repo.cleanup(); + } +}); + +test("FastAPI handler dependency import shadowing before the signature remains unresolved", async () => { + const repo = await makeRepository({ + "web/__init__.py": "", + "web/app.py": [ + "from fastapi import FastAPI, Depends", + "from .auth import require_user", + "require_user = replacement", + "app = FastAPI()", + '@app.get("/account")', + "def account(user = Depends(require_user)):", + " return True", + ].join("\n"), + "web/auth.py": [ + "def require_user():", + " authentication(session)", + ].join("\n"), + }); + try { + const analysis = await analyze(repo); + const context = analysis.fastApiDependencyContexts[0]; + assert.equal(context?.dependencies[0]?.resolution, "unresolved"); + assert.deepEqual(context?.authEvidence, []); + } finally { + await repo.cleanup(); + } +}); diff --git a/tests/fastapi-route-dependencies.test.mjs b/tests/fastapi-route-dependencies.test.mjs new file mode 100644 index 00000000..b9d550e2 --- /dev/null +++ b/tests/fastapi-route-dependencies.test.mjs @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { buildRepositoryIndex } from "@synsec/repository/analysis"; +import { buildModuleGraph } from "@synsec/repository/module-graph"; +import { buildRepositoryRouteFlowAnalysis } from "@synsec/repository/route-flow-analysis"; + +async function makeRepository(filesByPath) { + const root = await mkdtemp(join(tmpdir(), "synsec-fastapi-dependencies-")); + const files = []; + for (const [path, content] of Object.entries(filesByPath)) { + const absolute = join(root, path); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, content, "utf8"); + files.push({ path, size: Buffer.byteLength(content) }); + } + return { root, files, cleanup: () => rm(root, { recursive: true, force: true }) }; +} + +async function analyze(repo) { + const index = await buildRepositoryIndex(repo.root, repo.files); + return buildRepositoryRouteFlowAnalysis(repo.root, repo.files, index, buildModuleGraph(index, repo.files)); +} + +test("FastAPI route dependencies resolve same-file functions and bounded auth evidence", async () => { + const repo = await makeRepository({ + "app.py": [ + "from fastapi import FastAPI, Depends", + "app = FastAPI()", + "", + "def require_admin():", + " authorize(current_user)", + "", + '@app.get("/admin", dependencies=[Depends(require_admin)])', + "def admin_panel():", + " cursor.execute(query_text)", + ].join("\n"), + }); + try { + const analysis = await analyze(repo); + assert.equal(analysis.fastApiDependencyContexts.length, 1); + const context = analysis.fastApiDependencyContexts[0]; + assert.equal(context?.route.frameworkHint, "FastAPI route decorator"); + assert.equal(context?.route.route, "/admin"); + assert.equal(context?.dependencies[0]?.name, "require_admin"); + assert.equal(context?.dependencies[0]?.wrapper, "Depends"); + assert.equal(context?.dependencies[0]?.resolution, "same-file-function"); + assert.deepEqual(context?.authEvidence.map(({ path, line, kind, dependency, depth }) => ({ + path, line, kind, dependency, depth, + })), [{ + path: "app.py", + line: 5, + kind: "authorization", + dependency: "require_admin", + depth: 0, + }]); + assert.equal(context?.status, "auth-signal-observed"); + assert.equal(context?.interpretation, "structural-fastapi-dependency-evidence-not-runtime-protection"); + } finally { + await repo.cleanup(); + } +}); + +test("FastAPI route dependencies resolve repository-local named imports and helper calls", async () => { + const repo = await makeRepository({ + "web/__init__.py": "", + "web/app.py": [ + "from fastapi import FastAPI, Security", + "from .auth import require_user as current_user_dependency", + "app = FastAPI()", + '@app.get("/account", dependencies=[Security(current_user_dependency)])', + "def account():", + " return {'ok': True}", + ].join("\n"), + "web/auth.py": [ + "def require_user():", + " verify_session()", + "", + "def verify_session():", + " authentication(session)", + ].join("\n"), + }); + try { + const analysis = await analyze(repo); + const context = analysis.fastApiDependencyContexts[0]; + assert.equal(context?.dependencies[0]?.resolution, "imported-named-function"); + assert.equal(context?.dependencies[0]?.node?.path, "web/auth.py"); + assert.equal(context?.callScope, "dependency-and-bounded-callees"); + assert.equal(context?.authEvidence.some((item) => ( + item.path === "web/auth.py" && item.line === 5 && item.depth === 1 && item.kind === "authentication" + )), true); + } finally { + await repo.cleanup(); + } +}); + +test("FastAPI dynamic dependency factories fail closed instead of producing structural context", async () => { + const repo = await makeRepository({ + "app.py": [ + "from fastapi import FastAPI, Depends", + "app = FastAPI()", + "def dependency_factory():", + " return lambda: True", + '@app.get("/dynamic", dependencies=[Depends(dependency_factory())])', + "def dynamic_route():", + " return True", + ].join("\n"), + }); + try { + const analysis = await analyze(repo); + assert.deepEqual(analysis.fastApiDependencyContexts, []); + } finally { + await repo.cleanup(); + } +}); + +test("FastAPI shadowed imported dependencies remain unresolved and do not manufacture auth evidence", async () => { + const repo = await makeRepository({ + "web/__init__.py": "", + "web/app.py": [ + "from fastapi import FastAPI, Depends", + "from .auth import require_user", + "require_user = replacement", + "app = FastAPI()", + '@app.get("/account", dependencies=[Depends(require_user)])', + "def account():", + " return True", + ].join("\n"), + "web/auth.py": [ + "def require_user():", + " authentication(session)", + ].join("\n"), + }); + try { + const analysis = await analyze(repo); + const context = analysis.fastApiDependencyContexts[0]; + assert.equal(context?.dependencies[0]?.resolution, "unresolved"); + assert.deepEqual(context?.authEvidence, []); + assert.equal(context?.status, "no-auth-signal-observed"); + } finally { + await repo.cleanup(); + } +}); + +test("FastAPI dependency wrappers must themselves be explicit unshadowed FastAPI imports", async () => { + const repo = await makeRepository({ + "app.py": [ + "from fastapi import FastAPI, Depends", + "Depends = custom_wrapper", + "app = FastAPI()", + "def require_user():", + " authentication(session)", + '@app.get("/account", dependencies=[Depends(require_user)])', + "def account():", + " return True", + ].join("\n"), + }); + try { + const analysis = await analyze(repo); + assert.deepEqual(analysis.fastApiDependencyContexts, []); + } finally { + await repo.cleanup(); + } +}); diff --git a/tests/fastapi-router-composition.test.mjs b/tests/fastapi-router-composition.test.mjs new file mode 100644 index 00000000..193f918a --- /dev/null +++ b/tests/fastapi-router-composition.test.mjs @@ -0,0 +1,209 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { buildRepositoryIndex } from "@synsec/repository/analysis"; +import { buildModuleGraph } from "@synsec/repository/module-graph"; +import { buildRepositoryRouteFlowAnalysis } from "@synsec/repository/route-flow-analysis"; + +async function makeRepository(filesByPath) { + const root = await mkdtemp(join(tmpdir(), "synsec-fastapi-router-composition-")); + const files = []; + for (const [path, content] of Object.entries(filesByPath)) { + const absolute = join(root, path); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, content, "utf8"); + files.push({ path, size: Buffer.byteLength(content) }); + } + return { root, files, cleanup: () => rm(root, { recursive: true, force: true }) }; +} + +async function analyze(repo, options) { + const index = await buildRepositoryIndex(repo.root, repo.files); + return buildRepositoryRouteFlowAnalysis( + repo.root, + repo.files, + index, + buildModuleGraph(index, repo.files), + options, + ); +} + +function composedEntrypoints(analysis) { + return analysis.entrypoints.filter((entrypoint) => entrypoint.route.frameworkHint === "FastAPI composed router"); +} + +test("FastAPI imported APIRouter prefixes compose into exact route and sink evidence", async () => { + const repo = await makeRepository({ + "api/__init__.py": "", + "api/app.py": [ + "from fastapi import FastAPI", + "from .users import router as users_router", + "app = FastAPI()", + 'app.include_router(users_router, prefix="/api")', + ].join("\n"), + "api/users.py": [ + "from fastapi import APIRouter", + 'router = APIRouter(prefix="/users")', + '@router.get("/{user_id}")', + "def get_user(user_id):", + " cursor.execute(query_text)", + ].join("\n"), + }); + try { + const analysis = await analyze(repo); + const composed = composedEntrypoints(analysis); + assert.equal(composed.length, 1); + assert.equal(composed[0]?.route.route, "/api/users/{user_id}"); + assert.equal(composed[0]?.handler?.path, "api/users.py"); + assert.equal(composed[0]?.compositionInterpretation, "structural-fastapi-router-composition-not-runtime-reachability"); + assert.equal( + analysis.routeFlows.some((flow) => ( + flow.route.route === "/api/users/{user_id}" + && flow.evidence.some((item) => item.path === "api/users.py" && item.line === 5) + )), + true, + ); + } finally { + await repo.cleanup(); + } +}); + +test("FastAPI nested router includes compose bounded parent, include, and child prefixes", async () => { + const repo = await makeRepository({ + "api/__init__.py": "", + "api/app.py": [ + "from fastapi import FastAPI", + "from .v1 import router as v1_router", + "app = FastAPI()", + 'app.include_router(v1_router, prefix="/api")', + ].join("\n"), + "api/v1.py": [ + "from fastapi import APIRouter", + "from .users import router as users_router", + 'router = APIRouter(prefix="/v1")', + 'router.include_router(users_router, prefix="/accounts")', + ].join("\n"), + "api/users.py": [ + "from fastapi import APIRouter", + 'router = APIRouter(prefix="/users")', + '@router.post("/")', + "def create_user():", + " database.save(record)", + ].join("\n"), + }); + try { + const analysis = await analyze(repo); + const composed = composedEntrypoints(analysis); + assert.equal(composed.length, 1); + assert.equal(composed[0]?.route.route, "/api/v1/accounts/users"); + assert.equal(composed[0]?.composition?.includeDepth, 2); + assert.deepEqual(composed[0]?.composition?.prefixes, ["/api", "/v1", "/accounts", "/users"]); + } finally { + await repo.cleanup(); + } +}); + +test("FastAPI dynamic include prefixes fail closed", async () => { + const repo = await makeRepository({ + "api/__init__.py": "", + "api/app.py": [ + "from fastapi import FastAPI", + "from .users import router as users_router", + "app = FastAPI()", + 'API_PREFIX = "/api"', + "app.include_router(users_router, prefix=API_PREFIX)", + ].join("\n"), + "api/users.py": [ + "from fastapi import APIRouter", + "router = APIRouter()", + '@router.get("/users")', + "def users():", + " return True", + ].join("\n"), + }); + try { + const analysis = await analyze(repo); + assert.deepEqual(composedEntrypoints(analysis), []); + } finally { + await repo.cleanup(); + } +}); + +test("FastAPI shadowed imported router bindings fail closed", async () => { + const repo = await makeRepository({ + "api/__init__.py": "", + "api/app.py": [ + "from fastapi import FastAPI", + "from .users import router as users_router", + "app = FastAPI()", + "users_router = make_router()", + 'app.include_router(users_router, prefix="/api")', + ].join("\n"), + "api/users.py": [ + "from fastapi import APIRouter", + "router = APIRouter()", + '@router.get("/users")', + "def users():", + " return True", + ].join("\n"), + }); + try { + const analysis = await analyze(repo); + assert.deepEqual(composedEntrypoints(analysis), []); + } finally { + await repo.cleanup(); + } +}); + +test("FastAPI router composition obeys include-depth bounds without widening evidence", async () => { + const repo = await makeRepository({ + "api/__init__.py": "", + "api/app.py": [ + "from fastapi import FastAPI", + "from .v1 import router as v1_router", + "app = FastAPI()", + "app.include_router(v1_router)", + ].join("\n"), + "api/v1.py": [ + "from fastapi import APIRouter", + "from .users import router as users_router", + "router = APIRouter()", + "router.include_router(users_router)", + ].join("\n"), + "api/users.py": [ + "from fastapi import APIRouter", + "router = APIRouter()", + '@router.get("/users")', + "def users():", + " return True", + ].join("\n"), + }); + try { + const analysis = await analyze(repo, { maxFastApiIncludeDepth: 1 }); + assert.deepEqual(composedEntrypoints(analysis), []); + } finally { + await repo.cleanup(); + } +}); + +test("FastAPI router declarations used before definition do not create composed evidence", async () => { + const repo = await makeRepository({ + "app.py": [ + "from fastapi import FastAPI, APIRouter", + "app = FastAPI()", + 'app.include_router(router, prefix="/api")', + "router = APIRouter()", + '@router.get("/late")', + "def late():", + " return True", + ].join("\n"), + }); + try { + const analysis = await analyze(repo); + assert.deepEqual(composedEntrypoints(analysis), []); + } finally { + await repo.cleanup(); + } +}); diff --git a/tests/flask-blueprint-composition.test.mjs b/tests/flask-blueprint-composition.test.mjs new file mode 100644 index 00000000..3d368695 --- /dev/null +++ b/tests/flask-blueprint-composition.test.mjs @@ -0,0 +1,210 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { buildRepositoryIndex } from "@synsec/repository/analysis"; +import { buildCallGraph } from "@synsec/repository/call-graph"; +import { composeFlaskBlueprintEntrypoints } from "@synsec/repository/flask-blueprint-composition"; +import { buildModuleGraph } from "@synsec/repository/module-graph"; +import { repositoryRouteSinkFlowContexts } from "@synsec/repository/route-sink-flow"; + +async function makeRepository(filesByPath) { + const root = await mkdtemp(join(tmpdir(), "synsec-flask-blueprint-composition-")); + const files = []; + for (const [path, content] of Object.entries(filesByPath)) { + const absolute = join(root, path); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, content, "utf8"); + files.push({ path, size: Buffer.byteLength(content) }); + } + return { root, files, cleanup: () => rm(root, { recursive: true, force: true }) }; +} + +async function compose(repo, options) { + const index = await buildRepositoryIndex(repo.root, repo.files); + const moduleGraph = buildModuleGraph(index, repo.files); + const entrypoints = await composeFlaskBlueprintEntrypoints(repo.root, repo.files, moduleGraph, [], options); + const callGraph = await buildCallGraph(repo.root, repo.files); + return { + index, + entrypoints, + routeFlows: repositoryRouteSinkFlowContexts(index, entrypoints, callGraph), + }; +} + +function flaskEntrypoints(result) { + return result.entrypoints.filter((entrypoint) => entrypoint.route.frameworkHint === "Flask composed blueprint"); +} + +test("Flask imported Blueprint prefixes compose into exact route and sink evidence", async () => { + const repo = await makeRepository({ + "app/__init__.py": "", + "app/main.py": [ + "from flask import Flask", + "from .users import users as users_blueprint", + "app = Flask(__name__)", + 'app.register_blueprint(users_blueprint, url_prefix="/api")', + ].join("\n"), + "app/users.py": [ + "from flask import Blueprint", + 'users = Blueprint("users", __name__, url_prefix="/users")', + '@users.get("/")', + "def get_user(user_id):", + " cursor.execute(query_text)", + ].join("\n"), + }); + try { + const result = await compose(repo); + const entrypoints = flaskEntrypoints(result); + assert.equal(entrypoints.length, 1); + assert.equal(entrypoints[0]?.route.route, "/api/users/"); + assert.equal(entrypoints[0]?.handler?.path, "app/users.py"); + assert.equal( + entrypoints[0]?.compositionInterpretation, + "structural-flask-blueprint-composition-not-runtime-reachability", + ); + assert.equal( + result.routeFlows.some((flow) => ( + flow.route.route === "/api/users/" + && flow.evidence.some((item) => item.path === "app/users.py" && item.line === 5) + )), + true, + ); + } finally { + await repo.cleanup(); + } +}); + +test("Flask nested Blueprint registration composes parent, registration, and child prefixes", async () => { + const repo = await makeRepository({ + "app/__init__.py": "", + "app/main.py": [ + "from flask import Flask", + "from .api import api", + "app = Flask(__name__)", + 'app.register_blueprint(api, url_prefix="/root")', + ].join("\n"), + "app/api.py": [ + "from flask import Blueprint", + "from .users import users", + 'api = Blueprint("api", __name__, url_prefix="/v1")', + 'api.register_blueprint(users, url_prefix="/accounts")', + ].join("\n"), + "app/users.py": [ + "from flask import Blueprint", + 'users = Blueprint("users", __name__, url_prefix="/users")', + '@users.post("/")', + "def create_user():", + " database.execute(statement)", + ].join("\n"), + }); + try { + const result = await compose(repo); + const entrypoint = flaskEntrypoints(result)[0]; + assert.equal(entrypoint?.route.route, "/root/v1/accounts/users"); + assert.equal(entrypoint?.composition?.registerDepth, 2); + assert.deepEqual(entrypoint?.composition?.prefixes, ["/root", "/v1", "/accounts", "/users"]); + } finally { + await repo.cleanup(); + } +}); + +test("Flask dynamic registration prefixes fail closed", async () => { + const repo = await makeRepository({ + "app/__init__.py": "", + "app/main.py": [ + "from flask import Flask", + "from .users import users", + "app = Flask(__name__)", + 'API_PREFIX = "/api"', + "app.register_blueprint(users, url_prefix=API_PREFIX)", + ].join("\n"), + "app/users.py": [ + "from flask import Blueprint", + 'users = Blueprint("users", __name__)', + '@users.get("/users")', + "def list_users():", + " return True", + ].join("\n"), + }); + try { + assert.deepEqual(flaskEntrypoints(await compose(repo)), []); + } finally { + await repo.cleanup(); + } +}); + +test("Flask shadowed imported Blueprint bindings fail closed", async () => { + const repo = await makeRepository({ + "app/__init__.py": "", + "app/main.py": [ + "from flask import Flask", + "from .users import users", + "app = Flask(__name__)", + "users = build_blueprint()", + 'app.register_blueprint(users, url_prefix="/api")', + ].join("\n"), + "app/users.py": [ + "from flask import Blueprint", + 'users = Blueprint("users", __name__)', + '@users.get("/users")', + "def list_users():", + " return True", + ].join("\n"), + }); + try { + assert.deepEqual(flaskEntrypoints(await compose(repo)), []); + } finally { + await repo.cleanup(); + } +}); + +test("Flask Blueprint composition obeys registration depth bounds", async () => { + const repo = await makeRepository({ + "app/__init__.py": "", + "app/main.py": [ + "from flask import Flask", + "from .api import api", + "app = Flask(__name__)", + "app.register_blueprint(api)", + ].join("\n"), + "app/api.py": [ + "from flask import Blueprint", + "from .users import users", + 'api = Blueprint("api", __name__)', + "api.register_blueprint(users)", + ].join("\n"), + "app/users.py": [ + "from flask import Blueprint", + 'users = Blueprint("users", __name__)', + '@users.get("/users")', + "def list_users():", + " return True", + ].join("\n"), + }); + try { + assert.deepEqual(flaskEntrypoints(await compose(repo, { maxRegisterDepth: 1 })), []); + } finally { + await repo.cleanup(); + } +}); + +test("Flask Blueprint registrations used before declaration do not create composed evidence", async () => { + const repo = await makeRepository({ + "app.py": [ + "from flask import Flask, Blueprint", + "app = Flask(__name__)", + 'app.register_blueprint(users, url_prefix="/api")', + 'users = Blueprint("users", __name__)', + '@users.get("/late")', + "def late():", + " return True", + ].join("\n"), + }); + try { + assert.deepEqual(flaskEntrypoints(await compose(repo)), []); + } finally { + await repo.cleanup(); + } +}); diff --git a/tests/flask-blueprint-route-flow.test.mjs b/tests/flask-blueprint-route-flow.test.mjs new file mode 100644 index 00000000..a301135a --- /dev/null +++ b/tests/flask-blueprint-route-flow.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { buildRepositoryIndex } from "@synsec/repository/analysis"; +import { buildModuleGraph } from "@synsec/repository/module-graph"; +import { buildRepositoryRouteFlowAnalysis } from "@synsec/repository/route-flow-analysis"; + +async function makeRepository(filesByPath) { + const root = await mkdtemp(join(tmpdir(), "synsec-flask-route-flow-")); + const files = []; + for (const [path, content] of Object.entries(filesByPath)) { + const absolute = join(root, path); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, content, "utf8"); + files.push({ path, size: Buffer.byteLength(content) }); + } + return { root, files, cleanup: () => rm(root, { recursive: true, force: true }) }; +} + +test("aggregate route-flow analysis carries composed Flask Blueprint routes into exact sink correlation", async () => { + const repo = await makeRepository({ + "web/__init__.py": "", + "web/app.py": [ + "from flask import Flask", + "from .admin import admin", + "app = Flask(__name__)", + 'app.register_blueprint(admin, url_prefix="/api")', + ].join("\n"), + "web/admin.py": [ + "from flask import Blueprint", + 'admin = Blueprint("admin", __name__, url_prefix="/admin")', + '@admin.post("/jobs")', + "def create_job():", + " cursor.execute(statement)", + ].join("\n"), + }); + try { + const index = await buildRepositoryIndex(repo.root, repo.files); + const analysis = await buildRepositoryRouteFlowAnalysis( + repo.root, + repo.files, + index, + buildModuleGraph(index, repo.files), + ); + const route = analysis.entrypoints.find((entrypoint) => entrypoint.route.route === "/api/admin/jobs"); + assert.equal(route?.route.frameworkHint, "Flask composed blueprint"); + assert.equal( + analysis.routeFlows.some((flow) => ( + flow.route.route === "/api/admin/jobs" + && flow.evidence.some((item) => item.path === "web/admin.py" && item.line === 5) + )), + true, + ); + } finally { + await repo.cleanup(); + } +}); diff --git a/tests/gin-request-input-flow-aggregate.test.mjs b/tests/gin-request-input-flow-aggregate.test.mjs new file mode 100644 index 00000000..82fc7814 --- /dev/null +++ b/tests/gin-request-input-flow-aggregate.test.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { buildRepositoryIndex } from "@synsec/repository/analysis"; +import { buildModuleGraph } from "@synsec/repository/module-graph"; +import { buildRepositoryRouteFlowAnalysis } from "@synsec/repository/route-flow-analysis"; + +async function makeRepository(files) { + const root = await mkdtemp(join(tmpdir(), "synsec-gin-request-flow-aggregate-")); + const inputs = []; + for (const [path, content] of Object.entries(files)) { + await mkdir(dirname(join(root, path)), { recursive: true }); + await writeFile(join(root, path), content, "utf8"); + inputs.push({ path, size: Buffer.byteLength(content) }); + } + return { root, files: inputs, cleanup: () => rm(root, { recursive: true, force: true }) }; +} + +test("aggregate repository route-flow analysis includes bounded Gin direct request-source evidence", async () => { + const source = [ + "package api", + 'import "github.com/gin-gonic/gin"', + "func runJob(c *gin.Context) {", + ' runQuery(c.GetHeader("X-Trace"))', + "}", + "func runQuery(term string) {", + ' db.Query("select 1")', + "}", + "func routes() {", + " router := gin.Default()", + ' router.POST("/jobs", runJob)', + "}", + ].join("\n"); + const repo = await makeRepository({ "api/routes.go": source }); + try { + const index = await buildRepositoryIndex(repo.root, repo.files); + const analysis = await buildRepositoryRouteFlowAnalysis( + repo.root, + repo.files, + index, + buildModuleGraph(index, repo.files), + ); + + assert.deepEqual(analysis.ginRequestInputFlows.map((context) => ({ + route: context.route.route, + frameworkHint: context.route.frameworkHint, + handler: context.handler.name, + sourceKinds: context.sourceKinds, + sinkKinds: context.sinkKinds, + interpretation: context.interpretation, + evidence: context.evidence.map((item) => ({ + sourceKind: item.source.kind, + sourceLine: item.source.line, + sinkKind: item.sink.kind, + sinkLine: item.sink.line, + callDistance: item.callDistance, + })), + })), [{ + route: "/jobs", + frameworkHint: "Gin router", + handler: "runJob", + sourceKinds: ["header"], + sinkKinds: ["database"], + interpretation: "structural-gin-context-source-direct-call-sink-evidence-only", + evidence: [{ + sourceKind: "header", + sourceLine: 4, + sinkKind: "database", + sinkLine: 7, + callDistance: 1, + }], + }]); + assert.equal(JSON.stringify(analysis.ginRequestInputFlows).includes("X-Trace"), false); + } finally { + await repo.cleanup(); + } +}); diff --git a/tests/gin-request-input-flow.test.mjs b/tests/gin-request-input-flow.test.mjs new file mode 100644 index 00000000..a6440cae --- /dev/null +++ b/tests/gin-request-input-flow.test.mjs @@ -0,0 +1,213 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { buildRepositoryIndex } from "@synsec/repository/analysis"; +import { + buildGinRouteRequestInputFlowContexts, + findingGinRequestInputFlowEvidence, +} from "@synsec/repository/gin-request-input-flow"; +import { buildModuleGraph } from "@synsec/repository/module-graph"; +import { buildRepositoryRouteFlowAnalysis } from "@synsec/repository/route-flow-analysis"; + +async function makeRepository(files) { + const root = await mkdtemp(join(tmpdir(), "synsec-gin-request-flow-")); + const inputs = []; + for (const [path, content] of Object.entries(files)) { + await mkdir(dirname(join(root, path)), { recursive: true }); + await writeFile(join(root, path), content, "utf8"); + inputs.push({ path, size: Buffer.byteLength(content) }); + } + return { root, files: inputs, cleanup: () => rm(root, { recursive: true, force: true }) }; +} + +async function analyze(repo) { + const index = await buildRepositoryIndex(repo.root, repo.files); + return buildRepositoryRouteFlowAnalysis( + repo.root, + repo.files, + index, + buildModuleGraph(index, repo.files), + ); +} + +test("Gin direct context query passed to a direct callee produces structural source-to-sink evidence", async () => { + const source = [ + "package api", + 'import "github.com/gin-gonic/gin"', + "func runJob(c *gin.Context) {", + ' runQuery(c.Query("q"))', + "}", + "func runQuery(term string) {", + ' db.Query("select 1")', + "}", + "func routes() {", + " router := gin.Default()", + ' router.GET("/jobs", runJob)', + "}", + ].join("\n"); + const repo = await makeRepository({ "api/routes.go": source }); + try { + const analysis = await analyze(repo); + const ginFlow = analysis.routeFlows.find((item) => item.route.route === "/jobs" && item.route.frameworkHint === "Gin router"); + assert.equal(ginFlow?.handler.name, "runJob"); + assert.equal(ginFlow?.evidence.some((item) => + item.kind === "database" && item.line === 7 && item.functionName === "runQuery" && item.depth === 1), true); + assert.equal(analysis.callGraph.edges.some((edge) => + edge.line === 4 && edge.callee === "runQuery" && edge.resolution === "same-file-function"), true); + assert.deepEqual(analysis.callGraph.nodes.filter((node) => node.kind === "go-function").map((node) => ({ + name: node.name, + line: node.line, + endLine: node.endLine, + })), [ + { name: "runJob", line: 3, endLine: 5 }, + { name: "runQuery", line: 6, endLine: 8 }, + { name: "routes", line: 9, endLine: 12 }, + ]); + + const contexts = await buildGinRouteRequestInputFlowContexts(repo.root, analysis.routeFlows, analysis.callGraph); + assert.equal(contexts.length, 1); + const context = contexts[0]; + assert.equal(context?.route.route, "/jobs"); + assert.equal(context?.route.frameworkHint, "Gin router"); + assert.equal(context?.interpretation, "structural-gin-context-source-direct-call-sink-evidence-only"); + assert.deepEqual(context?.sourceKinds, ["query"]); + assert.deepEqual(context?.evidence.map((item) => ({ + sourceLine: item.source.line, + sourceKind: item.source.kind, + access: item.source.access, + sinkLine: item.sink.line, + sinkKind: item.sink.kind, + sinkFunction: item.sink.functionName, + callDistance: item.callDistance, + })), [{ + sourceLine: 4, + sourceKind: "query", + access: "gin.Context.Query", + sinkLine: 7, + sinkKind: "database", + sinkFunction: "runQuery", + callDistance: 1, + }]); + assert.deepEqual(findingGinRequestInputFlowEvidence(contexts, "api/routes.go", 7), [{ + method: "GET", + route: "/jobs", + frameworkHint: "Gin router", + handler: "runJob", + sourceKind: "query", + sourceFunction: "runJob", + sinkKind: "database", + sinkFunction: "runQuery", + callDistance: 1, + interpretation: "structural-gin-context-source-direct-call-sink-evidence-only", + }]); + assert.equal(JSON.stringify(context).includes('"q"'), false); + } finally { + await repo.cleanup(); + } +}); + +test("Gin direct context access on the exact sink line has zero call distance", async () => { + const source = [ + "package api", + 'import "github.com/gin-gonic/gin"', + "func runJob(c *gin.Context) {", + ' db.Query(c.Param("job_id"))', + "}", + "func routes() {", + " router := gin.Default()", + ' router.GET("/jobs/:job_id", runJob)', + "}", + ].join("\n"); + const repo = await makeRepository({ "api/routes.go": source }); + try { + const analysis = await analyze(repo); + const ginFlow = analysis.routeFlows.find((item) => item.route.route === "/jobs/:job_id" && item.route.frameworkHint === "Gin router"); + assert.equal(ginFlow?.handler.name, "runJob"); + assert.deepEqual(ginFlow?.evidence.filter((item) => item.kind === "database").map((item) => ({ line: item.line, functionName: item.functionName })), [ + { line: 4, functionName: "runJob" }, + ]); + const contexts = await buildGinRouteRequestInputFlowContexts(repo.root, analysis.routeFlows, analysis.callGraph); + assert.deepEqual(contexts[0]?.evidence.map((item) => ({ kind: item.source.kind, distance: item.callDistance })), [ + { kind: "path", distance: 0 }, + ]); + } finally { + await repo.cleanup(); + } +}); + +test("Gin bound-object APIs and stored request values are not promoted into directional flow", async () => { + const source = [ + "package api", + 'import "github.com/gin-gonic/gin"', + "type payload struct { Name string }", + "func runJob(c *gin.Context) {", + " var body payload", + " c.ShouldBindJSON(&body)", + ' term := c.Query("q")', + " runQuery(term)", + "}", + "func runQuery(term string) {", + ' db.Query("select 1")', + "}", + "func routes() {", + " router := gin.Default()", + ' router.POST("/jobs", runJob)', + "}", + ].join("\n"); + const repo = await makeRepository({ "api/routes.go": source }); + try { + const analysis = await analyze(repo); + const contexts = await buildGinRouteRequestInputFlowContexts(repo.root, analysis.routeFlows, analysis.callGraph); + assert.deepEqual(contexts, []); + } finally { + await repo.cleanup(); + } +}); + +test("Gin request flow fails closed for aliased imports and non-Gin context-looking methods", async () => { + const aliased = [ + "package api", + 'import g "github.com/gin-gonic/gin"', + "func runJob(c *g.Context) {", + ' db.Query(c.Query("q"))', + "}", + ].join("\n"); + const lookalike = [ + "package api", + "type Context struct{}", + "func runJob(c *Context) {", + ' db.Query(c.Query("q"))', + "}", + ].join("\n"); + for (const [name, source] of [["aliased", aliased], ["lookalike", lookalike]]) { + const repo = await makeRepository({ [`${name}.go`]: source }); + try { + const analysis = await analyze(repo); + const contexts = await buildGinRouteRequestInputFlowContexts(repo.root, analysis.routeFlows, analysis.callGraph); + assert.deepEqual(contexts, [], name); + } finally { + await repo.cleanup(); + } + } +}); + +test("Gin request-flow validates resource bounds", async () => { + const repo = await makeRepository({ + "api/routes.go": [ + "package api", + 'import "github.com/gin-gonic/gin"', + "func runJob(c *gin.Context) {}", + ].join("\n"), + }); + try { + const analysis = await analyze(repo); + await assert.rejects( + buildGinRouteRequestInputFlowContexts(repo.root, analysis.routeFlows, analysis.callGraph, { maxEvidence: 0 }), + /Gin request-flow maxEvidence must be an integer between 1 and 50/, + ); + } finally { + await repo.cleanup(); + } +}); diff --git a/tests/gin-request-input-forwarding.test.mjs b/tests/gin-request-input-forwarding.test.mjs new file mode 100644 index 00000000..bbfc0e4c --- /dev/null +++ b/tests/gin-request-input-forwarding.test.mjs @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { buildRepositoryIndex } from "@synsec/repository/analysis"; +import { findingGinRequestInputForwardingEvidence } from "@synsec/repository/gin-request-input-forwarding"; +import { buildModuleGraph } from "@synsec/repository/module-graph"; +import { buildRepositoryRouteFlowAnalysis } from "@synsec/repository/route-flow-analysis"; + +async function makeRepository(files) { + const root = await mkdtemp(join(tmpdir(), "synsec-gin-request-forwarding-")); + const inputs = []; + for (const [path, content] of Object.entries(files)) { + await mkdir(dirname(join(root, path)), { recursive: true }); + await writeFile(join(root, path), content, "utf8"); + inputs.push({ path, size: Buffer.byteLength(content) }); + } + return { root, files: inputs, cleanup: () => rm(root, { recursive: true, force: true }) }; +} + +async function analyze(repo, options = {}) { + const index = await buildRepositoryIndex(repo.root, repo.files); + return buildRepositoryRouteFlowAnalysis( + repo.root, + repo.files, + index, + buildModuleGraph(index, repo.files), + options, + ); +} + +function sourceWithHandler(handlerLines) { + return [ + "package api", + 'import "github.com/gin-gonic/gin"', + "func runJob(c *gin.Context) {", + ...handlerLines, + "}", + "func runQuery(term string) {", + ' db.Query("select 1")', + "}", + "func routes() {", + " router := gin.Default()", + ' router.GET("/jobs", runJob)', + "}", + ].join("\n"); +} + +test("aggregate Gin flow carries one unchanged single-use local request value to an exact callee sink", async () => { + const repo = await makeRepository({ + "api/routes.go": sourceWithHandler([ + ' term := c.Query("q")', + " runQuery(term)", + ]), + }); + try { + const analysis = await analyze(repo); + assert.deepEqual(analysis.ginRequestInputForwardingFlows.map((context) => ({ + route: context.route.route, + sourceKinds: context.sourceKinds, + sinkKinds: context.sinkKinds, + interpretation: context.interpretation, + evidence: context.evidence.map((item) => ({ + sourceLine: item.source.line, + useLine: item.binding.useLine, + sinkLine: item.sink.line, + distance: item.callDistance, + })), + })), [{ + route: "/jobs", + sourceKinds: ["query"], + sinkKinds: ["database"], + interpretation: "structural-gin-context-source-single-use-local-call-sink-evidence-only", + evidence: [{ sourceLine: 4, useLine: 5, sinkLine: 8, distance: 1 }], + }]); + assert.deepEqual(findingGinRequestInputForwardingEvidence( + analysis.ginRequestInputForwardingFlows, + "api/routes.go", + 8, + ), [{ + method: "GET", + route: "/jobs", + frameworkHint: "Gin router", + handler: "runJob", + sourceKind: "query", + sourceFunction: "runJob", + sinkKind: "database", + sinkFunction: "runQuery", + callDistance: 1, + bindingHops: 1, + interpretation: "structural-gin-context-source-single-use-local-call-sink-evidence-only", + }]); + assert.equal(JSON.stringify(analysis.ginRequestInputForwardingFlows).includes('"q"'), false); + } finally { + await repo.cleanup(); + } +}); + +test("Gin local forwarding fails closed on multiple use, reassignment, and transformation", async () => { + const cases = { + multiple: [ + ' term := c.Query("q")', + " log.Print(term)", + " runQuery(term)", + ], + reassigned: [ + ' term := c.Query("q")', + ' term = "fixed"', + " runQuery(term)", + ], + transformed: [ + ' term := c.Query("q")', + " runQuery(strings.TrimSpace(term))", + ], + }; + for (const [name, handlerLines] of Object.entries(cases)) { + const repo = await makeRepository({ [`api/${name}.go`]: sourceWithHandler(handlerLines) }); + try { + const analysis = await analyze(repo); + assert.deepEqual(analysis.ginRequestInputForwardingFlows, [], name); + } finally { + await repo.cleanup(); + } + } +}); + +test("Gin local forwarding honors its independent forward-line bound", async () => { + const repo = await makeRepository({ + "api/routes.go": sourceWithHandler([ + ' term := c.Param("job_id")', + "", + " runQuery(term)", + ]), + }); + try { + const tight = await analyze(repo, { maxGinRequestInputForwardLines: 1 }); + assert.deepEqual(tight.ginRequestInputForwardingFlows, []); + const allowed = await analyze(repo, { maxGinRequestInputForwardLines: 2 }); + assert.equal(allowed.ginRequestInputForwardingFlows[0]?.sourceKinds[0], "path"); + } finally { + await repo.cleanup(); + } +}); diff --git a/tests/gin-router-composition.test.mjs b/tests/gin-router-composition.test.mjs new file mode 100644 index 00000000..f1ea2d36 --- /dev/null +++ b/tests/gin-router-composition.test.mjs @@ -0,0 +1,244 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { buildRepositoryIndex } from "@synsec/repository/analysis"; +import { buildCallGraph } from "@synsec/repository/call-graph"; +import { composeGinRouterEntrypoints } from "@synsec/repository/gin-router-composition"; +import { buildModuleGraph } from "@synsec/repository/module-graph"; +import { buildRepositoryRouteFlowAnalysis } from "@synsec/repository/route-flow-analysis"; + +async function makeRepository(files) { + const root = await mkdtemp(join(tmpdir(), "synsec-gin-router-")); + const inputs = []; + for (const [path, content] of Object.entries(files)) { + await mkdir(dirname(join(root, path)), { recursive: true }); + await writeFile(join(root, path), content, "utf8"); + inputs.push({ path, size: Buffer.byteLength(content) }); + } + return { + root, + files: inputs, + cleanup: () => rm(root, { recursive: true, force: true }), + }; +} + +test("Gin groups, middleware, Go calls, and same-file handlers participate in exact sink correlation", async () => { + const source = [ + "package api", + "", + "import (", + ' "github.com/gin-gonic/gin"', + ")", + "", + "func requireUser(c *gin.Context) {}", + "func audit(c *gin.Context) {}", + "func runJob(c *gin.Context) {", + " runQuery()", + "}", + "func runQuery() {", + ' db.Query("select 1")', + "}", + "func routes() {", + " router := gin.Default()", + ' api := router.Group("/api", requireUser)', + ' jobs := api.Group("/jobs")', + ' jobs.POST("/run", audit, runJob)', + "}", + ].join("\n"); + const repo = await makeRepository({ "api/routes.go": source }); + try { + const index = await buildRepositoryIndex(repo.root, repo.files); + const analysis = await buildRepositoryRouteFlowAnalysis( + repo.root, + repo.files, + index, + buildModuleGraph(index, repo.files), + ); + + const goNodes = analysis.callGraph.nodes.filter((node) => node.kind === "go-function"); + assert.deepEqual(goNodes.map((node) => node.name), ["requireUser", "audit", "runJob", "runQuery", "routes"]); + assert.equal(analysis.callGraph.edges.some((edge) => edge.callee === "runQuery" && edge.resolution === "same-file-function"), true); + + const entrypoint = analysis.entrypoints.find( + (item) => item.route.route === "/api/jobs/run" && item.route.frameworkHint === "Gin router", + ); + assert.equal(entrypoint?.route.method, "POST"); + assert.equal(entrypoint?.handler?.name, "runJob"); + assert.equal(entrypoint?.resolution, "named-function"); + + const middleware = analysis.ginMiddlewareContexts.find((item) => item.route.route === "/api/jobs/run"); + assert.deepEqual(middleware?.middleware, [ + { name: "requireUser", source: "group", line: 18 }, + { name: "audit", source: "route", line: 19 }, + ]); + assert.equal(middleware?.scope.depth, 2); + assert.equal(middleware?.interpretation, "structural-gin-route-middleware-attachment-not-runtime-protection"); + + const flow = analysis.routeFlows.find( + (item) => item.route.route === "/api/jobs/run" && item.route.frameworkHint === "Gin router", + ); + assert.deepEqual(flow?.evidence + .filter((item) => item.kind === "database") + .map((item) => ({ path: item.path, line: item.line, depth: item.depth, functionName: item.functionName })), [ + { path: "api/routes.go", line: 13, depth: 1, functionName: "runQuery" }, + ]); + assert.equal(flow?.interpretation, "structural-route-call-sink-evidence-only"); + } finally { + await repo.cleanup(); + } +}); + +test("Gin routes resolve one unique handler in the same Go package directory", async () => { + const routes = [ + "package api", + 'import "github.com/gin-gonic/gin"', + "func routes() {", + " router := gin.New()", + ' router.POST("/jobs/run", runJob)', + "}", + ].join("\n"); + const handlers = [ + "package api", + 'import "github.com/gin-gonic/gin"', + "func runJob(c *gin.Context) {", + ' db.Query("select 1")', + "}", + ].join("\n"); + const repo = await makeRepository({ "api/routes.go": routes, "api/handlers.go": handlers }); + try { + const index = await buildRepositoryIndex(repo.root, repo.files); + const analysis = await buildRepositoryRouteFlowAnalysis( + repo.root, + repo.files, + index, + buildModuleGraph(index, repo.files), + ); + const entrypoint = analysis.entrypoints.find( + (item) => item.route.route === "/jobs/run" && item.route.frameworkHint === "Gin router", + ); + assert.equal(entrypoint?.handler?.path, "api/handlers.go"); + assert.equal(entrypoint?.handler?.name, "runJob"); + const flow = analysis.routeFlows.find( + (item) => item.route.route === "/jobs/run" && item.route.frameworkHint === "Gin router", + ); + assert.deepEqual(flow?.evidence.map((item) => ({ path: item.path, line: item.line, kind: item.kind })), [ + { path: "api/handlers.go", line: 4, kind: "database" }, + ]); + } finally { + await repo.cleanup(); + } +}); + +test("Gin composition fails closed on dynamic group prefixes", async () => { + const source = [ + "package api", + 'import "github.com/gin-gonic/gin"', + "func status(c *gin.Context) {}", + "func routes() {", + " router := gin.Default()", + " api := router.Group(apiPrefix)", + ' api.GET("/status", status)', + "}", + ].join("\n"); + const repo = await makeRepository({ "routes.go": source }); + try { + const graph = await buildCallGraph(repo.root, repo.files); + const result = await composeGinRouterEntrypoints(repo.root, repo.files, graph, []); + assert.deepEqual(result.entrypoints, []); + assert.deepEqual(result.middlewareContexts, []); + } finally { + await repo.cleanup(); + } +}); + +test("Gin composition rejects aliased imports and transformed handlers", async () => { + const source = [ + "package api", + 'import g "github.com/gin-gonic/gin"', + "func status(c *g.Context) {}", + "func routes() {", + " router := g.Default()", + ' router.GET("/status", wrap(status))', + "}", + ].join("\n"); + const repo = await makeRepository({ "routes.go": source }); + try { + const graph = await buildCallGraph(repo.root, repo.files); + const result = await composeGinRouterEntrypoints(repo.root, repo.files, graph, []); + assert.deepEqual(result.entrypoints, []); + } finally { + await repo.cleanup(); + } +}); + +test("Gin composition rejects reassigned scopes", async () => { + const source = [ + "package api", + 'import "github.com/gin-gonic/gin"', + "func runJob(c *gin.Context) {}", + "func routes() {", + " router := gin.Default()", + " router = replacement", + ' router.POST("/jobs/run", runJob)', + "}", + ].join("\n"); + const repo = await makeRepository({ "api/routes.go": source }); + try { + const graph = await buildCallGraph(repo.root, repo.files); + const result = await composeGinRouterEntrypoints(repo.root, repo.files, graph, []); + assert.deepEqual(result.entrypoints, []); + } finally { + await repo.cleanup(); + } +}); + +test("Gin ambiguous same-package handler names remain unresolved", async () => { + const routes = [ + "package api", + 'import "github.com/gin-gonic/gin"', + "func runJob(c *gin.Context) {}", + "func routes() {", + " router := gin.Default()", + ' router.POST("/jobs/run", runJob)', + "}", + ].join("\n"); + const duplicate = [ + "package api", + 'import "github.com/gin-gonic/gin"', + "func runJob(c *gin.Context) {}", + ].join("\n"); + const repo = await makeRepository({ "api/routes.go": routes, "api/duplicate.go": duplicate }); + try { + const graph = await buildCallGraph(repo.root, repo.files); + const result = await composeGinRouterEntrypoints(repo.root, repo.files, graph, []); + const entrypoint = result.entrypoints.find((item) => item.route.frameworkHint === "Gin router"); + assert.equal(entrypoint?.resolution, "unresolved"); + assert.equal(entrypoint?.handler, undefined); + } finally { + await repo.cleanup(); + } +}); + +test("Gin composition validates route output bounds", async () => { + const source = [ + "package api", + 'import "github.com/gin-gonic/gin"', + "func status(c *gin.Context) {}", + "func routes() {", + " router := gin.Default()", + ' router.GET("/status", status)', + "}", + ].join("\n"); + const repo = await makeRepository({ "routes.go": source }); + try { + const graph = await buildCallGraph(repo.root, repo.files); + await assert.rejects( + composeGinRouterEntrypoints(repo.root, repo.files, graph, [], { maxRoutes: 0 }), + /Gin maxRoutes must be an integer between 1 and 10000/, + ); + } finally { + await repo.cleanup(); + } +}); diff --git a/tests/github-action-inputs.test.mjs b/tests/github-action-inputs.test.mjs new file mode 100644 index 00000000..0c5c9f8e --- /dev/null +++ b/tests/github-action-inputs.test.mjs @@ -0,0 +1,67 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + booleanInput, + changedOnlyInput, + resolveWorkspaceFileInput, +} from "../apps/github-action/dist/inputs.js"; + +test("GitHub Action boolean inputs accept documented values and reject ambiguity", () => { + assert.equal(booleanInput(undefined, true), true); + assert.equal(booleanInput(" yes ", false), true); + assert.equal(booleanInput("0", true), false); + assert.throws(() => booleanInput("maybe", false), /Expected a boolean action input/); + + assert.equal(changedOnlyInput("auto"), undefined); + assert.equal(changedOnlyInput("true"), true); + assert.equal(changedOnlyInput("no"), false); + assert.throws(() => changedOnlyInput("sometimes"), /changed-only must be auto, true, or false/); +}); + +test("GitHub Action file inputs resolve regular files inside the checkout", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-action-input-")); + await mkdir(join(root, "config")); + await writeFile(join(root, "config", "synsec.json"), "{}", "utf8"); + + const resolved = await resolveWorkspaceFileInput(root, "config/synsec.json", "config-path"); + assert.equal(resolved, join(root, "config", "synsec.json")); +}); + +test("GitHub Action file inputs reject lexical traversal outside the checkout", async () => { + const parent = await mkdtemp(join(tmpdir(), "synsec-action-parent-")); + const root = join(parent, "repo"); + await mkdir(root); + await writeFile(join(parent, "outside.json"), "{}", "utf8"); + + await assert.rejects( + resolveWorkspaceFileInput(root, "../outside.json", "baseline-path"), + /must resolve inside GITHUB_WORKSPACE/, + ); +}); + +test("GitHub Action file inputs reject symlinks that escape the checkout", async () => { + const parent = await mkdtemp(join(tmpdir(), "synsec-action-symlink-")); + const root = join(parent, "repo"); + await mkdir(root); + const outside = join(parent, "outside.json"); + await writeFile(outside, "{}", "utf8"); + await symlink(outside, join(root, "baseline.json")); + + await assert.rejects( + resolveWorkspaceFileInput(root, "baseline.json", "baseline-path"), + /existing file inside GITHUB_WORKSPACE/, + ); +}); + +test("GitHub Action file inputs reject directories", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-action-dir-")); + await mkdir(join(root, "config")); + + await assert.rejects( + resolveWorkspaceFileInput(root, "config", "config-path"), + /regular file inside GITHUB_WORKSPACE/, + ); +}); diff --git a/tests/github-action-summary.test.mjs b/tests/github-action-summary.test.mjs new file mode 100644 index 00000000..99617a49 --- /dev/null +++ b/tests/github-action-summary.test.mjs @@ -0,0 +1,38 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { renderStepSummary } from "../apps/github-action/dist/summary.js"; + +function report() { + return { + schemaVersion: "1.0", + reportId: "summary-report", + generatedAt: "2026-08-22T15:20:00.000Z", + toolVersion: "0.2.0", + target: { path: "/workspace", commitSha: "abcdef1234567890" }, + scanners: [], + rawFindingCount: 3, + findingCount: 3, + summary: { critical: 1, high: 1, medium: 1, low: 0, info: 0, unknown: 0 }, + securityScore: 58, + findings: [], + scope: { mode: "changed-files", baseRef: "origin/main", changedFiles: ["src/app.ts"] }, + baseline: { new: ["a", "b"], fixed: ["c"], persisting: ["d", "e", "f"] }, + }; +} + +test("job summary contains aggregate scan and baseline metadata only", () => { + const value = report(); + value.findings = [{ primary: { title: "" } }]; + const summary = renderStepSummary(value, "base-scan"); + + assert.match(summary, /Security score:\*\* 58\/100/); + assert.match(summary, /Findings:\*\* 3/); + assert.match(summary, /Critical \| 1/); + assert.match(summary, /New: \*\*2\*\*/); + assert.match(summary, /Fixed: \*\*1\*\*/); + assert.match(summary, /Persisting: \*\*3\*\*/); + assert.match(summary, /Baseline:\*\* base-scan/); + assert.doesNotMatch(summary, /scanner-controlled/); + assert.doesNotMatch(summary, /src\/app\.ts/); +}); diff --git a/tests/github-actions-runner.test.mjs b/tests/github-actions-runner.test.mjs new file mode 100644 index 00000000..6c44fd12 --- /dev/null +++ b/tests/github-actions-runner.test.mjs @@ -0,0 +1,149 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { runGitHubActionsRepositoryScan } from "../packages/github/dist/actions-runner.js"; + +function report(commitSha = "abcdef1234567890") { + return { + schemaVersion: "1.0", + reportId: "report-actions", + generatedAt: "2026-08-22T14:30:00.000Z", + toolVersion: "0.2.0", + target: { path: "/workspace", commitSha }, + scanners: [{ scanner: "opengrep", startedAt: "a", completedAt: "b", findingCount: 0, artifactCount: 0, diagnostics: [] }], + rawFindingCount: 0, + findingCount: 0, + summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0, unknown: 0 }, + securityScore: 100, + findings: [], + scope: { mode: "changed-files", baseRef: "origin/main", changedFiles: ["src/app.ts"] }, + }; +} + +function outcome(commitSha = "abcdef1234567890") { + return { + report: report(commitSha), + repositoryIndex: { schemaVersion: "1.0", root: "/workspace", files: [] }, + statuses: [], + failures: [], + shouldFail: false, + changedFiles: ["src/app.ts"], + changedBase: "origin/main", + }; +} + +const config = { + version: 1, + scanners: ["opengrep"], + failOn: "high", + parallelism: 2, + timeoutMs: 60_000, +}; + +test("PR Actions runner defaults to changed-file scanning and publishes the scanned head", async () => { + let scanInput; + let request; + const result = await runGitHubActionsRepositoryScan("installation-token", { + config, + rootPath: "/workspace", + env: { + GITHUB_REPOSITORY: "cmahmud/synsec", + GITHUB_SHA: "abcdef1234567890", + GITHUB_REF: "refs/pull/2/head", + GITHUB_BASE_REF: "main", + GITHUB_HEAD_REF: "feature/multi-scanner-mvp", + }, + scan: async (input) => { + scanInput = input; + return outcome(); + }, + fetch: async (url, init) => { + request = { url, init }; + return new Response(JSON.stringify({ id: 444, status: "completed", conclusion: "success" }), { status: 201 }); + }, + }); + + assert.equal(scanInput.rootPath, "/workspace"); + assert.equal(scanInput.changedOnly, true); + assert.equal(scanInput.changedBase, "origin/main"); + assert.equal(result.context.pullRequestNumber, 2); + assert.equal(result.publication.check.headSha, "abcdef1234567890"); + assert.equal(result.publication.publication.id, 444); + assert.equal(request.url, "https://api.github.com/repos/cmahmud/synsec/check-runs"); + assert.equal(result.sarifPublication, undefined); +}); + +test("push Actions runner defaults to a full repository scan", async () => { + let scanInput; + await runGitHubActionsRepositoryScan("token", { + config, + env: { + GITHUB_REPOSITORY: "cmahmud/synsec", + GITHUB_SHA: "abcdef1234567890", + GITHUB_REF: "refs/heads/main", + }, + scan: async (input) => { + scanInput = input; + const value = outcome(); + value.report.scope = { mode: "repository" }; + return value; + }, + fetch: async () => new Response(JSON.stringify({ id: 445, status: "completed", conclusion: "success" }), { status: 201 }), + }); + + assert.equal(scanInput.changedOnly, false); + assert.equal(scanInput.changedBase, undefined); +}); + +test("Actions runner can publish the same commit-bound report to checks and code scanning", async () => { + const urls = []; + const result = await runGitHubActionsRepositoryScan("token", { + config, + publishSarif: true, + env: { + GITHUB_REPOSITORY: "cmahmud/synsec", + GITHUB_SHA: "abcdef1234567890", + GITHUB_REF: "refs/pull/2/head", + GITHUB_BASE_REF: "main", + GITHUB_HEAD_REF: "feature/multi-scanner-mvp", + }, + scan: async () => outcome(), + fetch: async (url) => { + urls.push(url); + if (url.endsWith("/check-runs")) { + return new Response(JSON.stringify({ id: 446, status: "completed", conclusion: "success" }), { status: 201 }); + } + if (url.endsWith("/code-scanning/sarifs")) { + return new Response(JSON.stringify({ id: "sarif-446" }), { status: 202 }); + } + throw new Error(`unexpected URL ${url}`); + }, + }); + + assert.deepEqual(urls, [ + "https://api.github.com/repos/cmahmud/synsec/check-runs", + "https://api.github.com/repos/cmahmud/synsec/code-scanning/sarifs", + ]); + assert.equal(result.sarifPublication.id, "sarif-446"); + assert.equal(result.sarifPublication.ref, "refs/pull/2/head"); +}); + +test("Actions runner refuses publication when the scan cannot prove its commit", async () => { + let published = false; + const value = outcome(); + delete value.report.target.commitSha; + + await assert.rejects( + () => runGitHubActionsRepositoryScan("token", { + config, + env: { GITHUB_REPOSITORY: "cmahmud/synsec", GITHUB_SHA: "abcdef1234567890" }, + scan: async () => value, + fetch: async () => { + published = true; + throw new Error("should not publish"); + }, + }), + /must produce a report with a commit SHA/, + ); + assert.equal(published, false); +}); diff --git a/tests/github-app-cli.test.mjs b/tests/github-app-cli.test.mjs new file mode 100644 index 00000000..4e89a934 --- /dev/null +++ b/tests/github-app-cli.test.mjs @@ -0,0 +1,243 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { execFile } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const exec = promisify(execFile); +const cli = new URL("../apps/cli/dist/github-app-cli.js", import.meta.url); + +async function runExpectingExit(args, expectedCode) { + try { + await exec(process.execPath, [cli.pathname, ...args]); + assert.fail(`Expected exit code ${expectedCode}.`); + } catch (error) { + assert.equal(error.code, expectedCode); + return error; + } +} + +test("GitHub App setup CLI prints the feature-aware minimum as JSON", async () => { + const { stdout } = await exec(process.execPath, [ + cli.pathname, + "requirements", + "--sarif", + "--remediation", + "--json", + ]); + const output = JSON.parse(stdout); + assert.deepEqual(output.permissions, { + contents: "write", + checks: "write", + security_events: "write", + pull_requests: "write", + }); + assert.equal(output.remediationWriteEnabled, true); + assert.deepEqual(output.events, [ + "installation", + "installation_repositories", + "pull_request", + "push", + ]); +}); + +test("GitHub App setup CLI evaluates a least-privilege configuration offline", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-app-setup-")); + try { + const setupPath = join(root, "setup.json"); + await writeFile(setupPath, JSON.stringify({ + permissions: { + contents: "read", + checks: "write", + }, + events: ["installation", "installation_repositories", "pull_request", "push"], + }), "utf8"); + + const { stdout } = await exec(process.execPath, [cli.pathname, "evaluate", setupPath, "--json"]); + const output = JSON.parse(stdout); + assert.equal(output.ready, true); + assert.deepEqual(output.missingPermissions, []); + assert.deepEqual(output.missingEvents, []); + assert.deepEqual(output.excessiveWritePermissions, []); + assert.equal(output.interpretation, "setup-comparison-not-runtime-authorization"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("GitHub App setup CLI exits 2 when required capability is missing", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-app-setup-missing-")); + try { + const setupPath = join(root, "setup.json"); + await writeFile(setupPath, JSON.stringify({ + permissions: { contents: "read" }, + events: ["push"], + }), "utf8"); + + const error = await runExpectingExit(["evaluate", setupPath, "--json"], 2); + const output = JSON.parse(error.stdout); + assert.equal(output.ready, false); + assert.ok(output.missingPermissions.some((item) => item.permission === "checks")); + assert.ok(output.missingEvents.includes("pull_request")); + assert.ok(output.missingEvents.includes("installation")); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("GitHub App setup CLI can enforce least-privilege drift in strict mode", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-app-setup-strict-")); + try { + const setupPath = join(root, "setup.json"); + await writeFile(setupPath, JSON.stringify({ + permissions: { + contents: "write", + checks: "write", + issues: "write", + }, + events: ["installation", "installation_repositories", "pull_request", "push", "issues"], + }), "utf8"); + + const error = await runExpectingExit(["evaluate", setupPath, "--json", "--strict"], 3); + const output = JSON.parse(error.stdout); + assert.equal(output.ready, true); + assert.deepEqual(output.excessiveWritePermissions, ["contents", "issues"]); + assert.deepEqual(output.extraEvents, ["issues"]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("GitHub App setup recovery CLI prints required fixes without mutating configuration", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-app-setup-recover-")); + try { + const setupPath = join(root, "setup.json"); + const original = JSON.stringify({ + permissions: { + contents: "write", + checks: "read", + issues: "write", + }, + events: ["push", "pull_request", "issues"], + }, null, 2); + await writeFile(setupPath, original, "utf8"); + + const error = await runExpectingExit(["recover", setupPath, "--json"], 2); + const output = JSON.parse(error.stdout); + assert.equal(output.ready, false); + assert.ok(output.requiredActions.includes("Upgrade GitHub App permission checks from read to write.")); + assert.ok(output.requiredActions.includes("Subscribe the GitHub App to the installation event.")); + assert.ok(output.leastPrivilegeReview.some((item) => item.includes("contents:write"))); + assert.ok(output.leastPrivilegeReview.some((item) => item.includes("issues event subscription"))); + assert.equal(output.interpretation, "operator-guidance-not-runtime-authorization"); + + const { readFile } = await import("node:fs/promises"); + assert.equal(await readFile(setupPath, "utf8"), original); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("GitHub App setup recovery CLI shares strict least-privilege exit semantics", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-app-setup-recover-strict-")); + try { + const setupPath = join(root, "setup.json"); + await writeFile(setupPath, JSON.stringify({ + permissions: { + contents: "write", + checks: "write", + }, + events: ["installation", "installation_repositories", "pull_request", "push"], + }), "utf8"); + + const error = await runExpectingExit(["recover", setupPath, "--json", "--strict"], 3); + const output = JSON.parse(error.stdout); + assert.equal(output.ready, true); + assert.deepEqual(output.requiredActions, []); + assert.ok(output.leastPrivilegeReview.some((item) => item.includes("contents:write"))); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("GitHub App setup CLI rejects credential-shaped or malformed setup documents by schema", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-app-setup-invalid-")); + try { + const setupPath = join(root, "setup.json"); + await writeFile(setupPath, JSON.stringify({ + permissions: { contents: "admin" }, + events: ["push"], + privateKey: "must-not-be-used", + }), "utf8"); + + const error = await runExpectingExit(["evaluate", setupPath], 1); + assert.match(error.stderr, /permission contents must be read or write/); + assert.doesNotMatch(error.stderr, /must-not-be-used/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("GitHub App shared-state CLI reports exact missing guarantees and exits 2", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-app-shared-state-")); + try { + const path = join(root, "capabilities.json"); + await writeFile(path, JSON.stringify({ + atomicReplayClaim: true, + atomicQueueInsertion: false, + atomicQueueClaimWithFence: true, + compareAndSetLeaseRenewal: false, + fencedQueueTransitions: true, + transactionalInstallationState: true, + sharedAuthorizationState: true, + }), "utf8"); + + const error = await runExpectingExit(["shared-state", path, "--json"], 2); + assert.deepEqual(JSON.parse(error.stdout), { + complete: false, + missing: ["atomicQueueInsertion", "compareAndSetLeaseRenewal"], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("GitHub App shared-state CLI succeeds only when every guarantee is declared true", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-app-shared-state-ready-")); + try { + const path = join(root, "capabilities.json"); + await writeFile(path, JSON.stringify({ + atomicReplayClaim: true, + atomicQueueInsertion: true, + atomicQueueClaimWithFence: true, + compareAndSetLeaseRenewal: true, + fencedQueueTransitions: true, + transactionalInstallationState: true, + sharedAuthorizationState: true, + }), "utf8"); + + const { stdout } = await exec(process.execPath, [cli.pathname, "shared-state", path, "--json"]); + assert.deepEqual(JSON.parse(stdout), { complete: true, missing: [] }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("GitHub App shared-state CLI rejects backend connection details without echoing values", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-app-shared-state-invalid-")); + try { + const path = join(root, "capabilities.json"); + await writeFile(path, JSON.stringify({ + atomicReplayClaim: true, + databaseUrl: "postgres://user:must-not-echo@example.invalid/db", + }), "utf8"); + + const error = await runExpectingExit(["shared-state", path], 1); + assert.match(error.stderr, /unsupported field databaseUrl/); + assert.doesNotMatch(error.stderr, /must-not-echo/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/github-app-credential-reload-cli.test.mjs b/tests/github-app-credential-reload-cli.test.mjs new file mode 100644 index 00000000..d99bb565 --- /dev/null +++ b/tests/github-app-credential-reload-cli.test.mjs @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { execFile } from "node:child_process"; +import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const exec = promisify(execFile); +const cli = new URL("../apps/cli/dist/github-app-credential-reload-cli.js", import.meta.url); + +async function runExpectingExit(args, expectedCode) { + try { + await exec(process.execPath, [cli.pathname, ...args]); + assert.fail(`Expected exit code ${expectedCode}.`); + } catch (error) { + assert.equal(error.code, expectedCode); + return error; + } +} + +test("credential reload CLI reports complete exact deployment membership", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-credential-reload-")); + try { + const path = join(root, "reload.json"); + await writeFile(path, JSON.stringify({ + kind: "webhook-secret", + targetGeneration: "webhook-v3", + expectedReplicaIds: ["synsec-0", "synsec-1"], + replicas: [ + { replicaId: "synsec-0", loadedGeneration: "webhook-v3", ready: true }, + { replicaId: "synsec-1", loadedGeneration: "webhook-v3", ready: true }, + ], + }), "utf8"); + + const { stdout } = await exec(process.execPath, [cli.pathname, path, "--json"]); + const output = JSON.parse(stdout); + assert.equal(output.complete, true); + assert.equal(output.expectedReplicaCount, 2); + assert.equal(output.matchedReplicaCount, 2); + assert.equal(output.missingReplicaCount, 0); + assert.equal(output.unexpectedReplicaCount, 0); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("credential reload CLI exits 2 when equal counts contain the wrong replica", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-credential-reload-membership-")); + try { + const path = join(root, "reload.json"); + await writeFile(path, JSON.stringify({ + kind: "app-private-key", + targetGeneration: "key-v4", + expectedReplicaIds: ["synsec-0", "synsec-1"], + replicas: [ + { replicaId: "synsec-0", loadedGeneration: "key-v4", ready: true }, + { replicaId: "synsec-2", loadedGeneration: "key-v4", ready: true }, + ], + }), "utf8"); + + const error = await runExpectingExit([path, "--json"], 2); + const output = JSON.parse(error.stdout); + assert.equal(output.complete, false); + assert.equal(output.missingReplicaCount, 1); + assert.equal(output.unexpectedReplicaCount, 1); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("credential reload CLI exits 2 for stale replica state", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-credential-reload-stale-")); + try { + const path = join(root, "reload.json"); + await writeFile(path, JSON.stringify({ + kind: "app-private-key", + targetGeneration: "key-v4", + expectedReplicaIds: ["synsec-0", "synsec-1"], + replicas: [ + { replicaId: "synsec-0", loadedGeneration: "key-v4", ready: true }, + { replicaId: "synsec-1", loadedGeneration: "key-v3", ready: true }, + ], + }), "utf8"); + + const error = await runExpectingExit([path, "--json"], 2); + const output = JSON.parse(error.stdout); + assert.equal(output.complete, false); + assert.equal(output.staleReplicaCount, 1); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("credential reload CLI rejects count-only rollout declarations", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-credential-reload-count-only-")); + try { + const path = join(root, "reload.json"); + await writeFile(path, JSON.stringify({ + kind: "webhook-secret", + targetGeneration: "webhook-v3", + expectedReplicaCount: 1, + replicas: [{ replicaId: "synsec-0", loadedGeneration: "webhook-v3", ready: true }], + }), "utf8"); + + const error = await runExpectingExit([path], 1); + assert.match(error.stderr, /unsupported field expectedReplicaCount/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("credential reload CLI rejects credential-bearing fields without echoing values", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-credential-reload-secret-")); + try { + const path = join(root, "reload.json"); + await writeFile(path, JSON.stringify({ + kind: "webhook-secret", + targetGeneration: "webhook-v3", + expectedReplicaIds: ["synsec-0"], + replicas: [], + secret: "do-not-echo-reload-secret", + }), "utf8"); + + const error = await runExpectingExit([path], 1); + assert.match(error.stderr, /unsupported field secret/); + assert.doesNotMatch(error.stderr, /do-not-echo-reload-secret/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("credential reload CLI rejects duplicate expected identities", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-credential-reload-duplicate-")); + try { + const path = join(root, "reload.json"); + await writeFile(path, JSON.stringify({ + kind: "webhook-secret", + targetGeneration: "webhook-v3", + expectedReplicaIds: ["synsec-0", "synsec-0"], + replicas: [], + }), "utf8"); + + const error = await runExpectingExit([path], 1); + assert.match(error.stderr, /expectedReplicaIds must contain unique/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("credential reload CLI rejects symlink inputs without reading targets", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-credential-reload-link-")); + try { + const target = join(root, "target.json"); + const link = join(root, "reload.json"); + await writeFile(target, JSON.stringify({ secret: "target-secret-must-not-leak" }), "utf8"); + await symlink(target, link); + + const error = await runExpectingExit([link], 1); + assert.match(error.stderr, /non-symlink regular file/); + assert.doesNotMatch(error.stderr, /target-secret-must-not-leak/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("credential reload CLI rejects unsupported options without reflecting their values", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-credential-reload-option-")); + try { + const path = join(root, "reload.json"); + await writeFile(path, JSON.stringify({ + kind: "webhook-secret", + targetGeneration: "webhook-v3", + expectedReplicaIds: ["synsec-0"], + replicas: [{ replicaId: "synsec-0", loadedGeneration: "webhook-v3", ready: true }], + }), "utf8"); + + const error = await runExpectingExit([path, "--token=do-not-reflect-this"], 1); + assert.match(error.stderr, /Unsupported credential reload CLI option/); + assert.doesNotMatch(error.stderr, /do-not-reflect-this/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/github-app-credential-reload-freshness.test.mjs b/tests/github-app-credential-reload-freshness.test.mjs new file mode 100644 index 00000000..fec05f9c --- /dev/null +++ b/tests/github-app-credential-reload-freshness.test.mjs @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + assessSynSecGitHubAppFreshCredentialReload, + buildSynSecGitHubAppCredentialRotationWithFreshReloadAssessment, +} from "@synsec/github/credential-reload-freshness"; + +const assessedAt = "2026-08-23T14:30:00.000Z"; + +function freshReplica(replicaId, observedAt = "2026-08-23T14:29:30.000Z") { + return { + replicaId, + loadedGeneration: "webhook-v3", + ready: true, + observedAt, + }; +} + +test("fresh credential reload requires complete recent observations for the exact fleet", () => { + const assessment = assessSynSecGitHubAppFreshCredentialReload({ + kind: "webhook-secret", + targetGeneration: "webhook-v3", + expectedReplicaIds: ["synsec-0", "synsec-1"], + replicas: [freshReplica("synsec-0"), freshReplica("synsec-1")], + assessedAt, + }); + + assert.equal(assessment.reload.complete, true); + assert.equal(assessment.complete, true); + assert.equal(assessment.maxObservationAgeSeconds, 300); + assert.equal(assessment.freshReplicaCount, 2); + assert.equal(assessment.expiredObservationCount, 0); + assert.equal(assessment.futureObservationCount, 0); +}); + +test("expired fleet observations fail closed even when generation and readiness match", () => { + const assessment = assessSynSecGitHubAppFreshCredentialReload({ + kind: "webhook-secret", + targetGeneration: "webhook-v3", + expectedReplicaIds: ["synsec-0", "synsec-1"], + replicas: [ + freshReplica("synsec-0"), + freshReplica("synsec-1", "2026-08-23T14:20:00.000Z"), + ], + assessedAt, + }); + + assert.equal(assessment.reload.complete, true); + assert.equal(assessment.expiredObservationCount, 1); + assert.equal(assessment.complete, false); +}); + +test("observations too far in the future fail closed while small clock skew is tolerated", () => { + const tolerated = assessSynSecGitHubAppFreshCredentialReload({ + kind: "webhook-secret", + targetGeneration: "webhook-v3", + expectedReplicaIds: ["synsec-0"], + replicas: [freshReplica("synsec-0", "2026-08-23T14:30:20.000Z")], + assessedAt, + }); + assert.equal(tolerated.complete, true); + + const rejected = assessSynSecGitHubAppFreshCredentialReload({ + kind: "webhook-secret", + targetGeneration: "webhook-v3", + expectedReplicaIds: ["synsec-0"], + replicas: [freshReplica("synsec-0", "2026-08-23T14:31:00.000Z")], + assessedAt, + }); + assert.equal(rejected.futureObservationCount, 1); + assert.equal(rejected.complete, false); +}); + +test("freshness bounds are explicit and deterministic", () => { + assert.throws(() => assessSynSecGitHubAppFreshCredentialReload({ + kind: "webhook-secret", + targetGeneration: "webhook-v3", + expectedReplicaIds: ["synsec-0"], + replicas: [freshReplica("synsec-0")], + assessedAt, + maxObservationAgeSeconds: 9, + }), /between 10 and 3600/); + + assert.throws(() => assessSynSecGitHubAppFreshCredentialReload({ + kind: "webhook-secret", + targetGeneration: "webhook-v3", + expectedReplicaIds: ["synsec-0"], + replicas: [freshReplica("synsec-0")], + assessedAt, + maxObservationAgeSeconds: 3601, + }), /between 10 and 3600/); +}); + +test("timestamps must be canonical UTC RFC 3339 values", () => { + assert.throws(() => assessSynSecGitHubAppFreshCredentialReload({ + kind: "webhook-secret", + targetGeneration: "webhook-v3", + expectedReplicaIds: ["synsec-0"], + replicas: [freshReplica("synsec-0")], + assessedAt: "2026-08-23T10:30:00-04:00", + }), /canonical UTC/); + + assert.throws(() => assessSynSecGitHubAppFreshCredentialReload({ + kind: "webhook-secret", + targetGeneration: "webhook-v3", + expectedReplicaIds: ["synsec-0"], + replicas: [freshReplica("synsec-0", "not-a-timestamp")], + assessedAt, + }), /RFC 3339/); +}); + +test("rotation cannot retire the previous credential on stale reload evidence", () => { + const assessment = buildSynSecGitHubAppCredentialRotationWithFreshReloadAssessment({ + rotation: { + kind: "webhook-secret", + replacementActivated: true, + externalConfigurationUpdated: true, + verificationSucceeded: true, + }, + reload: { + kind: "webhook-secret", + targetGeneration: "webhook-v3", + expectedReplicaIds: ["synsec-0"], + replicas: [freshReplica("synsec-0", "2026-08-23T14:20:00.000Z")], + assessedAt, + }, + }); + + assert.equal(assessment.reload.reload.complete, true); + assert.equal(assessment.reload.complete, false); + assert.equal(assessment.rotation.readyToRetirePrevious, false); + assert.match(assessment.rotation.requiredActions.join("\n"), /Reload or roll the SynSec runtime/); +}); + +test("rotation retirement can proceed only after fresh fleet reload and external verification", () => { + const assessment = buildSynSecGitHubAppCredentialRotationWithFreshReloadAssessment({ + rotation: { + kind: "webhook-secret", + replacementActivated: true, + externalConfigurationUpdated: true, + verificationSucceeded: true, + }, + reload: { + kind: "webhook-secret", + targetGeneration: "webhook-v3", + expectedReplicaIds: ["synsec-0", "synsec-1"], + replicas: [freshReplica("synsec-0"), freshReplica("synsec-1")], + assessedAt, + }, + }); + + assert.equal(assessment.reload.complete, true); + assert.equal(assessment.rotation.readyToRetirePrevious, true); +}); + +test("fresh rotation composition rejects mismatched credential kinds", () => { + assert.throws(() => buildSynSecGitHubAppCredentialRotationWithFreshReloadAssessment({ + rotation: { kind: "app-private-key" }, + reload: { + kind: "webhook-secret", + targetGeneration: "webhook-v3", + expectedReplicaIds: ["synsec-0"], + replicas: [freshReplica("synsec-0")], + assessedAt, + }, + }), /kinds must match/); +}); diff --git a/tests/github-app-credential-reload.test.mjs b/tests/github-app-credential-reload.test.mjs new file mode 100644 index 00000000..fe94a40c --- /dev/null +++ b/tests/github-app-credential-reload.test.mjs @@ -0,0 +1,178 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + assessSynSecGitHubAppCredentialReload, + buildSynSecGitHubAppCredentialRotationWithReloadAssessment, +} from "@synsec/github/credential-reload"; + +test("credential reload completes only when every specifically expected replica is ready on the target generation", () => { + const assessment = assessSynSecGitHubAppCredentialReload({ + kind: "webhook-secret", + targetGeneration: "webhook-2026-08-23-a", + expectedReplicaIds: ["synsec-0", "synsec-1"], + replicas: [ + { replicaId: "synsec-0", loadedGeneration: "webhook-2026-08-23-a", ready: true }, + { replicaId: "synsec-1", loadedGeneration: "webhook-2026-08-23-a", ready: true }, + ], + }); + + assert.equal(assessment.complete, true); + assert.equal(assessment.expectedReplicaCount, 2); + assert.equal(assessment.matchedReplicaCount, 2); + assert.equal(assessment.staleReplicaCount, 0); + assert.equal(assessment.unreadyReplicaCount, 0); + assert.equal(assessment.missingReplicaCount, 0); + assert.equal(assessment.unexpectedReplicaCount, 0); +}); + +test("credential reload fails closed for missing, stale, unready, or unexpected replicas", () => { + const assessment = assessSynSecGitHubAppCredentialReload({ + kind: "app-private-key", + targetGeneration: "key-v7", + expectedReplicaIds: ["synsec-a", "synsec-b", "synsec-c"], + replicas: [ + { replicaId: "synsec-a", loadedGeneration: "key-v7", ready: true }, + { replicaId: "synsec-b", loadedGeneration: "key-v6", ready: false }, + { replicaId: "synsec-extra", loadedGeneration: "key-v7", ready: true }, + ], + }); + + assert.equal(assessment.complete, false); + assert.equal(assessment.matchedReplicaCount, 1); + assert.equal(assessment.staleReplicaCount, 1); + assert.equal(assessment.unreadyReplicaCount, 1); + assert.equal(assessment.missingReplicaCount, 1); + assert.equal(assessment.unexpectedReplicaCount, 1); +}); + +test("equal replica counts cannot substitute an unexpected replica for a required replica", () => { + const assessment = assessSynSecGitHubAppCredentialReload({ + kind: "webhook-secret", + targetGeneration: "webhook-v2", + expectedReplicaIds: ["synsec-0", "synsec-1"], + replicas: [ + { replicaId: "synsec-0", loadedGeneration: "webhook-v2", ready: true }, + { replicaId: "synsec-2", loadedGeneration: "webhook-v2", ready: true }, + ], + }); + + assert.equal(assessment.observedReplicaCount, assessment.expectedReplicaCount); + assert.equal(assessment.missingReplicaCount, 1); + assert.equal(assessment.unexpectedReplicaCount, 1); + assert.equal(assessment.complete, false); +}); + +test("expected replica membership is set-based rather than order-dependent", () => { + const assessment = assessSynSecGitHubAppCredentialReload({ + kind: "webhook-secret", + targetGeneration: "webhook-v2", + expectedReplicaIds: ["synsec-1", "synsec-0"], + replicas: [ + { replicaId: "synsec-0", loadedGeneration: "webhook-v2", ready: true }, + { replicaId: "synsec-1", loadedGeneration: "webhook-v2", ready: true }, + ], + }); + + assert.equal(assessment.complete, true); +}); + +test("rotation composition derives runtime reload acknowledgement from exact fleet observations", () => { + const incomplete = buildSynSecGitHubAppCredentialRotationWithReloadAssessment({ + rotation: { + kind: "webhook-secret", + replacementActivated: true, + externalConfigurationUpdated: true, + verificationSucceeded: true, + }, + reload: { + kind: "webhook-secret", + targetGeneration: "webhook-v2", + expectedReplicaIds: ["synsec-0", "synsec-1"], + replicas: [ + { replicaId: "synsec-0", loadedGeneration: "webhook-v2", ready: true }, + { replicaId: "synsec-2", loadedGeneration: "webhook-v2", ready: true }, + ], + }, + }); + + assert.equal(incomplete.reload.complete, false); + assert.equal(incomplete.rotation.readyToRetirePrevious, false); + assert.match(incomplete.rotation.requiredActions.join("\n"), /Reload or roll the SynSec runtime/); + + const complete = buildSynSecGitHubAppCredentialRotationWithReloadAssessment({ + rotation: { + kind: "webhook-secret", + replacementActivated: true, + externalConfigurationUpdated: true, + verificationSucceeded: true, + }, + reload: { + kind: "webhook-secret", + targetGeneration: "webhook-v2", + expectedReplicaIds: ["synsec-0", "synsec-1"], + replicas: [ + { replicaId: "synsec-0", loadedGeneration: "webhook-v2", ready: true }, + { replicaId: "synsec-1", loadedGeneration: "webhook-v2", ready: true }, + ], + }, + }); + + assert.equal(complete.reload.complete, true); + assert.equal(complete.rotation.readyToRetirePrevious, true); +}); + +test("rotation composition rejects mismatched credential kinds", () => { + assert.throws(() => buildSynSecGitHubAppCredentialRotationWithReloadAssessment({ + rotation: { kind: "webhook-secret" }, + reload: { + kind: "app-private-key", + targetGeneration: "key-v2", + expectedReplicaIds: ["synsec-0"], + replicas: [{ replicaId: "synsec-0", loadedGeneration: "key-v2", ready: true }], + }, + }), /kinds must match/); +}); + +test("credential reload rejects duplicate expected or observed replica identifiers", () => { + assert.throws(() => assessSynSecGitHubAppCredentialReload({ + kind: "webhook-secret", + targetGeneration: "generation-1", + expectedReplicaIds: ["same", "same"], + replicas: [], + }), /expectedReplicaIds must contain unique/); + + assert.throws(() => assessSynSecGitHubAppCredentialReload({ + kind: "webhook-secret", + targetGeneration: "generation-1", + expectedReplicaIds: ["same", "other"], + replicas: [ + { replicaId: "same", loadedGeneration: "generation-1", ready: true }, + { replicaId: "same", loadedGeneration: "generation-1", ready: true }, + ], + }), /unique replicaId/); +}); + +test("credential reload bounds fleet membership and identifier metadata", () => { + assert.throws(() => assessSynSecGitHubAppCredentialReload({ + kind: "webhook-secret", + targetGeneration: "generation-1", + expectedReplicaIds: [], + replicas: [], + }), /between 1 and 1000/); + + assert.throws(() => assessSynSecGitHubAppCredentialReload({ + kind: "webhook-secret", + targetGeneration: "generation\nAuthorization: Bearer secret", + expectedReplicaIds: ["synsec-0"], + replicas: [], + }), /bounded non-secret identifier/); +}); + +test("credential reload rejects unknown credential kinds", () => { + assert.throws(() => assessSynSecGitHubAppCredentialReload({ + kind: "installation-token", + targetGeneration: "generation-1", + expectedReplicaIds: ["synsec-0"], + replicas: [], + }), /reload kind/); +}); diff --git a/tests/github-app-credential-rotation.test.mjs b/tests/github-app-credential-rotation.test.mjs new file mode 100644 index 00000000..1be42b9e --- /dev/null +++ b/tests/github-app-credential-rotation.test.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildSynSecGitHubAppCredentialRotationPlan } from "@synsec/github/credential-rotation"; + +test("webhook-secret rotation fails closed until GitHub update and authenticated delivery are confirmed", () => { + const plan = buildSynSecGitHubAppCredentialRotationPlan({ + kind: "webhook-secret", + replacementActivated: true, + runtimeReloaded: true, + }); + + assert.equal(plan.readyToRetirePrevious, false); + assert.match(plan.requiredActions.join("\n"), /Update the GitHub webhook secret/); + assert.match(plan.requiredActions.join("\n"), /authenticated GitHub webhook delivery/); + assert.match(plan.requiredActions.at(-1), /Keep the previous webhook secret/); + assert.equal(JSON.stringify(plan).includes("secret-value"), false); +}); + +test("webhook-secret rotation permits retirement only after every acknowledgement", () => { + const plan = buildSynSecGitHubAppCredentialRotationPlan({ + kind: "webhook-secret", + replacementActivated: true, + runtimeReloaded: true, + externalConfigurationUpdated: true, + verificationSucceeded: true, + }); + + assert.equal(plan.readyToRetirePrevious, true); + assert.deepEqual(plan.requiredActions, []); + assert.equal(plan.completedSteps.length, 4); +}); + +test("private-key rotation requires activation, runtime reload, and fresh token exchange", () => { + const incomplete = buildSynSecGitHubAppCredentialRotationPlan({ + kind: "app-private-key", + replacementActivated: true, + runtimeReloaded: true, + }); + assert.equal(incomplete.readyToRetirePrevious, false); + assert.match(incomplete.requiredActions.join("\n"), /installation-token exchange/); + assert.match(incomplete.requiredActions.at(-1), /Keep the previous GitHub App private key active/); + + const complete = buildSynSecGitHubAppCredentialRotationPlan({ + kind: "app-private-key", + replacementActivated: true, + runtimeReloaded: true, + verificationSucceeded: true, + }); + assert.equal(complete.readyToRetirePrevious, true); + assert.deepEqual(complete.requiredActions, []); +}); + +test("rotation planner rejects unknown credential kinds", () => { + assert.throws(() => buildSynSecGitHubAppCredentialRotationPlan({ + kind: "installation-token", + }), /credential rotation kind/); +}); diff --git a/tests/github-app-deployment.test.mjs b/tests/github-app-deployment.test.mjs new file mode 100644 index 00000000..5fda058b --- /dev/null +++ b/tests/github-app-deployment.test.mjs @@ -0,0 +1,191 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + assertGitHubAppDeploymentReady, + validateGitHubAppDeployment, +} from "@synsec/github/app-deployment"; + +const validConfig = { + appId: 12345, + privateKey: "-----BEGIN PRIVATE KEY-----\nZmFrZQ==\n-----END PRIVATE KEY-----", + webhookSecret: "a".repeat(32), + listenHost: "0.0.0.0", + tlsMode: "terminated-upstream", + stateDirectory: "/var/lib/synsec/state", + workspaceDirectory: "/var/lib/synsec/workspaces", + scannerIsolation: { + processBoundary: "container", + cpuLimit: true, + memoryLimit: true, + networkPolicy: "none", + repositoryFilesystem: "read-only", + }, +}; + +test("hosted deployment readiness accepts separated state, TLS termination, and declared scanner isolation", () => { + const result = validateGitHubAppDeployment(validConfig); + assert.equal(result.ready, true); + assert.deepEqual(result.issues, []); + assert.doesNotThrow(() => assertGitHubAppDeploymentReady(validConfig)); +}); + +test("deployment readiness accepts exactly two strong distinct webhook secrets during rotation", () => { + const result = validateGitHubAppDeployment({ + ...validConfig, + webhookSecret: ["n".repeat(32), "o".repeat(32)], + }); + assert.equal(result.ready, true); + assert.deepEqual(result.issues, []); +}); + +test("deployment readiness rejects empty, duplicate, or over-broad webhook secret sets without echoing values", () => { + const marker = "rotation-secret-marker".padEnd(32, "x"); + for (const webhookSecret of [ + [], + [marker, marker], + [marker, "b".repeat(32), "c".repeat(32)], + ]) { + const result = validateGitHubAppDeployment({ ...validConfig, webhookSecret }); + assert.equal(result.ready, false); + assert.ok(result.issues.some((issue) => issue.code === "invalid-webhook-secret-set")); + assert.equal(result.issues.some((issue) => issue.message.includes(marker)), false); + } +}); + +test("scanner isolation is warning-only by default but can be required for production startup", () => { + const { scannerIsolation, ...withoutIsolation } = validConfig; + const advisory = validateGitHubAppDeployment(withoutIsolation); + assert.equal(advisory.ready, true); + assert.deepEqual(advisory.issues.map((issue) => [issue.level, issue.code]), [ + ["warning", "scanner-isolation-missing"], + ]); + + const strict = validateGitHubAppDeployment({ ...withoutIsolation, requireScannerIsolation: true }); + assert.equal(strict.ready, false); + assert.deepEqual(strict.issues.map((issue) => [issue.level, issue.code]), [ + ["error", "scanner-isolation-missing"], + ]); + assert.throws(() => assertGitHubAppDeploymentReady({ ...withoutIsolation, requireScannerIsolation: true }), /scanner-isolation-missing/); +}); + +test("strict scanner isolation rejects host execution, missing resources, host networking, and writable source", () => { + const result = validateGitHubAppDeployment({ + ...validConfig, + requireScannerIsolation: true, + scannerIsolation: { + processBoundary: "host", + cpuLimit: false, + memoryLimit: false, + networkPolicy: "host", + repositoryFilesystem: "writable", + }, + }); + assert.equal(result.ready, false); + assert.deepEqual(new Set(result.issues.map((issue) => issue.code)), new Set([ + "scanner-process-unisolated", + "scanner-resource-limits-missing", + "scanner-network-unrestricted", + "scanner-repository-writable", + ])); + assert.equal(result.issues.every((issue) => issue.level === "error"), true); +}); + +test("egress-filtered sandbox execution satisfies the isolation contract", () => { + const result = validateGitHubAppDeployment({ + ...validConfig, + requireScannerIsolation: true, + scannerIsolation: { + processBoundary: "sandbox", + cpuLimit: true, + memoryLimit: true, + networkPolicy: "egress-filtered", + repositoryFilesystem: "read-only", + }, + }); + assert.equal(result.ready, true); + assert.deepEqual(result.issues, []); +}); + +test("hosted deployment readiness rejects a plaintext non-loopback listener", () => { + const result = validateGitHubAppDeployment({ ...validConfig, tlsMode: "none" }); + assert.equal(result.ready, false); + assert.ok(result.issues.some((issue) => issue.code === "plaintext-public-listener")); +}); + +test("hosted deployment readiness permits plaintext loopback for local development", () => { + const result = validateGitHubAppDeployment({ + ...validConfig, + listenHost: "127.0.0.1", + tlsMode: "none", + }); + assert.equal(result.ready, true); +}); + +test("hosted deployment readiness rejects weak credentials without echoing them", () => { + const privateKey = "not-a-private-key"; + const webhookSecret = "short-secret"; + const result = validateGitHubAppDeployment({ + ...validConfig, + appId: 0, + privateKey, + webhookSecret, + }); + + assert.equal(result.ready, false); + assert.deepEqual( + new Set(result.issues.map((issue) => issue.code)), + new Set(["invalid-app-id", "invalid-private-key", "weak-webhook-secret"]), + ); + assert.ok(result.issues.every((issue) => !issue.message.includes(privateKey))); + assert.ok(result.issues.every((issue) => !issue.message.includes(webhookSecret))); +}); + +test("hosted deployment readiness rejects mismatched PEM framing and wildcard listener values", () => { + const mismatchedPem = validateGitHubAppDeployment({ + ...validConfig, + privateKey: "-----BEGIN RSA PRIVATE KEY-----\nZmFrZQ==\n-----END PRIVATE KEY-----", + }); + assert.equal(mismatchedPem.ready, false); + assert.ok(mismatchedPem.issues.some((issue) => issue.code === "invalid-private-key")); + + const wildcardHost = validateGitHubAppDeployment({ ...validConfig, listenHost: "*" }); + assert.equal(wildcardHost.ready, false); + assert.ok(wildcardHost.issues.some((issue) => issue.code === "invalid-listen-host")); +}); + +test("hosted deployment readiness rejects relative and overlapping runtime directories", () => { + const relative = validateGitHubAppDeployment({ + ...validConfig, + stateDirectory: "./state", + workspaceDirectory: "./workspaces", + }); + assert.equal(relative.ready, false); + assert.ok(relative.issues.some((issue) => issue.code === "relative-state-directory")); + assert.ok(relative.issues.some((issue) => issue.code === "relative-workspace-directory")); + + const nested = validateGitHubAppDeployment({ + ...validConfig, + stateDirectory: "/var/lib/synsec", + workspaceDirectory: "/var/lib/synsec/workspaces", + }); + assert.equal(nested.ready, false); + assert.ok(nested.issues.some((issue) => issue.code === "overlapping-runtime-directories")); +}); + +test("deployment assertion reports only bounded diagnostic codes", () => { + assert.throws( + () => + assertGitHubAppDeploymentReady({ + ...validConfig, + webhookSecret: "must-not-appear", + stateDirectory: "/srv/synsec", + workspaceDirectory: "/srv/synsec/repos", + }), + (error) => { + assert.match(error.message, /weak-webhook-secret/); + assert.match(error.message, /overlapping-runtime-directories/); + assert.doesNotMatch(error.message, /must-not-appear/); + return true; + }, + ); +}); diff --git a/tests/github-app-dispatch.test.mjs b/tests/github-app-dispatch.test.mjs new file mode 100644 index 00000000..70ec167c --- /dev/null +++ b/tests/github-app-dispatch.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { dispatchGitHubAppWebhookScan } from "@synsec/github/app-dispatch"; + +function intake(overrides = {}) { + return { + duplicate: false, + shouldScan: true, + webhook: { + event: "push", + deliveryId: "delivery-1", + installationId: 7, + repository: "example/repo", + headSha: "a".repeat(40), + }, + ...overrides, + }; +} + +test("dispatch requires durable installation authorization before queueing", async () => { + let enqueued = false; + const result = await dispatchGitHubAppWebhookScan({ + intake: intake(), + installationStore: { isRepositoryAllowed: async () => false }, + queue: { enqueue: async () => { enqueued = true; throw new Error("must not enqueue"); } }, + }); + assert.deepEqual(result, { status: "rejected", reason: "installation_not_authorized" }); + assert.equal(enqueued, false); +}); + +test("dispatch creates only normalized commit-pinned push jobs", async () => { + let queued; + const result = await dispatchGitHubAppWebhookScan({ + intake: intake(), + installationStore: { isRepositoryAllowed: async (id, repository) => id === 7 && repository === "example/repo" }, + queue: { enqueue: async (job) => { queued = job; return { ...job, version: 1, jobId: "f".repeat(32), createdAt: "2026-08-22T18:30:00.000Z", attempts: 0, status: "pending" }; } }, + }); + assert.equal(result.status, "queued"); + assert.deepEqual(queued, { + deliveryId: "delivery-1", + installationId: 7, + repository: "example/repo", + headSha: "a".repeat(40), + event: "push", + }); + assert.equal("cloneUrl" in queued, false); + assert.equal("token" in queued, false); +}); + +test("dispatch preserves exact PR base and head provenance", async () => { + let queued; + const prIntake = intake({ + webhook: { + event: "pull_request", + action: "synchronize", + deliveryId: "delivery-pr", + installationId: 8, + repository: "example/repo", + headSha: "b".repeat(40), + baseSha: "c".repeat(40), + pullRequestNumber: 42, + }, + }); + const result = await dispatchGitHubAppWebhookScan({ + intake: prIntake, + installationStore: { isRepositoryAllowed: async () => true }, + queue: { enqueue: async (job) => { queued = job; return { ...job, version: 1, jobId: "e".repeat(32), createdAt: "2026-08-22T18:30:00.000Z", attempts: 0, status: "pending" }; } }, + }); + assert.equal(result.status, "queued"); + assert.equal(queued.headSha, "b".repeat(40)); + assert.equal(queued.baseSha, "c".repeat(40)); + assert.equal(queued.pullRequestNumber, 42); +}); + +test("duplicates and non-scan events never consult authorization or queue", async () => { + for (const current of [intake({ duplicate: true }), intake({ shouldScan: false })]) { + let touched = false; + const result = await dispatchGitHubAppWebhookScan({ + intake: current, + installationStore: { isRepositoryAllowed: async () => { touched = true; return true; } }, + queue: { enqueue: async () => { touched = true; throw new Error("must not queue"); } }, + }); + assert.equal(result.status, "ignored"); + assert.equal(touched, false); + } +}); diff --git a/tests/github-app-drain.test.mjs b/tests/github-app-drain.test.mjs new file mode 100644 index 00000000..de318f7e --- /dev/null +++ b/tests/github-app-drain.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createSynSecGitHubAppDrainController } from "@synsec/github/app-drain"; + +function responseRecorder() { + const headers = new Map(); + return { + statusCode: 200, + body: "", + ended: false, + setHeader(name, value) { headers.set(String(name).toLowerCase(), String(value)); }, + getHeader(name) { return headers.get(String(name).toLowerCase()); }, + end(body = "") { this.body += String(body); this.ended = true; }, + }; +} + +test("drain rejects new webhook admission with retryable aggregate-only response", async () => { + let invoked = 0; + const controller = createSynSecGitHubAppDrainController(async () => { invoked += 1; }); + controller.beginDrain(); + const response = responseRecorder(); + await controller.webhookHandler({}, response); + + assert.equal(invoked, 0); + assert.equal(response.statusCode, 503); + assert.equal(response.getHeader("retry-after"), "1"); + assert.equal(response.getHeader("cache-control"), "no-store"); + assert.equal(response.body, '{"status":"draining"}\n'); + assert.deepEqual(controller.status(), { acceptingWebhooks: false, activeWebhookRequests: 0 }); +}); + +test("request admitted before drain is allowed to finish and waitForDrained observes it", async () => { + let release; + const blocked = new Promise((resolve) => { release = resolve; }); + const controller = createSynSecGitHubAppDrainController(async () => { await blocked; }); + const first = controller.webhookHandler({}, responseRecorder()); + assert.equal(controller.status().activeWebhookRequests, 1); + + controller.beginDrain(); + const rejected = responseRecorder(); + await controller.webhookHandler({}, rejected); + assert.equal(rejected.statusCode, 503); + assert.equal(controller.status().activeWebhookRequests, 1); + + let drained = false; + const waiting = controller.waitForDrained(5_000).then(() => { drained = true; }); + await Promise.resolve(); + assert.equal(drained, false); + release(); + await first; + await waiting; + assert.deepEqual(controller.status(), { acceptingWebhooks: false, activeWebhookRequests: 0 }); +}); + +test("admission can be resumed explicitly after a drain", async () => { + let invoked = 0; + const controller = createSynSecGitHubAppDrainController(async () => { invoked += 1; }); + controller.beginDrain(); + controller.resumeAdmission(); + await controller.webhookHandler({}, responseRecorder()); + assert.equal(invoked, 1); + assert.deepEqual(controller.status(), { acceptingWebhooks: true, activeWebhookRequests: 0 }); +}); + +test("invalid drain timeouts are rejected without invoking the webhook handler", async () => { + let release; + const blocked = new Promise((resolve) => { release = resolve; }); + const controller = createSynSecGitHubAppDrainController(async () => { await blocked; }); + const inFlight = controller.webhookHandler({}, responseRecorder()); + await assert.rejects(controller.waitForDrained(999), /drain timeout/); + release(); + await inFlight; +}); diff --git a/tests/github-app-handler.test.mjs b/tests/github-app-handler.test.mjs new file mode 100644 index 00000000..9da2680a --- /dev/null +++ b/tests/github-app-handler.test.mjs @@ -0,0 +1,237 @@ +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import test from "node:test"; + +import { handleGitHubAppWebhook } from "@synsec/github/app-handler"; + +const secret = "synsec-app-handler-secret"; +const headSha = "0123456789abcdef0123456789abcdef01234567"; +const baseSha = "abcdef0123456789abcdef0123456789abcdef01"; + +function signature(body) { + return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; +} + +class MemoryReplayStore { + constructor() { + this.claims = new Map(); + } + async claim(deliveryId) { + const existing = this.claims.get(deliveryId); + if (existing) return { accepted: false, deliveryId, receivedAt: existing }; + const receivedAt = "2026-08-22T18:40:00.000Z"; + this.claims.set(deliveryId, receivedAt); + return { accepted: true, deliveryId, receivedAt }; + } + async release(deliveryId, receivedAt) { + if (this.claims.get(deliveryId) !== receivedAt) return false; + this.claims.delete(deliveryId); + return true; + } +} + +class MemoryInstallationStore { + constructor() { + this.records = new Map(); + this.putCount = 0; + } + async get(id) { + return this.records.get(id); + } + async put(input) { + this.putCount += 1; + const record = { + version: 1, + installationId: input.installationId, + accountLogin: input.accountLogin, + accountType: input.accountType, + repositorySelection: input.repositorySelection, + repositories: [...(input.repositories ?? [])].sort(), + ...(input.suspendedAt ? { suspendedAt: input.suspendedAt } : {}), + updatedAt: input.updatedAt ?? "2026-08-22T18:40:00.000Z", + }; + this.records.set(record.installationId, record); + return record; + } + async remove(id) { + return this.records.delete(id); + } + async isRepositoryAllowed(id, repository) { + const record = this.records.get(id); + return Boolean(record && !record.suspendedAt && ( + record.repositorySelection === "all" || record.repositories.includes(repository) + )); + } +} + +class MemoryQueue { + constructor() { + this.inputs = []; + } + async enqueue(input) { + this.inputs.push(input); + return { + version: 1, + jobId: "0".repeat(32), + ...input, + createdAt: "2026-08-22T18:40:00.000Z", + attempts: 0, + status: "pending", + }; + } +} + +function pullRequestBody() { + return Buffer.from(JSON.stringify({ + action: "synchronize", + installation: { id: 7 }, + repository: { full_name: "cmahmud/synsec", clone_url: "https://attacker.invalid/repo.git" }, + number: 2, + pull_request: { + head: { sha: headSha }, + base: { sha: baseSha }, + }, + })); +} + +async function authorizedInstallationStore() { + const store = new MemoryInstallationStore(); + await store.put({ + installationId: 7, + accountLogin: "cmahmud", + accountType: "User", + repositorySelection: "selected", + repositories: ["cmahmud/synsec"], + }); + return store; +} + +test("unified handler synchronizes installation state without enqueueing a scan", async () => { + const replayStore = new MemoryReplayStore(); + const installationStore = new MemoryInstallationStore(); + const queue = new MemoryQueue(); + const body = Buffer.from(JSON.stringify({ + action: "created", + installation: { + id: 42, + account: { login: "example-org", type: "Organization" }, + repository_selection: "selected", + suspended_at: null, + }, + repositories: [{ full_name: "example-org/repo" }], + })); + + const result = await handleGitHubAppWebhook({ + body, + signatureHeader: signature(body), + webhookSecret: secret, + eventName: "installation", + deliveryId: "delivery-install-1", + replayStore, + installationStore, + queue, + now: Date.UTC(2026, 7, 22, 18, 40), + }); + + assert.deepEqual(result, { status: "installation_updated", installationId: 42 }); + assert.equal(await installationStore.isRepositoryAllowed(42, "example-org/repo"), true); + assert.equal(queue.inputs.length, 0); +}); + +test("duplicate installation delivery does not mutate authorization state twice", async () => { + const replayStore = new MemoryReplayStore(); + const installationStore = new MemoryInstallationStore(); + const queue = new MemoryQueue(); + const body = Buffer.from(JSON.stringify({ + action: "created", + installation: { + id: 42, + account: { login: "example-org", type: "Organization" }, + repository_selection: "all", + suspended_at: null, + }, + })); + const input = { + body, + signatureHeader: signature(body), + webhookSecret: secret, + eventName: "installation", + deliveryId: "delivery-install-duplicate", + replayStore, + installationStore, + queue, + }; + + assert.equal((await handleGitHubAppWebhook(input)).status, "installation_updated"); + assert.deepEqual(await handleGitHubAppWebhook(input), { status: "ignored", reason: "duplicate" }); + assert.equal(installationStore.putCount, 1); + assert.equal(queue.inputs.length, 0); +}); + +test("authorized pull request delivery queues exact commit provenance", async () => { + const replayStore = new MemoryReplayStore(); + const installationStore = await authorizedInstallationStore(); + const queue = new MemoryQueue(); + const body = pullRequestBody(); + + const result = await handleGitHubAppWebhook({ + body, + signatureHeader: signature(body), + webhookSecret: secret, + eventName: "pull_request", + deliveryId: "delivery-pr-1", + replayStore, + installationStore, + queue, + }); + + assert.equal(result.status, "queued"); + assert.equal(queue.inputs.length, 1); + assert.deepEqual(queue.inputs[0], { + deliveryId: "delivery-pr-1", + installationId: 7, + repository: "cmahmud/synsec", + headSha, + event: "pull_request", + baseSha, + pullRequestNumber: 2, + }); + assert.equal(JSON.stringify(queue.inputs[0]).includes("attacker.invalid"), false); +}); + +test("durable dispatch failure releases the accepted replay claim so GitHub can retry", async () => { + const replayStore = new MemoryReplayStore(); + const installationStore = await authorizedInstallationStore(); + const body = pullRequestBody(); + let attempts = 0; + const queue = { + async enqueue(input) { + attempts += 1; + if (attempts === 1) throw new Error("temporary queue failure"); + return { + version: 1, + jobId: "1".repeat(32), + ...input, + createdAt: "2026-08-22T18:40:00.000Z", + attempts: 0, + status: "pending", + }; + }, + }; + const input = { + body, + signatureHeader: signature(body), + webhookSecret: secret, + eventName: "pull_request", + deliveryId: "delivery-retry-1", + replayStore, + installationStore, + queue, + }; + + await assert.rejects(() => handleGitHubAppWebhook(input), /temporary queue failure/); + assert.equal(replayStore.claims.has("delivery-retry-1"), false); + const retried = await handleGitHubAppWebhook(input); + assert.equal(retried.status, "queued"); + assert.equal(attempts, 2); +}); diff --git a/tests/github-app-host-profile.test.mjs b/tests/github-app-host-profile.test.mjs new file mode 100644 index 00000000..96ae46c0 --- /dev/null +++ b/tests/github-app-host-profile.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseGitHubAppHostProfile } from "@synsec/github/app-host-profile"; + +function profile(overrides = {}) { + return { + releaseId: "synsec-v0.2.0+e634364", + replicaId: "github-app-01", + replicaCount: 3, + appId: 12345, + credentialDirectory: "/run/credentials/synsec-github", + postgresUrlEnvironment: "SYNSEC_POSTGRES_URL", + listenHost: "127.0.0.1", + port: 8787, + tlsMode: "terminated-upstream", + workspaceDirectory: "/var/lib/synsec/workspaces", + scannerRuntimeCommand: "/usr/bin/docker", + scannerImage: `ghcr.io/example/synsec-scanners@sha256:${"a".repeat(64)}`, + operatorStatusPath: "/_synsec/operator/status", + ...overrides, + }; +} + +test("host profile normalizes a complete secret-free production wiring contract", () => { + assert.deepEqual(parseGitHubAppHostProfile(profile()), { + version: 1, + ...profile(), + interpretation: "secret-free-host-wiring-contract-not-runtime-readiness", + }); +}); + +test("host profile rejects unknown or missing fields before they can become a credential side channel", () => { + const marker = "private-key-secret-marker"; + assert.throws( + () => parseGitHubAppHostProfile({ ...profile(), privateKey: marker }), + (error) => { + assert.match(error.message, /exactly the supported non-secret fields/); + assert.doesNotMatch(error.message, new RegExp(marker)); + return true; + }, + ); + const missing = profile(); + delete missing.operatorStatusPath; + assert.throws(() => parseGitHubAppHostProfile(missing), /exactly the supported non-secret fields/); +}); + +test("host profile accepts only an environment-variable name for PostgreSQL connection lookup", () => { + assert.throws( + () => parseGitHubAppHostProfile(profile({ postgresUrlEnvironment: "postgresql://user:secret@db/synsec" })), + /environment-variable name/, + ); + assert.throws(() => parseGitHubAppHostProfile(profile({ postgresUrlEnvironment: "synsec_postgres_url" })), /environment-variable name/); +}); + +test("host profile requires immutable scanner images and one runtime command token", () => { + assert.throws(() => parseGitHubAppHostProfile(profile({ scannerImage: "ghcr.io/example/scanner:latest" })), /immutable sha256/); + assert.throws(() => parseGitHubAppHostProfile(profile({ scannerRuntimeCommand: "docker --host tcp://evil" })), /one bounded command token/); +}); + +test("host profile keeps mounted credentials separate from untrusted repository workspaces", () => { + assert.throws( + () => parseGitHubAppHostProfile(profile({ + credentialDirectory: "/var/lib/synsec", + workspaceDirectory: "/var/lib/synsec/workspaces", + })), + /separate, non-nested trees/, + ); +}); + +test("host profile rejects plaintext production TLS mode, invalid listeners, ports, and replica counts", () => { + assert.throws(() => parseGitHubAppHostProfile(profile({ tlsMode: "none" })), /TLS mode/); + assert.throws(() => parseGitHubAppHostProfile(profile({ listenHost: "*" })), /listener/); + assert.throws(() => parseGitHubAppHostProfile(profile({ port: 70000 })), /listener port/); + assert.throws(() => parseGitHubAppHostProfile(profile({ replicaCount: 0 })), /replica count/); +}); + +test("host profile output never contains inline credential or database values", () => { + const parsed = parseGitHubAppHostProfile(profile()); + const serialized = JSON.stringify(parsed); + assert.doesNotMatch(serialized, /BEGIN PRIVATE KEY|webhook-secret|postgresql:\/\//i); + assert.equal(parsed.postgresUrlEnvironment, "SYNSEC_POSTGRES_URL"); +}); diff --git a/tests/github-app-http.test.mjs b/tests/github-app-http.test.mjs new file mode 100644 index 00000000..80963ce8 --- /dev/null +++ b/tests/github-app-http.test.mjs @@ -0,0 +1,172 @@ +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import test from "node:test"; + +import { createGitHubAppWebhookHttpHandler } from "@synsec/github/app-http"; + +const secret = "synsec-http-webhook-secret"; +const headSha = "0123456789abcdef0123456789abcdef01234567"; +const baseSha = "abcdef0123456789abcdef0123456789abcdef01"; + +function signature(body) { + return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; +} + +function pullRequestBody() { + return Buffer.from(JSON.stringify({ + action: "synchronize", + installation: { id: 7 }, + repository: { full_name: "cmahmud/synsec", clone_url: "https://attacker.invalid/repo.git" }, + number: 2, + pull_request: { head: { sha: headSha }, base: { sha: baseSha } }, + })); +} + +class ReplayStore { + constructor() { this.claims = new Map(); } + async claim(deliveryId) { + const existing = this.claims.get(deliveryId); + if (existing) return { accepted: false, deliveryId, receivedAt: existing }; + const receivedAt = "2026-08-22T19:10:00.000Z"; + this.claims.set(deliveryId, receivedAt); + return { accepted: true, deliveryId, receivedAt }; + } + async release(deliveryId, receivedAt) { + if (this.claims.get(deliveryId) !== receivedAt) return false; + this.claims.delete(deliveryId); + return true; + } +} + +class InstallationStore { + async get() { return undefined; } + async put(input) { return { version: 1, repositories: [], updatedAt: new Date().toISOString(), ...input }; } + async remove() { return false; } + async isRepositoryAllowed(id, repository) { return id === 7 && repository === "cmahmud/synsec"; } +} + +function request(body, overrides = {}) { + const headers = { + "content-type": "application/json", + "content-length": String(body.byteLength), + "x-hub-signature-256": signature(body), + "x-github-event": "pull_request", + "x-github-delivery": "delivery-http-1", + ...(overrides.headers ?? {}), + }; + return { + url: overrides.url ?? "/github/webhooks", + method: overrides.method ?? "POST", + headers, + async *[Symbol.asyncIterator]() { + if (body.byteLength > 0) yield body; + }, + }; +} + +function response() { + return { + statusCode: 0, + headers: new Map(), + body: "", + setHeader(name, value) { this.headers.set(String(name).toLowerCase(), value); }, + end(body = "") { this.body = String(body); }, + }; +} + +test("HTTP webhook endpoint queues an authorized commit-pinned delivery with a minimal response", async () => { + const replayStore = new ReplayStore(); + const queued = []; + const handler = createGitHubAppWebhookHttpHandler({ + webhookSecret: secret, + replayStore, + installationStore: new InstallationStore(), + queue: { + async enqueue(input) { + queued.push(input); + return { version: 1, jobId: "c".repeat(32), ...input, createdAt: new Date().toISOString(), attempts: 0, status: "pending" }; + }, + }, + }); + const body = pullRequestBody(); + const res = response(); + await handler(request(body), res); + + assert.equal(res.statusCode, 202); + assert.deepEqual(JSON.parse(res.body), { status: "queued" }); + assert.equal(res.headers.get("cache-control"), "no-store"); + assert.equal(queued.length, 1); + assert.equal(queued[0].headSha, headSha); + assert.equal(JSON.stringify(queued[0]).includes("attacker.invalid"), false); +}); + +test("HTTP webhook endpoint rejects wrong methods, media types, missing headers, and oversized bodies before durable processing", async () => { + let claims = 0; + const replayStore = new ReplayStore(); + replayStore.claim = async (...args) => { claims += 1; return ReplayStore.prototype.claim.apply(replayStore, args); }; + const handler = createGitHubAppWebhookHttpHandler({ + webhookSecret: secret, + replayStore, + installationStore: new InstallationStore(), + queue: { async enqueue() { throw new Error("must not enqueue"); } }, + }); + const body = pullRequestBody(); + + const methodRes = response(); + await handler(request(body, { method: "GET" }), methodRes); + assert.equal(methodRes.statusCode, 405); + + const mediaRes = response(); + await handler(request(body, { headers: { "content-type": "text/plain" } }), mediaRes); + assert.equal(mediaRes.statusCode, 415); + + const missingRes = response(); + await handler(request(body, { headers: { "x-github-delivery": "" } }), missingRes); + assert.equal(missingRes.statusCode, 400); + + const largeRes = response(); + await handler(request(Buffer.alloc(0), { headers: { "content-length": String(10 * 1024 * 1024 + 1) } }), largeRes); + assert.equal(largeRes.statusCode, 413); + assert.equal(claims, 0); +}); + +test("HTTP webhook endpoint hides durable failure details, redacts operator callbacks, and leaves the delivery retryable", async () => { + const replayStore = new ReplayStore(); + const errors = []; + let attempts = 0; + const githubToken = `ghp_${"a".repeat(36)}`; + const credentialUrl = "postgres://db-user:queue-password@db.internal/synsec"; + const handler = createGitHubAppWebhookHttpHandler({ + webhookSecret: secret, + replayStore, + installationStore: new InstallationStore(), + queue: { + async enqueue(input) { + attempts += 1; + if (attempts === 1) throw new Error(`queue unavailable token=${githubToken} backend=${credentialUrl}`); + return { version: 1, jobId: "d".repeat(32), ...input, createdAt: new Date().toISOString(), attempts: 0, status: "pending" }; + }, + }, + onError: (error) => errors.push(error), + }); + const body = pullRequestBody(); + const req = request(body, { headers: { "x-github-delivery": "delivery-http-retry" } }); + + const first = response(); + await handler(req, first); + assert.equal(first.statusCode, 500); + assert.deepEqual(JSON.parse(first.body), { status: "error" }); + assert.equal(first.body.includes(githubToken), false); + assert.equal(first.body.includes("queue-password"), false); + assert.equal(replayStore.claims.has("delivery-http-retry"), false); + assert.equal(errors.length, 1); + const callbackMessage = errors[0] instanceof Error ? errors[0].message : String(errors[0]); + assert.equal(callbackMessage.includes(githubToken), false); + assert.equal(callbackMessage.includes("queue-password"), false); + assert.match(callbackMessage, /queue unavailable/); + + const second = response(); + await handler(request(body, { headers: { "x-github-delivery": "delivery-http-retry" } }), second); + assert.equal(second.statusCode, 202); + assert.equal(attempts, 2); +}); diff --git a/tests/github-app-intake-host.test.mjs b/tests/github-app-intake-host.test.mjs new file mode 100644 index 00000000..6ce57c12 --- /dev/null +++ b/tests/github-app-intake-host.test.mjs @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createSynSecGitHubAppIntakeHost } from "@synsec/github/app-intake-host"; + +const scenarioIds = [ + "replay.concurrent-duplicate-claim", + "queue.concurrent-idempotent-insert", + "queue.concurrent-claim-fence", + "queue.stale-fence-renewal", + "queue.stale-fence-terminal-transitions", + "installation.concurrent-selection-mutation", + "authorization.cross-replica-revocation", +]; + +function profile(overrides = {}) { + return { + releaseId: "v0.2.0-test", + replicaId: "intake-a", + replicaCount: 2, + appId: 1234, + credentialDirectory: "/run/secrets/synsec/github", + postgresUrlEnvironment: "SYNSEC_POSTGRES_URL", + listenHost: "127.0.0.1", + port: 32123, + tlsMode: "terminated-upstream", + workspaceDirectory: "/var/lib/synsec/workspaces", + scannerRuntimeCommand: "docker", + scannerImage: `example.invalid/synsec-scanner@sha256:${"a".repeat(64)}`, + operatorStatusPath: "/operator/status", + ...overrides, + }; +} + +function report() { + return { + schemaVersion: 1, + backendId: "postgres-v1", + implementationVersion: "0.2.0-postgres-v1", + complete: true, + scenarioTimeoutMs: 10_000, + results: scenarioIds.map((id) => ({ id, status: "passed", durationMs: 1 })), + coverage: { + complete: true, + coveredScenarioIds: [...scenarioIds], + missingScenarioIds: [], + missingCapabilities: [], + }, + }; +} + +function snapshot(generation = "generation-1") { + return { + generation, + privateKey: "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n", + webhookSecret: "s".repeat(32), + }; +} + +function fakePool() { + let connects = 0; + const client = { + async query(text) { + if (text.includes("SELECT version FROM synsec_github_schema")) return { rows: [] }; + return { rows: [] }; + }, + release() {}, + }; + return { + get connects() { return connects; }, + async connect() { + connects += 1; + return client; + }, + async query() { + return { rows: [] }; + }, + }; +} + +test("intake host rejects detached conformance evidence before credential or database access", async () => { + const pool = fakePool(); + let credentialLoads = 0; + await assert.rejects( + createSynSecGitHubAppIntakeHost({ + profile: profile(), + pool, + conformanceReport: { schemaVersion: 1 }, + async loadCredentials() { + credentialLoads += 1; + return snapshot(); + }, + }), + /shared-state evidence is not ready/, + ); + assert.equal(credentialLoads, 0); + assert.equal(pool.connects, 0); +}); + +test("intake host composes validated PostgreSQL intake with memory-only credential generations", async () => { + const pool = fakePool(); + let generation = 1; + const host = await createSynSecGitHubAppIntakeHost({ + profile: profile(), + pool, + conformanceReport: report(), + async loadCredentials() { + return snapshot(`generation-${generation++}`); + }, + }); + + assert.equal(pool.connects, 1); + assert.equal(host.profile.replicaId, "intake-a"); + assert.equal(host.interpretation, "executable-intake-host-boundary-not-worker-or-fleet-readiness"); + assert.equal(host.drain.status().acceptingWebhooks, true); + assert.deepEqual(host.credentialStatus(), { + version: 1, + generation: "generation-1", + webhookSecretCount: 1, + reloadCount: 0, + interpretation: "memory-only-runtime-credential-generation", + }); + + const reloaded = await host.reloadCredentials(); + assert.equal(reloaded.generation, "generation-2"); + assert.equal(reloaded.reloadCount, 1); + assert.equal(host.credentialStatus().generation, "generation-2"); +}); + +test("intake host validates TLS ownership before loading credentials or migrating", async () => { + const pool = fakePool(); + let credentialLoads = 0; + await assert.rejects( + createSynSecGitHubAppIntakeHost({ + profile: profile({ tlsMode: "local" }), + pool, + conformanceReport: report(), + async loadCredentials() { + credentialLoads += 1; + return snapshot(); + }, + }), + /local TLS requires caller-owned key and certificate material/, + ); + assert.equal(credentialLoads, 0); + assert.equal(pool.connects, 0); +}); + +test("intake host rejects caller TLS material when TLS terminates upstream", async () => { + const pool = fakePool(); + let credentialLoads = 0; + await assert.rejects( + createSynSecGitHubAppIntakeHost({ + profile: profile(), + pool, + conformanceReport: report(), + tls: { key: "key", cert: "cert" }, + async loadCredentials() { + credentialLoads += 1; + return snapshot(); + }, + }), + /TLS material is accepted only for local TLS mode/, + ); + assert.equal(credentialLoads, 0); + assert.equal(pool.connects, 0); +}); diff --git a/tests/github-app-intake.test.mjs b/tests/github-app-intake.test.mjs new file mode 100644 index 00000000..b6f4b298 --- /dev/null +++ b/tests/github-app-intake.test.mjs @@ -0,0 +1,140 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; + +import { intakeGitHubAppWebhook } from "../packages/github/dist/app-intake.js"; + +const webhookSecret = "synsec-webhook-secret"; + +function signature(body) { + return `sha256=${createHmac("sha256", webhookSecret).update(body).digest("hex")}`; +} + +function pullRequestBody(action = "synchronize") { + return Buffer.from(JSON.stringify({ + action, + installation: { id: 42 }, + repository: { full_name: "cmahmud/synsec" }, + number: 7, + pull_request: { + head: { sha: "abc123" }, + base: { sha: "def456" }, + }, + })); +} + +test("app intake verifies before touching replay state", async () => { + let claims = 0; + const replayStore = { + async claim(deliveryId) { + claims += 1; + return { accepted: true, deliveryId, receivedAt: new Date().toISOString() }; + }, + }; + const body = pullRequestBody(); + + await assert.rejects(() => intakeGitHubAppWebhook({ + body, + signatureHeader: "sha256=0000000000000000000000000000000000000000000000000000000000000000", + webhookSecret, + eventName: "pull_request", + deliveryId: "delivery-1", + replayStore, + }), /signature verification failed/); + assert.equal(claims, 0); +}); + +test("app intake makes the first scannable delivery eligible and suppresses duplicates", async () => { + const seen = new Set(); + const replayStore = { + async claim(deliveryId) { + const accepted = !seen.has(deliveryId); + seen.add(deliveryId); + return { accepted, deliveryId, receivedAt: "2026-08-22T17:00:00.000Z" }; + }, + }; + const body = pullRequestBody(); + const input = { + body, + signatureHeader: signature(body), + webhookSecret, + eventName: "pull_request", + deliveryId: "delivery-1", + replayStore, + }; + + const first = await intakeGitHubAppWebhook(input); + assert.equal(first.duplicate, false); + assert.equal(first.shouldScan, true); + assert.equal(first.webhook.deliveryId, "delivery-1"); + + const duplicate = await intakeGitHubAppWebhook(input); + assert.equal(duplicate.duplicate, true); + assert.equal(duplicate.shouldScan, false); +}); + +test("app intake deduplicates supported bookkeeping events without scanning them", async () => { + const body = Buffer.from(JSON.stringify({ action: "created", installation: { id: 42 } })); + const replayStore = { + async claim(deliveryId) { + return { accepted: true, deliveryId, receivedAt: "2026-08-22T17:00:00.000Z" }; + }, + }; + + const result = await intakeGitHubAppWebhook({ + body, + signatureHeader: signature(body), + webhookSecret, + eventName: "installation", + deliveryId: "delivery-installation", + replayStore, + }); + assert.equal(result.duplicate, false); + assert.equal(result.shouldScan, false); +}); + +test("app intake fails closed if replay storage returns a different delivery identity", async () => { + const body = pullRequestBody(); + const replayStore = { + async claim() { + return { accepted: true, deliveryId: "wrong-delivery", receivedAt: "2026-08-22T17:00:00.000Z" }; + }, + }; + + await assert.rejects(() => intakeGitHubAppWebhook({ + body, + signatureHeader: signature(body), + webhookSecret, + eventName: "pull_request", + deliveryId: "delivery-1", + replayStore, + }), /mismatched delivery id/); +}); + +test("app intake redacts credentials from replay backend failures", async () => { + const body = pullRequestBody(); + const githubToken = `ghp_${"b".repeat(36)}`; + const replayStore = { + async claim() { + throw new Error(`replay database failed token=${githubToken} url=postgres://sync:db-password@db.internal/synsec`); + }, + }; + + let failure; + try { + await intakeGitHubAppWebhook({ + body, + signatureHeader: signature(body), + webhookSecret, + eventName: "pull_request", + deliveryId: "delivery-redaction", + replayStore, + }); + } catch (error) { + failure = error; + } + assert.ok(failure instanceof Error); + assert.match(failure.message, /replay database failed/); + assert.equal(failure.message.includes(githubToken), false); + assert.equal(failure.message.includes("db-password"), false); +}); diff --git a/tests/github-app-local-runtime-replica.test.mjs b/tests/github-app-local-runtime-replica.test.mjs new file mode 100644 index 00000000..88aeba3c --- /dev/null +++ b/tests/github-app-local-runtime-replica.test.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { generateKeyPairSync } from "node:crypto"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { createLocalGitHubAppRuntime } from "@synsec/github/app-runtime"; + +function privateKeyPem() { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + return privateKey.export({ type: "pkcs8", format: "pem" }); +} + +async function runtimeOptions(replicaCount) { + const root = await mkdtemp(join(tmpdir(), "synsec-local-replica-")); + return { + stateDirectory: join(root, "state"), + workspaceRoot: join(root, "workspaces"), + webhookSecret: "runtime-webhook-secret", + appId: 12345, + privateKey: privateKeyPem(), + config: { scanners: ["opengrep"], parallelism: 1 }, + ...(replicaCount !== undefined ? { replicaCount } : {}), + }; +} + +test("local filesystem runtime accepts omitted or explicit single-replica cardinality", async () => { + for (const replicaCount of [undefined, 1]) { + const runtime = await createLocalGitHubAppRuntime(await runtimeOptions(replicaCount)); + assert.deepEqual(await runtime.runWorkerOnce(), { status: "idle" }); + } +}); + +test("local filesystem runtime rejects horizontal replica declarations before creating state", async () => { + for (const replicaCount of [0, 2, 10, 1.5, Number.NaN]) { + const options = await runtimeOptions(replicaCount); + await assert.rejects( + () => createLocalGitHubAppRuntime(options), + /supports exactly one application replica/, + String(replicaCount), + ); + } +}); diff --git a/tests/github-app-maintenance.test.mjs b/tests/github-app-maintenance.test.mjs new file mode 100644 index 00000000..140e8fab --- /dev/null +++ b/tests/github-app-maintenance.test.mjs @@ -0,0 +1,162 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createSynSecGitHubAppMaintenanceController } from "@synsec/github/app-maintenance"; + +function webhookDrain(overrides = {}) { + let acceptingWebhooks = true; + let activeWebhookRequests = 0; + return { + webhookHandler: async () => {}, + beginDrain() { + acceptingWebhooks = false; + return this.status(); + }, + resumeAdmission() { + acceptingWebhooks = true; + return this.status(); + }, + status() { + return { acceptingWebhooks, activeWebhookRequests }; + }, + async waitForDrained() { + activeWebhookRequests = 0; + }, + ...overrides, + }; +} + +function workerDrain(overrides = {}) { + let acceptingWorkerRuns = true; + let activeWorkerRuns = 0; + return { + beginDrain() { + acceptingWorkerRuns = false; + return this.status(); + }, + resumeAdmission() { + acceptingWorkerRuns = true; + return this.status(); + }, + status() { + return { acceptingWorkerRuns, activeWorkerRuns }; + }, + async run(operation) { + if (!acceptingWorkerRuns) return { admitted: false }; + activeWorkerRuns += 1; + try { + return { admitted: true, value: await operation() }; + } finally { + activeWorkerRuns -= 1; + } + }, + async waitForDrained() { + activeWorkerRuns = 0; + }, + ...overrides, + }; +} + +test("maintenance closes both admissions and waits for durable fenced leases before stop eligibility", async () => { + const webhooks = webhookDrain(); + const workers = workerDrain(); + const observations = [2, 1, 0]; + let observationCount = 0; + const controller = createSynSecGitHubAppMaintenanceController({ + webhookDrain: webhooks, + workerDrain: workers, + pollIntervalMs: 10, + async countActiveLeases() { + observationCount += 1; + assert.equal(webhooks.status().acceptingWebhooks, false); + assert.equal(workers.status().acceptingWorkerRuns, false); + return observations.shift() ?? 0; + }, + }); + + const evidence = await controller.prepareForServiceStop(1_000); + assert.equal(observationCount, 3); + assert.deepEqual(evidence, { + webhookAdmissionClosed: true, + workerAdmissionClosed: true, + localWebhookRequests: 0, + localWorkerRuns: 0, + activeLeases: 0, + }); + assert.deepEqual(controller.status(), { + acceptingWebhooks: false, + acceptingWorkerRuns: false, + activeWebhookRequests: 0, + activeWorkerRuns: 0, + }); +}); + +test("maintenance fails closed and suppresses durable-backend diagnostic disclosure", async () => { + const secretDiagnostic = "postgresql://synsec:super-secret@example.internal/customer"; + const controller = createSynSecGitHubAppMaintenanceController({ + webhookDrain: webhookDrain(), + workerDrain: workerDrain(), + async countActiveLeases() { + throw new Error(secretDiagnostic); + }, + }); + + await assert.rejects( + controller.prepareForServiceStop(1_000), + (error) => { + assert.match(error.message, /durable active-lease observation failed/i); + assert.doesNotMatch(error.message, /super-secret|example\.internal|customer/); + return true; + }, + ); + assert.equal(controller.status().acceptingWebhooks, false); + assert.equal(controller.status().acceptingWorkerRuns, false); +}); + +test("maintenance rejects malformed durable lease observations instead of treating them as drain proof", async () => { + for (const invalid of [-1, 1.5, Number.NaN, 1_000_001]) { + const controller = createSynSecGitHubAppMaintenanceController({ + webhookDrain: webhookDrain(), + workerDrain: workerDrain(), + async countActiveLeases() { + return invalid; + }, + }); + await assert.rejects(controller.prepareForServiceStop(1_000), /active-lease observation failed/i); + } +}); + +test("maintenance can explicitly resume both admission boundaries after an aborted service operation", () => { + const controller = createSynSecGitHubAppMaintenanceController({ + webhookDrain: webhookDrain(), + workerDrain: workerDrain(), + async countActiveLeases() { + return 0; + }, + }); + + controller.beginDrain(); + assert.equal(controller.status().acceptingWebhooks, false); + assert.equal(controller.status().acceptingWorkerRuns, false); + const resumed = controller.resumeAdmission(); + assert.equal(resumed.acceptingWebhooks, true); + assert.equal(resumed.acceptingWorkerRuns, true); +}); + +test("maintenance validates polling and timeout bounds before relying on operator configuration", async () => { + assert.throws( + () => createSynSecGitHubAppMaintenanceController({ + webhookDrain: webhookDrain(), + workerDrain: workerDrain(), + pollIntervalMs: 1, + async countActiveLeases() { return 0; }, + }), + /poll interval/i, + ); + + const controller = createSynSecGitHubAppMaintenanceController({ + webhookDrain: webhookDrain(), + workerDrain: workerDrain(), + async countActiveLeases() { return 0; }, + }); + await assert.rejects(controller.prepareForServiceStop(1), /maintenance timeout/i); +}); diff --git a/tests/github-app-mounted-runtime-credentials.test.mjs b/tests/github-app-mounted-runtime-credentials.test.mjs new file mode 100644 index 00000000..e66a1365 --- /dev/null +++ b/tests/github-app-mounted-runtime-credentials.test.mjs @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { loadMountedGitHubAppRuntimeCredentialSnapshot } from "@synsec/github/mounted-runtime-credentials"; +import { createGitHubAppRuntimeCredentialSource } from "@synsec/github/runtime-credentials"; + +const PRIVATE_KEY_A = "-----BEGIN PRIVATE KEY-----\nalpha\n-----END PRIVATE KEY-----\n"; +const PRIVATE_KEY_B = "-----BEGIN PRIVATE KEY-----\nbeta\n-----END PRIVATE KEY-----\n"; +const SECRET_A = "a".repeat(32); +const SECRET_B = "b".repeat(32); +const SECRET_C = "c".repeat(32); + +async function makeMount() { + const root = await mkdtemp(join(tmpdir(), "synsec-mounted-github-credentials-")); + return { root, cleanup: () => rm(root, { recursive: true, force: true }) }; +} + +async function writeSnapshot(root, { generation, privateKey, secret, previousSecret }) { + await writeFile(join(root, "generation"), `${generation}\n`, "utf8"); + await writeFile(join(root, "private-key.pem"), privateKey, "utf8"); + await writeFile(join(root, "webhook-secret"), `${secret}\n`, "utf8"); + if (previousSecret !== undefined) { + await writeFile(join(root, "webhook-secret-previous"), `${previousSecret}\n`, "utf8"); + } +} + +test("mounted credential source reads fixed bounded files with optional rotation overlap", async () => { + const mount = await makeMount(); + try { + await writeSnapshot(mount.root, { + generation: "gen-1", + privateKey: PRIVATE_KEY_A, + secret: SECRET_A, + previousSecret: SECRET_B, + }); + const snapshot = await loadMountedGitHubAppRuntimeCredentialSnapshot(mount.root); + assert.equal(snapshot.generation, "gen-1"); + assert.equal(snapshot.privateKey, PRIVATE_KEY_A); + assert.deepEqual(snapshot.webhookSecret, [SECRET_A, SECRET_B]); + } finally { + await mount.cleanup(); + } +}); + +test("mounted credential source supports a single active webhook secret", async () => { + const mount = await makeMount(); + try { + await writeSnapshot(mount.root, { + generation: "gen-single", + privateKey: PRIVATE_KEY_A, + secret: SECRET_A, + }); + const snapshot = await loadMountedGitHubAppRuntimeCredentialSnapshot(mount.root); + assert.equal(snapshot.webhookSecret, SECRET_A); + } finally { + await mount.cleanup(); + } +}); + +test("mounted credential reload swaps atomically and failed mounted validation preserves the active generation", async () => { + const mount = await makeMount(); + try { + await writeSnapshot(mount.root, { + generation: "gen-1", + privateKey: PRIVATE_KEY_A, + secret: SECRET_A, + }); + const source = createGitHubAppRuntimeCredentialSource( + await loadMountedGitHubAppRuntimeCredentialSnapshot(mount.root), + ); + + await writeSnapshot(mount.root, { + generation: "gen-2", + privateKey: PRIVATE_KEY_B, + secret: SECRET_B, + previousSecret: SECRET_A, + }); + const status = await source.reload(() => loadMountedGitHubAppRuntimeCredentialSnapshot(mount.root)); + assert.equal(status.generation, "gen-2"); + assert.equal(status.reloadCount, 1); + assert.equal(source.getPrivateKey(), PRIVATE_KEY_B); + assert.deepEqual(source.getWebhookSecret(), [SECRET_B, SECRET_A]); + + await writeFile(join(mount.root, "webhook-secret"), "too-short\n", "utf8"); + await assert.rejects( + source.reload(() => loadMountedGitHubAppRuntimeCredentialSnapshot(mount.root)), + /webhook secret must contain between 32 and 4096 bytes/, + ); + assert.equal(source.getStatus().generation, "gen-2"); + assert.equal(source.getStatus().reloadCount, 1); + assert.equal(source.getPrivateKey(), PRIVATE_KEY_B); + } finally { + await mount.cleanup(); + } +}); + +test("mounted credential source rejects relative directories, symlinks, and oversized files", async () => { + await assert.rejects( + loadMountedGitHubAppRuntimeCredentialSnapshot("relative/secrets"), + /must be an absolute path/, + ); + + const mount = await makeMount(); + const outside = await makeMount(); + try { + await writeSnapshot(mount.root, { + generation: "gen-1", + privateKey: PRIVATE_KEY_A, + secret: SECRET_A, + }); + await writeFile(join(outside.root, "secret"), SECRET_C, "utf8"); + await rm(join(mount.root, "webhook-secret")); + await symlink(join(outside.root, "secret"), join(mount.root, "webhook-secret")); + await assert.rejects( + loadMountedGitHubAppRuntimeCredentialSnapshot(mount.root), + /regular non-symlink files/, + ); + + await rm(join(mount.root, "webhook-secret")); + await writeFile(join(mount.root, "webhook-secret"), "x".repeat(4097), "utf8"); + await assert.rejects( + loadMountedGitHubAppRuntimeCredentialSnapshot(mount.root), + /violates its byte bound/, + ); + } finally { + await mount.cleanup(); + await outside.cleanup(); + } +}); + +test("mounted credential source errors do not reflect mount paths or credential contents", async () => { + const mount = await makeMount(); + const marker = "super-secret-value-that-must-not-leak"; + try { + await writeFile(join(mount.root, "generation"), "gen-1\n", "utf8"); + await writeFile(join(mount.root, "private-key.pem"), PRIVATE_KEY_A, "utf8"); + await writeFile(join(mount.root, "webhook-secret"), marker.repeat(200), "utf8"); + try { + await loadMountedGitHubAppRuntimeCredentialSnapshot(mount.root); + assert.fail("expected mounted credential load to fail"); + } catch (error) { + const message = String(error?.message ?? error); + assert.doesNotMatch(message, new RegExp(marker)); + assert.doesNotMatch(message, new RegExp(mount.root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + } + } finally { + await mount.cleanup(); + } +}); diff --git a/tests/github-app-operator-status.test.mjs b/tests/github-app-operator-status.test.mjs new file mode 100644 index 00000000..2acaed58 --- /dev/null +++ b/tests/github-app-operator-status.test.mjs @@ -0,0 +1,143 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildGitHubAppOperatorStatusSnapshot, + createGitHubAppOperatorStatusHttpHandler, +} from "@synsec/github/app-operator-status"; + +function observation(overrides = {}) { + return { + releaseId: "synsec-v0.2.0+9435694", + schemaVersion: 1, + ready: true, + credentialStatus: { + version: 1, + generation: "vault:2026-08-25T14:00Z", + webhookSecretCount: 2, + reloadCount: 4, + interpretation: "memory-only-runtime-credential-generation", + }, + webhookAdmission: "open", + workerAdmission: "open", + activeWebhookRequests: 2, + activeWorkerRuns: 1, + durableActiveLeases: 3, + recoveryPhase: "idle", + observedAt: "2026-08-25T14:00:00.000Z", + ...overrides, + }; +} + +function responseRecorder() { + const headers = new Map(); + let body = ""; + const response = { + statusCode: 0, + setHeader(name, value) { headers.set(String(name).toLowerCase(), String(value)); }, + end(value = "") { body = String(value); }, + }; + return { response, headers, getBody: () => body }; +} + +test("operator snapshot exposes only fixed aggregate fields", () => { + const snapshot = buildGitHubAppOperatorStatusSnapshot({ + ...observation(), + backendUrl: "postgresql://secret@example/db", + tenantId: "tenant-secret-marker", + privateKey: "private-key-marker", + }); + assert.deepEqual(snapshot, { + version: 1, + release: { id: "synsec-v0.2.0+9435694", schemaVersion: 1 }, + ready: true, + credentials: { generation: "vault:2026-08-25T14:00Z", webhookSecretCount: 2, reloadCount: 4 }, + admission: { webhook: "open", worker: "open", activeWebhookRequests: 2, activeWorkerRuns: 1 }, + durable: { activeLeases: 3 }, + recovery: { phase: "idle" }, + observedAt: "2026-08-25T14:00:00.000Z", + interpretation: "aggregate-operator-observation-not-external-security-proof", + }); + const serialized = JSON.stringify(snapshot); + assert.doesNotMatch(serialized, /secret@example|tenant-secret-marker|private-key-marker/); +}); + +test("operator snapshot rejects malformed identifiers, counters, credential status, and recovery state", () => { + assert.throws(() => buildGitHubAppOperatorStatusSnapshot(observation({ releaseId: "secret value with spaces" })), /release id/); + assert.throws(() => buildGitHubAppOperatorStatusSnapshot(observation({ durableActiveLeases: -1 })), /active lease count/); + assert.throws(() => buildGitHubAppOperatorStatusSnapshot(observation({ recoveryPhase: "unknown" })), /recovery phase/); + assert.throws(() => buildGitHubAppOperatorStatusSnapshot(observation({ + credentialStatus: { ...observation().credentialStatus, webhookSecretCount: 3 }, + })), /secret count/); +}); + +test("operator HTTP handler hides endpoint from unauthorized callers and never observes state", async () => { + let observed = 0; + const handler = createGitHubAppOperatorStatusHttpHandler({ + authorize: async () => false, + observe: async () => { observed += 1; return observation(); }, + }); + const recorder = responseRecorder(); + await handler({ method: "GET", url: "/_synsec/operator/status" }, recorder.response); + assert.equal(recorder.response.statusCode, 404); + assert.deepEqual(JSON.parse(recorder.getBody()), { status: "not_found" }); + assert.equal(observed, 0); + assert.equal(recorder.headers.get("cache-control"), "no-store"); +}); + +test("operator HTTP handler returns the bounded snapshot after authorization", async () => { + const handler = createGitHubAppOperatorStatusHttpHandler({ + authorize: () => true, + observe: () => observation(), + }); + const recorder = responseRecorder(); + await handler({ method: "GET", url: "/_synsec/operator/status?ignored=1" }, recorder.response); + assert.equal(recorder.response.statusCode, 200); + const payload = JSON.parse(recorder.getBody()); + assert.equal(payload.release.id, "synsec-v0.2.0+9435694"); + assert.equal(payload.durable.activeLeases, 3); + assert.equal(payload.credentials.webhookSecretCount, 2); + assert.equal(recorder.headers.get("x-content-type-options"), "nosniff"); +}); + +test("authorization exceptions fail closed without reflecting secret-bearing errors", async () => { + const errors = []; + const handler = createGitHubAppOperatorStatusHttpHandler({ + authorize: async () => { throw new Error("Bearer secret-auth-marker"); }, + observe: () => observation(), + onError: (error) => errors.push(error.message), + }); + const recorder = responseRecorder(); + await handler({ method: "GET", url: "/_synsec/operator/status" }, recorder.response); + assert.equal(recorder.response.statusCode, 404); + assert.doesNotMatch(recorder.getBody(), /secret-auth-marker/); + assert.deepEqual(errors, ["GitHub App operator status authorization_failed."]); +}); + +test("observation exceptions return categorical unavailable without reflecting backend diagnostics", async () => { + const errors = []; + const handler = createGitHubAppOperatorStatusHttpHandler({ + authorize: () => true, + observe: async () => { throw new Error("postgresql://user:password@db/tenant-secret"); }, + onError: (error) => errors.push(error.message), + }); + const recorder = responseRecorder(); + await handler({ method: "GET", url: "/_synsec/operator/status" }, recorder.response); + assert.equal(recorder.response.statusCode, 503); + assert.deepEqual(JSON.parse(recorder.getBody()), { status: "unavailable" }); + assert.doesNotMatch(recorder.getBody(), /password|tenant-secret/); + assert.deepEqual(errors, ["GitHub App operator status observation_failed."]); +}); + +test("operator status path and method are bounded", async () => { + assert.throws(() => createGitHubAppOperatorStatusHttpHandler({ + path: "relative", + authorize: () => true, + observe: () => observation(), + }), /bounded absolute path/); + + const handler = createGitHubAppOperatorStatusHttpHandler({ authorize: () => true, observe: () => observation() }); + const recorder = responseRecorder(); + await handler({ method: "POST", url: "/_synsec/operator/status" }, recorder.response); + assert.equal(recorder.response.statusCode, 405); + assert.equal(recorder.headers.get("allow"), "GET"); +}); diff --git a/tests/github-app-permissions.test.mjs b/tests/github-app-permissions.test.mjs new file mode 100644 index 00000000..d94cbd04 --- /dev/null +++ b/tests/github-app-permissions.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + diagnoseGitHubAppWorkerPermissions, + requiredGitHubAppWorkerPermissions, +} from "@synsec/github/app-permissions"; + +test("worker permission diagnostics report the minimum non-SARIF requirements", () => { + assert.deepEqual(requiredGitHubAppWorkerPermissions(), [ + { permission: "contents", level: "read", purpose: "repository-acquisition" }, + { permission: "checks", level: "write", purpose: "check-publication" }, + ]); + + const result = diagnoseGitHubAppWorkerPermissions({ contents: "read", checks: "write" }); + assert.equal(result.ok, true); + assert.equal(result.metadataAvailable, true); + assert.equal(result.diagnostics.every((item) => item.status === "satisfied"), true); +}); + +test("worker permission diagnostics add security_events only when SARIF publication is enabled", () => { + const required = requiredGitHubAppWorkerPermissions({ publishSarif: true }); + assert.deepEqual(required.map((item) => `${item.permission}:${item.level}`), [ + "contents:read", + "checks:write", + "security_events:write", + ]); + + const result = diagnoseGitHubAppWorkerPermissions({ + contents: "write", + checks: "write", + security_events: "write", + }, { publishSarif: true }); + assert.equal(result.ok, true); +}); + +test("worker permission diagnostics distinguish missing and insufficient permissions", () => { + const result = diagnoseGitHubAppWorkerPermissions({ contents: "read", checks: "read" }, { publishSarif: true }); + assert.equal(result.ok, false); + assert.equal(result.diagnostics.find((item) => item.permission === "contents").status, "satisfied"); + assert.equal(result.diagnostics.find((item) => item.permission === "checks").status, "insufficient"); + assert.equal(result.diagnostics.find((item) => item.permission === "security_events").status, "missing"); + assert.match(result.diagnostics.find((item) => item.permission === "checks").message, /checks:read is insufficient/); +}); + +test("missing GitHub permission metadata fails closed as unknown", () => { + const result = diagnoseGitHubAppWorkerPermissions(undefined, { publishSarif: true }); + assert.equal(result.ok, false); + assert.equal(result.metadataAvailable, false); + assert.equal(result.diagnostics.every((item) => item.status === "unknown"), true); +}); diff --git a/tests/github-app-production-readiness.test.mjs b/tests/github-app-production-readiness.test.mjs new file mode 100644 index 00000000..8920d280 --- /dev/null +++ b/tests/github-app-production-readiness.test.mjs @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES, +} from "@synsec/github/app-deployment"; +import { + GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS, +} from "@synsec/github/shared-state-conformance"; +import { + assessGitHubAppProductionReadiness, + assertGitHubAppProductionReady, +} from "@synsec/github/production-readiness"; + +const capabilities = Object.fromEntries( + REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES.map((capability) => [capability, true]), +); + +function deployment(overrides = {}) { + return { + appId: 123, + privateKey: "-----BEGIN PRIVATE KEY-----\ntest-only\n-----END PRIVATE KEY-----", + webhookSecret: "a".repeat(32), + listenHost: "127.0.0.1", + tlsMode: "none", + stateDirectory: "/var/lib/synsec/state", + workspaceDirectory: "/var/lib/synsec/workspaces", + ...overrides, + }; +} + +function contract(overrides = {}) { + return { + contractVersion: 1, + backendId: "postgres-v1", + implementationVersion: "build.42", + capabilities, + evidence: REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES.map((capability) => ({ + capability, + mechanism: "shared-durable-store", + reference: `conformance-${capability}`, + })), + ...overrides, + }; +} + +function report(overrides = {}) { + const coveredScenarioIds = GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS.map((scenario) => scenario.id); + return { + schemaVersion: 1, + backendId: "postgres-v1", + implementationVersion: "build.42", + complete: true, + scenarioTimeoutMs: 5000, + results: coveredScenarioIds.map((id) => ({ id, status: "passed", durationMs: 1 })), + coverage: { + complete: true, + coveredScenarioIds, + missingScenarioIds: [], + missingCapabilities: [], + }, + ...overrides, + }; +} + +test("production readiness keeps single-replica deployments independent of shared-state evidence", () => { + const readiness = assessGitHubAppProductionReadiness(deployment()); + assert.equal(readiness.ready, true); + assert.equal(readiness.requiresSharedStateEvidence, false); + assert.equal(readiness.sharedStateEvidence, undefined); +}); + +test("production readiness fails closed when multi-replica evidence is absent", () => { + const readiness = assessGitHubAppProductionReadiness(deployment({ + replicaCount: 2, + stateBackend: { kind: "shared-transactional", capabilities }, + })); + + assert.equal(readiness.deployment.ready, true); + assert.equal(readiness.requiresSharedStateEvidence, true); + assert.equal(readiness.ready, false); + assert.deepEqual( + readiness.sharedStateEvidence.issues.map((issue) => issue.code), + ["invalid-backend-contract", "invalid-conformance-report"], + ); +}); + +test("production readiness accepts complete evidence for the exact multi-replica adapter build", () => { + const readiness = assessGitHubAppProductionReadiness( + deployment({ replicaCount: 3, stateBackend: { kind: "shared-transactional", capabilities } }), + contract(), + report(), + ); + + assert.equal(readiness.ready, true); + assert.equal(readiness.deployment.ready, true); + assert.equal(readiness.sharedStateEvidence.ready, true); +}); + +test("production readiness rejects stale conformance evidence despite complete capability declarations", () => { + const readiness = assessGitHubAppProductionReadiness( + deployment({ replicaCount: 2, stateBackend: { kind: "shared-transactional", capabilities } }), + contract({ implementationVersion: "build.43" }), + report(), + ); + + assert.equal(readiness.deployment.ready, true); + assert.equal(readiness.ready, false); + assert.deepEqual( + readiness.sharedStateEvidence.issues.map((issue) => issue.code), + ["implementation-version-mismatch"], + ); +}); + +test("production readiness assertion reports categorical codes without credential values", () => { + const secret = "postgres://user:must-not-echo@db.internal/synsec"; + assert.throws( + () => assertGitHubAppProductionReady( + deployment({ replicaCount: 2, stateBackend: { kind: "shared-transactional", capabilities } }), + contract({ backendId: secret }), + report(), + ), + (error) => { + assert.match(error.message, /invalid-backend-contract/); + assert.doesNotMatch(error.message, /must-not-echo|db\.internal/); + return true; + }, + ); +}); diff --git a/tests/github-app-provisioning-cli.test.mjs b/tests/github-app-provisioning-cli.test.mjs new file mode 100644 index 00000000..d3cd0891 --- /dev/null +++ b/tests/github-app-provisioning-cli.test.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import test from "node:test"; + +const execFileAsync = promisify(execFile); +const cli = resolve("apps/cli/dist/github-app-provision-cli.js"); + +async function withConfig(value, operation) { + const directory = await mkdtemp(join(tmpdir(), "synsec-app-provision-")); + const path = join(directory, "provisioning.json"); + try { + await writeFile(path, JSON.stringify(value), { encoding: "utf8", mode: 0o600 }); + return await operation(path); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +function baseConfig(overrides = {}) { + return { + homepageUrl: "https://synsec.example/", + webhookUrl: "https://synsec.example/github/webhooks", + redirectUrl: "https://synsec.example/github/app/manifest/callback", + setupUrl: "https://synsec.example/github/app/setup", + organization: "SynSec-HQ", + ...overrides, + }; +} + +test("provisioning CLI emits a machine-readable organization manifest registration request", async () => { + await withConfig(baseConfig({ publishSarif: true }), async (path) => { + const { stdout, stderr } = await execFileAsync(process.execPath, [cli, path, "--json"], { + encoding: "utf8", + }); + assert.equal(stderr, ""); + const registration = JSON.parse(stdout); + assert.equal(registration.method, "POST"); + assert.equal( + registration.action, + "https://github.com/organizations/SynSec-HQ/settings/apps/new", + ); + assert.match(registration.fields.state, /^[A-Za-z0-9_-]{40,}$/); + const manifest = JSON.parse(registration.fields.manifest); + assert.equal(manifest.default_permissions.contents, "read"); + assert.equal(manifest.default_permissions.security_events, "write"); + assert.equal(manifest.setup_on_update, true); + }); +}); + +test("provisioning CLI rejects credential fields instead of accidentally serializing them", async () => { + await withConfig(baseConfig({ privateKey: "do-not-accept" }), async (path) => { + await assert.rejects( + execFileAsync(process.execPath, [cli, path, "--json"], { encoding: "utf8" }), + (error) => { + assert.match(error.stderr, /unsupported field privateKey/); + assert.match(error.stderr, /Credentials and secrets are not accepted/); + assert.doesNotMatch(error.stdout ?? "", /do-not-accept/); + return true; + }, + ); + }); +}); + +test("provisioning CLI rejects non-HTTPS production endpoints", async () => { + await withConfig(baseConfig({ webhookUrl: "http://127.0.0.1:3000/webhook" }), async (path) => { + await assert.rejects( + execFileAsync(process.execPath, [cli, path, "--json"], { encoding: "utf8" }), + (error) => { + assert.match(error.stderr, /absolute HTTPS URL/); + return true; + }, + ); + }); +}); diff --git a/tests/github-app-provisioning.test.mjs b/tests/github-app-provisioning.test.mjs new file mode 100644 index 00000000..6c08a26f --- /dev/null +++ b/tests/github-app-provisioning.test.mjs @@ -0,0 +1,223 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildSynSecGitHubAppManifest, + createSynSecGitHubAppManifestRegistration, + provisionSynSecGitHubAppManifestConversion, + validateSynSecGitHubAppManifestCallback, +} from "@synsec/github/app-provisioning"; + +function options(overrides = {}) { + return { + homepageUrl: "https://synsec.example/", + webhookUrl: "https://synsec.example/github/webhooks", + redirectUrl: "https://synsec.example/github/app/manifest/callback", + setupUrl: "https://synsec.example/github/app/setup", + name: "SynSec Production", + description: "Repository-first defensive security", + ...overrides, + }; +} + +function validatedCallback() { + return validateSynSecGitHubAppManifestCallback({ + code: "temporary_manifest_code_123", + state: "expected_state_123", + expectedState: "expected_state_123", + }); +} + +const privateKey = `-----BEGIN PRIVATE KEY-----\n${"A".repeat(128)}\n-----END PRIVATE KEY-----`; +const webhookSecret = "w".repeat(48); + +test("manifest provisioning uses the feature-aware least-privilege setup contract", () => { + const manifest = buildSynSecGitHubAppManifest(options()); + assert.deepEqual(manifest.default_permissions, { + contents: "read", + checks: "write", + }); + assert.deepEqual(manifest.default_events, [ + "installation", + "installation_repositories", + "pull_request", + "push", + ]); + assert.equal(manifest.hook_attributes.active, true); + assert.equal(manifest.public, false); + assert.equal(manifest.setup_on_update, true); + assert.equal("request_oauth_on_install" in manifest, false); + + const writeManifest = buildSynSecGitHubAppManifest(options({ + publishSarif: true, + enableRemediationPullRequests: true, + })); + assert.deepEqual(writeManifest.default_permissions, { + contents: "write", + checks: "write", + security_events: "write", + pull_requests: "write", + }); +}); + +test("manifest registration emits a bounded POST contract for personal and organization ownership", () => { + const manifest = buildSynSecGitHubAppManifest(options()); + const personal = createSynSecGitHubAppManifestRegistration({ manifest, state: "state_123" }); + assert.equal(personal.method, "POST"); + assert.equal(personal.action, "https://github.com/settings/apps/new"); + assert.equal(personal.fields.state, "state_123"); + assert.deepEqual(JSON.parse(personal.fields.manifest), manifest); + assert.equal(personal.interpretation, "registration-request-not-provisioning-success"); + + const organization = createSynSecGitHubAppManifestRegistration({ + manifest, + organization: "SynSec-HQ", + state: "state_456", + }); + assert.equal( + organization.action, + "https://github.com/organizations/SynSec-HQ/settings/apps/new", + ); + + const generated = createSynSecGitHubAppManifestRegistration({ manifest }); + assert.match(generated.fields.state, /^[A-Za-z0-9_-]{40,}$/); + assert.notEqual(generated.fields.state, createSynSecGitHubAppManifestRegistration({ manifest }).fields.state); +}); + +test("manifest callback validation requires matching state and never treats callback presence as conversion success", () => { + const callback = validatedCallback(); + assert.deepEqual(callback, { + version: 1, + code: "temporary_manifest_code_123", + interpretation: "validated-callback-not-conversion-success", + }); + + assert.throws( + () => validateSynSecGitHubAppManifestCallback({ + code: "temporary_manifest_code_123", + state: "attacker_state", + expectedState: "expected_state_123", + }), + /state does not match/, + ); + assert.throws( + () => validateSynSecGitHubAppManifestCallback({ + code: undefined, + state: "expected_state_123", + expectedState: "expected_state_123", + }), + /missing code or state/, + ); +}); + +test("manifest conversion hands credentials directly to activation and returns secret-free metadata", async () => { + let activated; + const result = await provisionSynSecGitHubAppManifestConversion({ + callback: validatedCallback(), + async exchange(code) { + assert.equal(code, "temporary_manifest_code_123"); + return { + id: 424242, + pem: privateKey, + webhook_secret: webhookSecret, + client_secret: "unused-client-secret-must-not-be-forwarded", + owner: { login: "untrusted-response-metadata" }, + }; + }, + async activate(credentials) { + activated = credentials; + return { generation: "secret-manager:version/42" }; + }, + }); + + assert.deepEqual(activated, { + appId: 424242, + privateKey, + webhookSecret, + }); + assert.deepEqual(result, { + version: 1, + appId: 424242, + generation: "secret-manager:version/42", + interpretation: "secret-manager-handoff-complete-not-runtime-readiness", + }); + const serialized = JSON.stringify(result); + assert.doesNotMatch(serialized, /BEGIN PRIVATE KEY/); + assert.doesNotMatch(serialized, /unused-client-secret/); + assert.doesNotMatch(serialized, new RegExp(webhookSecret)); +}); + +test("manifest conversion fails closed on malformed credential responses before activation", async () => { + let activationCount = 0; + await assert.rejects( + provisionSynSecGitHubAppManifestConversion({ + callback: validatedCallback(), + async exchange() { + return { id: 42, pem: "not-a-key", webhook_secret: webhookSecret }; + }, + async activate() { + activationCount += 1; + return { generation: "never" }; + }, + }), + /private key must be PEM encoded/, + ); + assert.equal(activationCount, 0); +}); + +test("manifest conversion sanitizes transport and activation failures", async () => { + await assert.rejects( + provisionSynSecGitHubAppManifestConversion({ + callback: validatedCallback(), + async exchange() { + throw new Error(`backend leaked ${webhookSecret}`); + }, + async activate() { + throw new Error("unreachable"); + }, + }), + (error) => { + assert.equal(error.message, "GitHub App manifest conversion transport failed."); + assert.doesNotMatch(error.message, new RegExp(webhookSecret)); + return true; + }, + ); + + await assert.rejects( + provisionSynSecGitHubAppManifestConversion({ + callback: validatedCallback(), + async exchange() { + return { id: 42, pem: privateKey, webhook_secret: webhookSecret }; + }, + async activate() { + throw new Error(`secret manager leaked ${privateKey}`); + }, + }), + (error) => { + assert.equal(error.message, "GitHub App credential activation failed."); + assert.doesNotMatch(error.message, /BEGIN PRIVATE KEY/); + return true; + }, + ); +}); + +test("manifest provisioning fails closed on unsafe URLs and invalid setup/update combinations", () => { + assert.throws( + () => buildSynSecGitHubAppManifest(options({ webhookUrl: "http://synsec.example/github/webhooks" })), + /absolute HTTPS URL/, + ); + assert.throws( + () => buildSynSecGitHubAppManifest(options({ redirectUrl: "https://user:pass@synsec.example/callback" })), + /without credentials or a fragment/, + ); + assert.throws( + () => buildSynSecGitHubAppManifest(options({ setupUrl: undefined, setupOnUpdate: true })), + /requires a setup URL/, + ); + assert.throws( + () => createSynSecGitHubAppManifestRegistration({ + manifest: buildSynSecGitHubAppManifest(options()), + organization: "bad--org", + }), + /organization is invalid/, + ); +}); diff --git a/tests/github-app-readiness-policy.test.mjs b/tests/github-app-readiness-policy.test.mjs new file mode 100644 index 00000000..a43c9e87 --- /dev/null +++ b/tests/github-app-readiness-policy.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + assessGitHubAppRuntimeReadiness, + createGitHubAppRuntimeReadinessPredicate, +} from "@synsec/github/app-readiness-policy"; + +function status(overrides = {}) { + return { + installations: { + total: 2, + active: 2, + suspended: 0, + allRepositories: 1, + selectedRepositories: 1, + ...(overrides.installations ?? {}), + }, + queue: { + total: 3, + pending: 2, + leased: 1, + expiredLeases: 0, + failed: 0, + ...(overrides.queue ?? {}), + }, + }; +} + +test("hosted runtime readiness defaults to rejecting expired worker leases", () => { + assert.equal(assessGitHubAppRuntimeReadiness(status()).ready, true); + + const assessment = assessGitHubAppRuntimeReadiness(status({ + queue: { total: 3, pending: 1, leased: 2, expiredLeases: 1, failed: 0 }, + })); + assert.equal(assessment.ready, false); + assert.deepEqual(assessment.codes, ["expired-leases"]); +}); + +test("hosted runtime readiness supports bounded deployment-specific backlog thresholds", () => { + const assessment = assessGitHubAppRuntimeReadiness(status({ + queue: { total: 9, pending: 6, leased: 1, expiredLeases: 0, failed: 2 }, + }), { + maxPendingJobs: 5, + maxFailedJobs: 1, + }); + + assert.equal(assessment.ready, false); + assert.deepEqual(assessment.codes, ["pending-backlog", "failed-backlog"]); +}); + +test("hosted runtime readiness fails closed on internally inconsistent aggregate status", () => { + const cases = [ + status({ installations: { total: 3 } }), + status({ installations: { allRepositories: 2, selectedRepositories: 2 } }), + status({ queue: { total: 4 } }), + status({ queue: { leased: 0, expiredLeases: 1, pending: 3 } }), + status({ queue: { pending: -1, total: 0 } }), + ]; + + for (const candidate of cases) { + const assessment = assessGitHubAppRuntimeReadiness(candidate); + assert.equal(assessment.ready, false); + assert.deepEqual(assessment.codes, ["invalid-status"]); + } +}); + +test("readiness policy bounds thresholds and validates them before the first probe", () => { + for (const invalid of [-1, 1.5, 1_000_000_001]) { + assert.throws(() => createGitHubAppRuntimeReadinessPredicate({ + maxPendingJobs: invalid, + }), /maxPendingJobs must be an integer/); + } +}); + +test("readiness predicate integrates with the hosted listener without exposing reason codes", () => { + const predicate = createGitHubAppRuntimeReadinessPredicate({ maxExpiredLeases: 0 }); + assert.equal(predicate(status()), true); + assert.equal(predicate(status({ + queue: { total: 3, pending: 1, leased: 2, expiredLeases: 1, failed: 0 }, + })), false); +}); diff --git a/tests/github-app-recovery.test.mjs b/tests/github-app-recovery.test.mjs new file mode 100644 index 00000000..402bf5ad --- /dev/null +++ b/tests/github-app-recovery.test.mjs @@ -0,0 +1,158 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createSynSecGitHubAppRecoveryController } from "@synsec/github/app-recovery"; + +function maintenance(initial = {}) { + let status = { + acceptingWebhooks: true, + acceptingWorkerRuns: true, + activeWebhookRequests: 0, + activeWorkerRuns: 0, + ...initial, + }; + return { + beginCalls: 0, + resumeCalls: 0, + beginDrain() { + this.beginCalls += 1; + status = { ...status, acceptingWebhooks: false, acceptingWorkerRuns: false }; + return { ...status }; + }, + resumeAdmission() { + this.resumeCalls += 1; + status = { ...status, acceptingWebhooks: true, acceptingWorkerRuns: true }; + return { ...status }; + }, + status() { return { ...status }; }, + setStatus(next) { status = { ...status, ...next }; }, + }; +} + +const ready = { + sharedStateReady: true, + runtimeCredentialsReady: true, + githubControlPlaneReady: true, +}; + +test("recovery isolation immediately closes both local admission boundaries", () => { + const control = maintenance(); + const recovery = createSynSecGitHubAppRecoveryController({ maintenance: control, async probe() { return ready; } }); + + const result = recovery.isolate("shared-state-unavailable"); + assert.equal(control.beginCalls, 1); + assert.equal(control.status().acceptingWebhooks, false); + assert.equal(control.status().acceptingWorkerRuns, false); + assert.equal(result.state, "isolated"); + assert.equal(result.reason, "shared-state-unavailable"); + assert.equal(result.attempts, 0); + assert.equal(JSON.stringify(result).includes("credential"), false); +}); + +test("recovery waits for locally admitted work before touching trusted probes", async () => { + const control = maintenance({ activeWebhookRequests: 1, activeWorkerRuns: 1 }); + let probes = 0; + const recovery = createSynSecGitHubAppRecoveryController({ + maintenance: control, + pollIntervalMs: 10, + async probe() { probes += 1; return ready; }, + }); + recovery.isolate("operator"); + const pending = recovery.recover(500); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(probes, 0); + assert.equal(control.resumeCalls, 0); + + control.setStatus({ activeWebhookRequests: 0, activeWorkerRuns: 0 }); + const result = await pending; + assert.equal(probes, 1); + assert.equal(control.resumeCalls, 1); + assert.equal(result.state, "running"); + assert.equal(result.reason, undefined); +}); + +test("recovery retries explicit not-ready observations but resumes only after one fully-ready observation", async () => { + const control = maintenance(); + const observations = [ + { ...ready, sharedStateReady: false }, + { ...ready, runtimeCredentialsReady: false }, + ready, + ]; + const recovery = createSynSecGitHubAppRecoveryController({ + maintenance: control, + pollIntervalMs: 10, + async probe() { return observations.shift(); }, + }); + recovery.isolate("runtime-credentials-unavailable"); + + const result = await recovery.recover(500); + assert.equal(result.state, "running"); + assert.equal(result.attempts, 3); + assert.equal(control.resumeCalls, 1); +}); + +test("thrown or malformed probe failures remain categorical and keep admission closed", async () => { + for (const probe of [ + async () => { throw new Error("postgresql://user:secret@db.internal/customer"); }, + async () => ({ sharedStateReady: true }), + ]) { + const control = maintenance(); + const recovery = createSynSecGitHubAppRecoveryController({ maintenance: control, probe }); + recovery.isolate("github-control-plane-unavailable"); + const result = await recovery.recover(500); + assert.equal(result.state, "recovery-failed"); + assert.equal(control.resumeCalls, 0); + assert.equal(control.status().acceptingWebhooks, false); + assert.equal(control.status().acceptingWorkerRuns, false); + assert.equal(JSON.stringify(result).includes("secret"), false); + assert.equal(JSON.stringify(result).includes("postgresql"), false); + } +}); + +test("concurrent recovery callers share one verification attempt", async () => { + const control = maintenance(); + let release; + const gate = new Promise((resolve) => { release = resolve; }); + let probes = 0; + const recovery = createSynSecGitHubAppRecoveryController({ + maintenance: control, + async probe() { + probes += 1; + await gate; + return ready; + }, + }); + recovery.isolate("operator"); + const first = recovery.recover(500); + const second = recovery.recover(500); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(probes, 1); + release(); + assert.deepEqual(await first, await second); + assert.equal(control.resumeCalls, 1); +}); + +test("recovery fails closed if admission reopens outside the controller", async () => { + const control = maintenance(); + let probes = 0; + const recovery = createSynSecGitHubAppRecoveryController({ + maintenance: control, + async probe() { probes += 1; return ready; }, + }); + recovery.isolate("operator"); + control.resumeAdmission(); + const result = await recovery.recover(500); + assert.equal(result.state, "recovery-failed"); + assert.equal(probes, 0); +}); + +test("recovery validates bounded configuration and categorical incident reasons", () => { + const control = maintenance(); + assert.throws( + () => createSynSecGitHubAppRecoveryController({ maintenance: control, pollIntervalMs: 9, async probe() { return ready; } }), + /recovery poll interval/, + ); + const recovery = createSynSecGitHubAppRecoveryController({ maintenance: control, async probe() { return ready; } }); + assert.throws(() => recovery.isolate("repo-secret-value"), /recovery reason is invalid/); + recovery.isolate("operator"); + assert.throws(() => recovery.recover(99), /recovery timeout/); +}); diff --git a/tests/github-app-retention.test.mjs b/tests/github-app-retention.test.mjs new file mode 100644 index 00000000..ff2f18f9 --- /dev/null +++ b/tests/github-app-retention.test.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { mkdtemp, utimes } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { FileGitHubScanQueue } from "@synsec/github/scan-queue"; +import { pruneGitHubAppFailedJobs } from "@synsec/github/retention"; + +async function setup(now) { + const directory = await mkdtemp(join(tmpdir(), "synsec-retention-")); + return new FileGitHubScanQueue(directory, { now: () => now, leaseMs: 10_000 }); +} + +async function enqueueFailed(queue, input, modifiedAt) { + const job = await queue.enqueue(input); + const leased = await queue.claimNext(); + assert.equal(leased.jobId, job.jobId); + await queue.fail(job.jobId, leased.leaseId); + if (modifiedAt !== undefined) { + const date = new Date(modifiedAt); + await utimes(join(queue.directory, `${job.jobId}.json`), date, date); + } + return job; +} + +test("retention deletes only failed jobs whose terminal record is old", async () => { + const now = Date.parse("2026-08-22T20:00:00.000Z"); + const queue = await setup(now); + const oldFailed = await enqueueFailed(queue, { + deliveryId: "old-failed", installationId: 1, repository: "o/old", headSha: "a".repeat(40), event: "push", + createdAt: new Date(now - 12 * 60 * 60 * 1000).toISOString(), + }, now - 2 * 60 * 60 * 1000); + const recentFailed = await enqueueFailed(queue, { + deliveryId: "recent-failed", installationId: 1, repository: "o/recent", headSha: "b".repeat(40), event: "push", + createdAt: new Date(now - 12 * 60 * 60 * 1000).toISOString(), + }, now - 30 * 60 * 1000); + await queue.enqueue({ + deliveryId: "old-pending", installationId: 1, repository: "o/pending", headSha: "c".repeat(40), event: "push", + createdAt: new Date(now - 24 * 60 * 60 * 1000).toISOString(), + }); + + const result = await pruneGitHubAppFailedJobs(queue, { now: () => now, failedJobRetentionMs: 60 * 60 * 1000 }); + assert.deepEqual(result, { inspected: 3, deleted: 1, retainedFailed: 1 }); + const remaining = await queue.list(); + assert.equal(remaining.some((job) => job.jobId === oldFailed.jobId), false); + assert.equal(remaining.some((job) => job.jobId === recentFailed.jobId), true); + assert.equal(remaining.some((job) => job.status === "pending"), true); +}); + +test("retention caps deletions per maintenance pass", async () => { + const now = Date.parse("2026-08-22T20:00:00.000Z"); + const queue = await setup(now); + for (let index = 0; index < 3; index += 1) { + await enqueueFailed(queue, { + deliveryId: `failed-${index}`, installationId: 2, repository: `o/r${index}`, + headSha: `${index + 1}`.repeat(40), event: "push", + }, now - 3 * 60 * 60 * 1000 - index); + } + const result = await pruneGitHubAppFailedJobs(queue, { now: () => now, failedJobRetentionMs: 60 * 60 * 1000, maxDeletes: 2 }); + assert.deepEqual(result, { inspected: 3, deleted: 2, retainedFailed: 1 }); + assert.equal((await queue.list()).length, 1); +}); + +test("retention rejects unbounded policy values", async () => { + const now = Date.parse("2026-08-22T20:00:00.000Z"); + const queue = await setup(now); + await assert.rejects(() => pruneGitHubAppFailedJobs(queue, { failedJobRetentionMs: 1 }), /retention must be between/); + await assert.rejects(() => pruneGitHubAppFailedJobs(queue, { maxDeletes: 1001 }), /maxDeletes must be between/); +}); diff --git a/tests/github-app-rotation-cli.test.mjs b/tests/github-app-rotation-cli.test.mjs new file mode 100644 index 00000000..4acc7bac --- /dev/null +++ b/tests/github-app-rotation-cli.test.mjs @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { execFile } from "node:child_process"; +import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const exec = promisify(execFile); +const cli = new URL("../apps/cli/dist/github-app-cli.js", import.meta.url); + +async function runExpectingExit(args, expectedCode) { + try { + await exec(process.execPath, [cli.pathname, ...args]); + assert.fail(`Expected exit code ${expectedCode}.`); + } catch (error) { + assert.equal(error.code, expectedCode); + return error; + } +} + +test("rotation CLI fails closed while webhook verification remains incomplete", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-app-rotation-")); + try { + const path = join(root, "rotation.json"); + await writeFile(path, JSON.stringify({ + kind: "webhook-secret", + replacementActivated: true, + runtimeReloaded: true, + externalConfigurationUpdated: true, + }), "utf8"); + const error = await runExpectingExit(["rotation", path, "--json"], 2); + const output = JSON.parse(error.stdout); + assert.equal(output.readyToRetirePrevious, false); + assert.match(output.requiredActions.join("\n"), /authenticated GitHub webhook delivery/); + assert.match(output.requiredActions.at(-1), /Keep the previous webhook secret/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("rotation CLI returns ready only after private-key token exchange verification", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-app-rotation-ready-")); + try { + const path = join(root, "rotation.json"); + await writeFile(path, JSON.stringify({ + kind: "app-private-key", + replacementActivated: true, + runtimeReloaded: true, + verificationSucceeded: true, + }), "utf8"); + const { stdout } = await exec(process.execPath, [cli.pathname, "rotation", path, "--json"]); + const output = JSON.parse(stdout); + assert.equal(output.readyToRetirePrevious, true); + assert.deepEqual(output.requiredActions, []); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("rotation CLI rejects credential-bearing fields without echoing their values", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-app-rotation-secret-")); + try { + const path = join(root, "rotation.json"); + await writeFile(path, JSON.stringify({ + kind: "webhook-secret", + replacementActivated: true, + secret: "do-not-echo-this-secret-value", + }), "utf8"); + const error = await runExpectingExit(["rotation", path], 1); + assert.match(error.stderr, /unsupported field secret/); + assert.doesNotMatch(error.stderr, /do-not-echo-this-secret-value/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("GitHub App diagnostic inputs reject symlinks without reading their target", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-app-rotation-symlink-")); + try { + const target = join(root, "target.json"); + const link = join(root, "rotation.json"); + await writeFile(target, JSON.stringify({ + kind: "webhook-secret", + secret: "target-secret-must-not-be-read", + }), "utf8"); + await symlink(target, link); + + const error = await runExpectingExit(["rotation", link], 1); + assert.match(error.stderr, /non-symlink regular file/); + assert.doesNotMatch(error.stderr, /target-secret-must-not-be-read/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/github-app-runtime.test.mjs b/tests/github-app-runtime.test.mjs new file mode 100644 index 00000000..c7d34b47 --- /dev/null +++ b/tests/github-app-runtime.test.mjs @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { generateKeyPairSync } from "node:crypto"; +import { access, mkdir, mkdtemp, readdir, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { createLocalGitHubAppRuntime } from "@synsec/github/app-runtime"; +import { markGitHubWorkspaceOwned } from "@synsec/github/workspace-ownership"; + +function privateKeyPem() { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + return privateKey.export({ type: "pkcs8", format: "pem" }); +} + +async function textFiles(root) { + const result = []; + async function walk(path) { + for (const entry of await readdir(path, { withFileTypes: true })) { + const child = join(path, entry.name); + if (entry.isDirectory()) await walk(child); + else if (entry.isFile()) result.push(await readFile(child, "utf8")); + } + } + await walk(root); + return result; +} + +test("local App runtime composes durable stores, maintenance, status, and an idle worker without persisting credentials", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-app-runtime-")); + const stateDirectory = join(root, "state"); + const workspaceRoot = join(root, "workspaces"); + const privateKey = privateKeyPem(); + const webhookSecret = "runtime-webhook-secret"; + const runtime = await createLocalGitHubAppRuntime({ + stateDirectory, + workspaceRoot, + webhookSecret, + appId: 12345, + privateKey, + config: { scanners: ["opengrep"], parallelism: 1 }, + }); + + assert.equal(runtime.stateDirectory, stateDirectory); + assert.equal(runtime.workspaceRoot, workspaceRoot); + assert.equal(typeof runtime.webhookHandler, "function"); + assert.deepEqual(await runtime.runWorkerOnce(), { status: "idle" }); + assert.deepEqual(await runtime.runMaintenance(), { + expiredReplayRecordsDeleted: 0, + failedJobs: { inspected: 0, deleted: 0, retainedFailed: 0 }, + workspaces: { inspected: 0, owned: 0, stale: 0, deleted: 0, skipped: 0 }, + }); + assert.deepEqual(await runtime.getStatus(), { + installations: { total: 0, active: 0, suspended: 0, allRepositories: 0, selectedRepositories: 0 }, + queue: { total: 0, pending: 0, leased: 0, expiredLeases: 0, failed: 0 }, + }); + + const persisted = (await textFiles(stateDirectory)).join("\n"); + assert.equal(persisted.includes(webhookSecret), false); + assert.equal(persisted.includes(privateKey.slice(0, 32)), false); +}); + +test("runtime workspace cleanup is opt-in and removes only stale marker-owned directories", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-app-runtime-workspaces-")); + const stateDirectory = join(root, "state"); + const workspaceRoot = join(root, "workspaces"); + await mkdir(workspaceRoot, { recursive: true }); + const now = Date.parse("2026-08-22T20:00:00.000Z"); + const stale = join(workspaceRoot, "synsec-github-stale"); + const unrelated = join(workspaceRoot, "synsec-github-unowned"); + await mkdir(stale); + await mkdir(unrelated); + await markGitHubWorkspaceOwned(stale, () => now - 2 * 60 * 60 * 1000); + + const observe = await createLocalGitHubAppRuntime({ + stateDirectory, + workspaceRoot, + webhookSecret: "runtime-webhook-secret", + appId: 12345, + privateKey: privateKeyPem(), + config: { scanners: ["opengrep"], parallelism: 1 }, + workspaceRetentionMs: 60 * 60 * 1000, + now: () => now, + }); + assert.deepEqual((await observe.runMaintenance()).workspaces, { + inspected: 2, + owned: 1, + stale: 1, + deleted: 0, + skipped: 1, + }); + await access(stale); + + const cleanup = await createLocalGitHubAppRuntime({ + stateDirectory, + workspaceRoot, + webhookSecret: "runtime-webhook-secret", + appId: 12345, + privateKey: privateKeyPem(), + config: { scanners: ["opengrep"], parallelism: 1 }, + workspaceRetentionMs: 60 * 60 * 1000, + deleteStaleOwnedWorkspaces: true, + now: () => now, + }); + assert.equal((await cleanup.runMaintenance()).workspaces.deleted, 1); + await assert.rejects(() => access(stale), /ENOENT/); + await access(unrelated); +}); + +test("local App runtime refuses overlapping durable state and scanner workspace trees", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-app-runtime-overlap-")); + const base = { + webhookSecret: "secret", + appId: 12345, + privateKey: privateKeyPem(), + config: { scanners: ["opengrep"], parallelism: 1 }, + }; + + await assert.rejects(() => createLocalGitHubAppRuntime({ + ...base, + stateDirectory: join(root, "state"), + workspaceRoot: join(root, "state", "workspaces"), + }), /separate directory trees/); + await assert.rejects(() => createLocalGitHubAppRuntime({ + ...base, + stateDirectory: join(root, "workspaces", "state"), + workspaceRoot: join(root, "workspaces"), + }), /separate directory trees/); +}); diff --git a/tests/github-app-server.test.mjs b/tests/github-app-server.test.mjs new file mode 100644 index 00000000..229c0cc8 --- /dev/null +++ b/tests/github-app-server.test.mjs @@ -0,0 +1,292 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createGitHubAppServer } from "@synsec/github/app-server"; + +async function getJson(url, options) { + const response = await fetch(url, options); + return { status: response.status, body: await response.json(), headers: response.headers }; +} + +const healthyStatus = { + installations: { + total: 2, + active: 1, + suspended: 1, + allRepositories: 1, + selectedRepositories: 1, + }, + queue: { total: 4, pending: 1, leased: 1, expiredLeases: 1, failed: 2 }, +}; + +test("hosted App server exposes aggregate-only health on loopback", async () => { + let webhookCalls = 0; + const app = createGitHubAppServer({ + host: "127.0.0.1", + port: 0, + tlsMode: "none", + webhookHandler: async (_request, response) => { + webhookCalls += 1; + response.statusCode = 204; + response.end(); + }, + getStatus: async () => healthyStatus, + }); + + const address = await app.start(); + try { + const result = await getJson(`http://127.0.0.1:${address.port}/healthz`); + assert.equal(result.status, 200); + assert.deepEqual(result.body, { + status: "ok", + ...healthyStatus, + }); + assert.equal(result.headers.get("cache-control"), "no-store"); + assert.equal(result.headers.get("x-content-type-options"), "nosniff"); + assert.equal(webhookCalls, 0); + } finally { + await app.close(); + } +}); + +test("hosted App server exposes a minimal routing-readiness probe", async () => { + let webhookCalls = 0; + let statusCalls = 0; + const app = createGitHubAppServer({ + host: "127.0.0.1", + port: 0, + tlsMode: "none", + webhookHandler: async (_request, response) => { + webhookCalls += 1; + response.end(); + }, + getStatus: async () => { + statusCalls += 1; + return healthyStatus; + }, + isReady: (status) => status.queue.expiredLeases === 0, + }); + + const address = await app.start(); + try { + const result = await getJson(`http://127.0.0.1:${address.port}/readyz`); + assert.equal(result.status, 503); + assert.deepEqual(result.body, { status: "not_ready" }); + assert.equal(result.headers.get("cache-control"), "no-store"); + assert.equal(statusCalls, 1); + assert.equal(webhookCalls, 0); + + const health = await getJson(`http://127.0.0.1:${address.port}/healthz`); + assert.equal(health.status, 200); + assert.equal(health.body.status, "ok"); + } finally { + await app.close(); + } +}); + +test("hosted App readiness fails closed on status or policy errors without leaking diagnostics", async () => { + const secret = "https://operator:super-secret@db.internal/runtime"; + for (const failureMode of ["status", "policy"]) { + const app = createGitHubAppServer({ + host: "127.0.0.1", + port: 0, + tlsMode: "none", + webhookHandler: async (_request, response) => response.end(), + getStatus: async () => { + if (failureMode === "status") throw new Error(secret); + return healthyStatus; + }, + isReady: () => { + if (failureMode === "policy") throw new Error(secret); + return true; + }, + }); + const address = await app.start(); + try { + const result = await getJson(`http://127.0.0.1:${address.port}/readyz`); + assert.equal(result.status, 503); + assert.deepEqual(result.body, { status: "not_ready" }); + assert.doesNotMatch(JSON.stringify(result.body), /super-secret|db\.internal/); + } finally { + await app.close(); + } + } +}); + +test("hosted App server delegates non-probe requests and bounds probe methods", async () => { + let seenUrl; + const app = createGitHubAppServer({ + host: "127.0.0.1", + port: 0, + tlsMode: "none", + webhookHandler: async (request, response) => { + seenUrl = request.url; + response.statusCode = 202; + response.end("queued"); + }, + }); + const address = await app.start(); + try { + for (const path of ["healthz", "readyz"]) { + const probePost = await fetch(`http://127.0.0.1:${address.port}/${path}`, { method: "POST" }); + assert.equal(probePost.status, 405); + assert.equal(probePost.headers.get("allow"), "GET"); + } + + const readiness = await getJson(`http://127.0.0.1:${address.port}/readyz`); + assert.equal(readiness.status, 200); + assert.deepEqual(readiness.body, { status: "ready" }); + + const webhook = await fetch(`http://127.0.0.1:${address.port}/github/webhooks`, { + method: "POST", + body: "{}", + headers: { "content-type": "application/json" }, + }); + assert.equal(webhook.status, 202); + assert.equal(await webhook.text(), "queued"); + assert.equal(seenUrl, "/github/webhooks"); + } finally { + await app.close(); + } +}); + +test("hosted App server bounds concurrent webhook work and keeps probes available", async () => { + let webhookCalls = 0; + let releaseFirst; + const firstMayFinish = new Promise((resolve) => { releaseFirst = resolve; }); + let firstStartedResolve; + const firstStarted = new Promise((resolve) => { firstStartedResolve = resolve; }); + + const app = createGitHubAppServer({ + host: "127.0.0.1", + port: 0, + tlsMode: "none", + maxConcurrentWebhooks: 1, + webhookHandler: async (_request, response) => { + webhookCalls += 1; + firstStartedResolve(); + await firstMayFinish; + response.statusCode = 202; + response.end("queued"); + }, + getStatus: async () => healthyStatus, + }); + const address = await app.start(); + try { + const first = fetch(`http://127.0.0.1:${address.port}/github/webhooks`, { + method: "POST", + body: "first", + }); + await firstStarted; + + const busy = await getJson(`http://127.0.0.1:${address.port}/github/webhooks`, { + method: "POST", + body: "second", + }); + assert.equal(busy.status, 503); + assert.deepEqual(busy.body, { status: "busy" }); + assert.equal(busy.headers.get("retry-after"), "1"); + assert.equal(webhookCalls, 1); + + const health = await getJson(`http://127.0.0.1:${address.port}/healthz`); + assert.equal(health.status, 200); + assert.equal(health.body.status, "ok"); + + const readiness = await getJson(`http://127.0.0.1:${address.port}/readyz`); + assert.equal(readiness.status, 200); + assert.deepEqual(readiness.body, { status: "ready" }); + + releaseFirst(); + const firstResponse = await first; + assert.equal(firstResponse.status, 202); + assert.equal(await firstResponse.text(), "queued"); + } finally { + releaseFirst?.(); + await app.close(); + } +}); + +test("hosted App server rejects unsafe plaintext and malformed TLS, probe, or concurrency configurations", () => { + const handler = async (_request, response) => response.end(); + assert.throws(() => createGitHubAppServer({ + host: "0.0.0.0", + port: 3000, + tlsMode: "none", + webhookHandler: handler, + }), /only on loopback/); + + assert.throws(() => createGitHubAppServer({ + host: "127.0.0.1", + port: 3000, + tlsMode: "local", + webhookHandler: handler, + }), /requires both key and certificate/); + + assert.throws(() => createGitHubAppServer({ + host: "127.0.0.1", + port: 3000, + tlsMode: "none", + tls: { key: "key", cert: "cert" }, + webhookHandler: handler, + }), /accepted only in local TLS mode/); + + for (const maxConcurrentWebhooks of [0, 1.5, 1001]) { + assert.throws(() => createGitHubAppServer({ + host: "127.0.0.1", + port: 3000, + tlsMode: "none", + maxConcurrentWebhooks, + webhookHandler: handler, + }), /concurrent webhook limit/); + } + + for (const [field, value] of [["healthPath", "healthz"], ["readinessPath", "/readyz?verbose=1"]]) { + assert.throws(() => createGitHubAppServer({ + host: "127.0.0.1", + port: 3000, + tlsMode: "none", + [field]: value, + webhookHandler: handler, + }), /must be an absolute path without query, fragment, or control components/); + } + + assert.throws(() => createGitHubAppServer({ + host: "127.0.0.1", + port: 3000, + tlsMode: "none", + healthPath: "/probe", + readinessPath: "/probe", + webhookHandler: handler, + }), /must be distinct/); + + assert.throws(() => createGitHubAppServer({ + host: "127.0.0.1", + port: 3000, + tlsMode: "none", + webhookHandler: handler, + isReady: () => true, + }), /requires aggregate runtime status collection/); +}); + +test("hosted App server returns unavailable when status collection fails", async () => { + const app = createGitHubAppServer({ + host: "127.0.0.1", + port: 0, + tlsMode: "none", + webhookHandler: async (_request, response) => response.end(), + getStatus: async () => { + throw new Error("durable state unavailable"); + }, + }); + const address = await app.start(); + try { + const health = await getJson(`http://127.0.0.1:${address.port}/healthz`); + assert.equal(health.status, 503); + assert.deepEqual(health.body, { status: "unavailable" }); + + const readiness = await getJson(`http://127.0.0.1:${address.port}/readyz`); + assert.equal(readiness.status, 503); + assert.deepEqual(readiness.body, { status: "not_ready" }); + } finally { + await app.close(); + } +}); diff --git a/tests/github-app-service-lifecycle.test.mjs b/tests/github-app-service-lifecycle.test.mjs new file mode 100644 index 00000000..46ffb626 --- /dev/null +++ b/tests/github-app-service-lifecycle.test.mjs @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { EventEmitter } from "node:events"; +import { + bindSynSecGitHubAppServiceSignals, + createSynSecGitHubAppServiceLifecycleController, +} from "@synsec/github/app-service-lifecycle"; + +function stopEvidence() { + return { + webhookAdmissionClosed: true, + workerAdmissionClosed: true, + localWebhookRequests: 0, + localWorkerRuns: 0, + activeLeases: 0, + }; +} + +function maintenance(overrides = {}) { + return { + prepareCalls: 0, + resumeCalls: 0, + async prepareForServiceStop() { + this.prepareCalls += 1; + return stopEvidence(); + }, + resumeAdmission() { + this.resumeCalls += 1; + }, + ...overrides, + }; +} + +test("service lifecycle serializes concurrent stop requests and hands off only after drain evidence", async () => { + let release; + const gate = new Promise((resolve) => { release = resolve; }); + const control = maintenance({ + async prepareForServiceStop() { + this.prepareCalls += 1; + await gate; + return stopEvidence(); + }, + }); + const handoffs = []; + const lifecycle = createSynSecGitHubAppServiceLifecycleController({ + maintenance: control, + onReadyToStop(evidence, reason) { + handoffs.push({ evidence, reason }); + }, + }); + + const first = lifecycle.requestStop("SIGTERM"); + const second = lifecycle.requestStop("SIGINT"); + assert.equal(lifecycle.status().state, "draining"); + assert.equal(control.prepareCalls, 1); + release(); + + assert.deepEqual(await first, { state: "ready-to-stop", reason: "SIGTERM" }); + assert.deepEqual(await second, { state: "ready-to-stop", reason: "SIGTERM" }); + assert.equal(handoffs.length, 1); + assert.equal(handoffs[0].reason, "SIGTERM"); + assert.deepEqual(handoffs[0].evidence, stopEvidence()); + assert.equal(control.prepareCalls, 1); +}); + +test("service lifecycle fails closed and does not expose maintenance diagnostics", async () => { + const control = maintenance({ + async prepareForServiceStop() { + this.prepareCalls += 1; + throw new Error("postgresql://user:secret@example.internal/customer"); + }, + }); + let readyCalls = 0; + let failedReason; + const lifecycle = createSynSecGitHubAppServiceLifecycleController({ + maintenance: control, + onReadyToStop() { readyCalls += 1; }, + onStopFailed(reason) { failedReason = reason; }, + }); + + const result = await lifecycle.requestStop("operator"); + assert.deepEqual(result, { state: "stop-failed", reason: "operator" }); + assert.equal(readyCalls, 0); + assert.equal(failedReason, "operator"); + assert.equal(JSON.stringify(result).includes("secret"), false); + assert.equal(control.resumeCalls, 0); + + assert.deepEqual(lifecycle.resume(), { state: "running" }); + assert.equal(control.resumeCalls, 1); +}); + +test("service lifecycle treats a failing hosting handoff as a failed stop and can resume", async () => { + const control = maintenance(); + const lifecycle = createSynSecGitHubAppServiceLifecycleController({ + maintenance: control, + async onReadyToStop() { + throw new Error("system manager detail should not escape"); + }, + }); + + assert.deepEqual(await lifecycle.requestStop("SIGTERM"), { state: "stop-failed", reason: "SIGTERM" }); + assert.deepEqual(lifecycle.resume(), { state: "running" }); + assert.equal(control.resumeCalls, 1); +}); + +test("service lifecycle cannot resume after stop eligibility has been handed off", async () => { + const lifecycle = createSynSecGitHubAppServiceLifecycleController({ + maintenance: maintenance(), + onReadyToStop() {}, + }); + await lifecycle.requestStop(); + assert.throws(() => lifecycle.resume(), /already ready to stop/); +}); + +test("SIGTERM and SIGINT bindings invoke the same serialized lifecycle boundary and dispose cleanly", async () => { + const source = new EventEmitter(); + const reasons = []; + const controller = { + async requestStop(reason) { + reasons.push(reason); + return { state: "ready-to-stop", reason }; + }, + }; + const binding = bindSynSecGitHubAppServiceSignals(controller, source); + + source.emit("SIGTERM"); + source.emit("SIGINT"); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(reasons, ["SIGTERM", "SIGINT"]); + + binding.dispose(); + binding.dispose(); + source.emit("SIGTERM"); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(reasons, ["SIGTERM", "SIGINT"]); +}); + +test("service lifecycle validates bounded timeout configuration", () => { + assert.throws( + () => createSynSecGitHubAppServiceLifecycleController({ + maintenance: maintenance(), + timeoutMs: 99, + onReadyToStop() {}, + }), + /service stop timeout/, + ); +}); diff --git a/tests/github-app-setup.test.mjs b/tests/github-app-setup.test.mjs new file mode 100644 index 00000000..fc67dc4c --- /dev/null +++ b/tests/github-app-setup.test.mjs @@ -0,0 +1,170 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildSynSecGitHubAppSetupContract, + buildSynSecGitHubAppSetupRecoveryPlan, + evaluateSynSecGitHubAppSetup, +} from "@synsec/github/app-setup"; + +test("default GitHub App setup remains read-only for repository contents", () => { + const setup = buildSynSecGitHubAppSetupContract(); + assert.equal(setup.version, 1); + assert.deepEqual(setup.permissions, { + contents: "read", + checks: "write", + }); + assert.equal(setup.remediationWriteEnabled, false); + assert.deepEqual(setup.events, [ + "installation", + "installation_repositories", + "pull_request", + "push", + ]); + assert.match(setup.notes.join("\n"), /contents:read is sufficient/); +}); + +test("SARIF adds only security-events publication permission", () => { + const setup = buildSynSecGitHubAppSetupContract({ publishSarif: true }); + assert.deepEqual(setup.permissions, { + contents: "read", + checks: "write", + security_events: "write", + }); + assert.equal(setup.permissions.pull_requests, undefined); +}); + +test("remediation write permissions are explicit opt-in and contents write subsumes acquisition read", () => { + const setup = buildSynSecGitHubAppSetupContract({ + publishSarif: true, + enableRemediationPullRequests: true, + }); + assert.deepEqual(setup.permissions, { + contents: "write", + checks: "write", + security_events: "write", + pull_requests: "write", + }); + assert.equal(setup.remediationWriteEnabled, true); + assert.match(setup.notes.join("\n"), /explicitly approved remediation PR creation/); +}); + +test("setup evaluator distinguishes missing capability from least-privilege drift", () => { + const evaluation = evaluateSynSecGitHubAppSetup({ + permissions: { + contents: "write", + checks: "read", + issues: "write", + }, + events: ["push", "pull_request", "issues"], + }); + + assert.equal(evaluation.ready, false); + assert.deepEqual(evaluation.missingPermissions, [{ + permission: "checks", + required: "write", + actual: "read", + }]); + assert.deepEqual(evaluation.excessiveWritePermissions, ["contents", "issues"]); + assert.deepEqual(evaluation.missingEvents, ["installation", "installation_repositories"]); + assert.deepEqual(evaluation.extraEvents, ["issues"]); + assert.equal(evaluation.interpretation, "setup-comparison-not-runtime-authorization"); +}); + +test("setup evaluator accepts exactly the feature-aware minimum", () => { + const setup = buildSynSecGitHubAppSetupContract({ publishSarif: true, enableRemediationPullRequests: true }); + const evaluation = evaluateSynSecGitHubAppSetup({ + permissions: setup.permissions, + events: setup.events, + options: { publishSarif: true, enableRemediationPullRequests: true }, + }); + assert.deepEqual(evaluation, { + version: 1, + ready: true, + missingPermissions: [], + excessiveWritePermissions: [], + missingEvents: [], + extraEvents: [], + interpretation: "setup-comparison-not-runtime-authorization", + }); +}); + +test("setup recovery plan separates required fixes from least-privilege review", () => { + const plan = buildSynSecGitHubAppSetupRecoveryPlan({ + permissions: { + contents: "write", + checks: "read", + issues: "write", + }, + events: ["push", "pull_request", "issues"], + }); + + assert.deepEqual(plan, { + version: 1, + ready: false, + requiredActions: [ + "Upgrade GitHub App permission checks from read to write.", + "Subscribe the GitHub App to the installation event.", + "Subscribe the GitHub App to the installation_repositories event.", + ], + leastPrivilegeReview: [ + "Review contents:write and remove it if no other operator-approved feature requires it.", + "Review issues:write and remove it if no other operator-approved feature requires it.", + "Review the issues event subscription and remove it if no other operator-approved feature requires it.", + ], + interpretation: "operator-guidance-not-runtime-authorization", + }); +}); + +test("setup recovery plan is empty when the feature-aware minimum is satisfied", () => { + const setup = buildSynSecGitHubAppSetupContract({ publishSarif: true }); + const plan = buildSynSecGitHubAppSetupRecoveryPlan({ + permissions: setup.permissions, + events: setup.events, + options: { publishSarif: true }, + }); + assert.equal(plan.ready, true); + assert.deepEqual(plan.requiredActions, []); + assert.deepEqual(plan.leastPrivilegeReview, []); +}); + +test("setup evaluator validates bounded permission and event names before comparison", () => { + assert.throws(() => evaluateSynSecGitHubAppSetup({ + permissions: { "contents\nwrite": "write" }, + events: ["push"], + }), /invalid permission name/); + assert.throws(() => evaluateSynSecGitHubAppSetup({ + permissions: { contents: "read" }, + events: ["pull request"], + }), /invalid event name/); + + const tooManyPermissions = Object.fromEntries( + Array.from({ length: 101 }, (_, index) => [`permission_${index}`, "read"]), + ); + assert.throws(() => evaluateSynSecGitHubAppSetup({ + permissions: tooManyPermissions, + events: ["push"], + }), /permission list exceeds 100 entries/); +}); + +test("setup contract contains no installation, repository-target, credential, or commit identity", () => { + const setup = buildSynSecGitHubAppSetupContract({ enableRemediationPullRequests: true }); + const serialized = JSON.stringify(setup); + for (const forbidden of [ + "installationId", + "accountLogin", + "example/private-repository", + "installation-token-value", + "a".repeat(40), + "clone_url", + "targetUrl", + ]) { + assert.equal(serialized.includes(forbidden), false); + } + assert.deepEqual(Object.keys(setup).sort(), [ + "events", + "notes", + "permissions", + "remediationWriteEnabled", + "version", + ]); +}); diff --git a/tests/github-app-shared-runtime.test.mjs b/tests/github-app-shared-runtime.test.mjs new file mode 100644 index 00000000..0d7627d5 --- /dev/null +++ b/tests/github-app-shared-runtime.test.mjs @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createGitHubAppSharedRuntime } from "@synsec/github/shared-runtime"; +import { + GITHUB_APP_SHARED_STATE_CONTRACT_VERSION, +} from "@synsec/github/shared-state-contract"; +import { REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES } from "@synsec/github/app-deployment"; +import { GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS } from "@synsec/github/shared-state-conformance"; + +function contract() { + return { + contractVersion: GITHUB_APP_SHARED_STATE_CONTRACT_VERSION, + backendId: "postgres-v1", + implementationVersion: "0.2.0", + capabilities: Object.fromEntries(REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES.map((capability) => [capability, true])), + evidence: REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES.map((capability) => ({ + capability, + mechanism: "serializable-transaction", + reference: `conformance-${capability}`, + })), + }; +} + +function conformanceReport(overrides = {}) { + const coveredScenarioIds = GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS.map((scenario) => scenario.id); + return { + schemaVersion: 1, + backendId: "postgres-v1", + implementationVersion: "0.2.0", + complete: true, + scenarioTimeoutMs: 5000, + results: coveredScenarioIds.map((id) => ({ id, status: "passed", durationMs: 1 })), + coverage: { + complete: true, + coveredScenarioIds, + missingScenarioIds: [], + missingCapabilities: [], + }, + ...overrides, + }; +} + +function stores() { + return { + replayStore: { + claim: async () => true, + release: async () => true, + }, + installationStore: { + get: async () => undefined, + put: async (record) => record, + remove: async () => false, + isRepositoryAllowed: async () => true, + }, + queue: { + enqueue: async (job) => ({ ...job, version: 1, jobId: "0".repeat(32), createdAt: new Date(0).toISOString(), attempts: 0, status: "pending" }), + claimNext: async () => undefined, + assertLease: async () => { throw new Error("not called"); }, + release: async () => { throw new Error("not called"); }, + fail: async () => { throw new Error("not called"); }, + complete: async () => false, + }, + }; +} + +function worker() { + return { + config: { schemaVersion: 1, scanners: {} }, + getInstallationToken: async () => "unused", + }; +} + +test("composes shared stores only behind complete identity-bound conformance evidence", () => { + const backendContract = contract(); + const state = stores(); + const runtime = createGitHubAppSharedRuntime({ + backendContract, + conformanceReport: conformanceReport(), + webhookSecret: "s".repeat(32), + ...state, + worker: worker(), + }); + assert.equal(runtime.backendId, "postgres-v1"); + assert.equal(runtime.implementationVersion, "0.2.0"); + assert.equal(typeof runtime.webhookHandler, "function"); + assert.equal(typeof runtime.runWorkerOnce, "function"); +}); + +test("rejects incomplete backend evidence before composing external stores", () => { + const backendContract = contract(); + backendContract.evidence.pop(); + const state = stores(); + assert.throws(() => createGitHubAppSharedRuntime({ + backendContract, + conformanceReport: conformanceReport(), + webhookSecret: "s".repeat(32), + ...state, + worker: worker(), + }), /invalid-backend-contract/); +}); + +test("rejects unversioned or unknown-field backend declarations", () => { + const backendContract = { ...contract(), connectionString: "not-accepted" }; + const state = stores(); + assert.throws(() => createGitHubAppSharedRuntime({ + backendContract, + conformanceReport: conformanceReport(), + webhookSecret: "s".repeat(32), + ...state, + worker: worker(), + }), /invalid-backend-contract/); +}); + +test("rejects stale or missing conformance evidence before stores become active", () => { + const state = stores(); + assert.throws(() => createGitHubAppSharedRuntime({ + backendContract: contract(), + conformanceReport: conformanceReport({ implementationVersion: "0.1.9" }), + webhookSecret: "s".repeat(32), + ...state, + worker: worker(), + }), /implementation-version-mismatch/); + + assert.throws(() => createGitHubAppSharedRuntime({ + backendContract: contract(), + conformanceReport: undefined, + webhookSecret: "s".repeat(32), + ...state, + worker: worker(), + }), /invalid-conformance-report/); +}); diff --git a/tests/github-app-shared-state-conformance.test.mjs b/tests/github-app-shared-state-conformance.test.mjs new file mode 100644 index 00000000..c19e8b08 --- /dev/null +++ b/tests/github-app-shared-state-conformance.test.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS, + assessGitHubAppSharedStateConformanceCoverage, +} from "@synsec/github/shared-state-conformance"; +import { REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES } from "@synsec/github/app-deployment"; + +test("conformance matrix covers every required shared-state capability exactly once", () => { + assert.deepEqual( + GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS.map((scenario) => scenario.capability), + [...REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES], + ); + assert.equal( + new Set(GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS.map((scenario) => scenario.id)).size, + GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS.length, + ); +}); + +test("complete scenario coverage is reported deterministically", () => { + const ids = GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS.map((scenario) => scenario.id).reverse(); + const assessment = assessGitHubAppSharedStateConformanceCoverage(ids); + assert.equal(assessment.complete, true); + assert.deepEqual(assessment.missingScenarioIds, []); + assert.deepEqual(assessment.missingCapabilities, []); + assert.deepEqual(assessment.coveredScenarioIds, GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS.map((scenario) => scenario.id)); +}); + +test("missing scenarios map back to the exact missing capability", () => { + const missing = GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS[3]; + const ids = GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS + .filter((scenario) => scenario.id !== missing.id) + .map((scenario) => scenario.id); + const assessment = assessGitHubAppSharedStateConformanceCoverage(ids); + assert.equal(assessment.complete, false); + assert.deepEqual(assessment.missingScenarioIds, [missing.id]); + assert.deepEqual(assessment.missingCapabilities, [missing.capability]); +}); + +test("unknown and duplicate scenario ids cannot manufacture coverage", () => { + const first = GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS[0]; + const assessment = assessGitHubAppSharedStateConformanceCoverage([ + first.id, + first.id, + "made-up.passes-anyway", + ]); + assert.equal(assessment.complete, false); + assert.deepEqual(assessment.coveredScenarioIds, [first.id]); + assert.equal(assessment.missingScenarioIds.length, GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS.length - 1); +}); diff --git a/tests/github-app-shared-state-contract.test.mjs b/tests/github-app-shared-state-contract.test.mjs new file mode 100644 index 00000000..7d27c445 --- /dev/null +++ b/tests/github-app-shared-state-contract.test.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + GITHUB_APP_SHARED_STATE_CONTRACT_VERSION, + assessGitHubAppSharedStateBackendContract, + assertGitHubAppSharedStateBackendContract, +} from "@synsec/github/shared-state-contract"; +import { REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES } from "@synsec/github/app-deployment"; + +function completeContract() { + return { + contractVersion: GITHUB_APP_SHARED_STATE_CONTRACT_VERSION, + backendId: "postgres-v1", + implementationVersion: "0.2.0", + capabilities: Object.fromEntries(REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES.map((capability) => [capability, true])), + evidence: REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES.map((capability) => ({ + capability, + mechanism: capability === "compareAndSetLeaseRenewal" ? "compare-and-set" : capability === "fencedQueueTransitions" ? "fencing-token" : "serializable-transaction", + reference: `conformance-${capability}`, + })), + }; +} + +test("accepts one complete versioned backend contract", () => { + const contract = completeContract(); + const assessment = assessGitHubAppSharedStateBackendContract(contract); + assert.equal(assessment.ready, true); + assert.deepEqual(assessment.issues, []); + assert.deepEqual(assessment.missingEvidence, []); + assert.doesNotThrow(() => assertGitHubAppSharedStateBackendContract(contract)); +}); + +test("requires implementation evidence for every transactional capability", () => { + const contract = completeContract(); + contract.evidence = contract.evidence.filter((entry) => entry.capability !== "atomicReplayClaim"); + const assessment = assessGitHubAppSharedStateBackendContract(contract); + assert.equal(assessment.ready, false); + assert.deepEqual(assessment.missingEvidence, ["atomicReplayClaim"]); + assert.ok(assessment.issues.some((issue) => issue.code === "missing-capability-evidence" && issue.capability === "atomicReplayClaim")); +}); + +test("rejects duplicate capability evidence instead of counting it twice", () => { + const contract = completeContract(); + contract.evidence[1] = { ...contract.evidence[0] }; + const assessment = assessGitHubAppSharedStateBackendContract(contract); + assert.equal(assessment.ready, false); + assert.ok(assessment.issues.some((issue) => issue.code === "duplicate-evidence")); + assert.ok(assessment.missingEvidence.length >= 1); +}); + +test("rejects false or incomplete capability declarations", () => { + const contract = completeContract(); + contract.capabilities.atomicQueueInsertion = false; + const assessment = assessGitHubAppSharedStateBackendContract(contract); + assert.equal(assessment.ready, false); + assert.ok(assessment.issues.some((issue) => issue.code === "invalid-capabilities")); +}); + +test("rejects credential-like or outbound evidence references", () => { + const contract = completeContract(); + contract.evidence[0].reference = "postgres://user:password@db.example/internal"; + const assessment = assessGitHubAppSharedStateBackendContract(contract); + assert.equal(assessment.ready, false); + assert.ok(assessment.issues.some((issue) => issue.code === "invalid-evidence")); +}); + +test("rejects unknown fields and unsupported contract versions", () => { + const withUnknownField = { ...completeContract(), connectionString: "should-not-be-accepted" }; + assert.deepEqual(assessGitHubAppSharedStateBackendContract(withUnknownField).issues.map((issue) => issue.code), ["invalid-shape"]); + + const wrongVersion = completeContract(); + wrongVersion.contractVersion = 2; + const assessment = assessGitHubAppSharedStateBackendContract(wrongVersion); + assert.equal(assessment.ready, false); + assert.ok(assessment.issues.some((issue) => issue.code === "unsupported-contract-version")); +}); diff --git a/tests/github-app-shared-state-deployment.test.mjs b/tests/github-app-shared-state-deployment.test.mjs new file mode 100644 index 00000000..d36684b3 --- /dev/null +++ b/tests/github-app-shared-state-deployment.test.mjs @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + assessGitHubAppSharedStateCapabilities, + REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES, + validateGitHubAppDeployment, +} from "@synsec/github/app-deployment"; + +const validConfig = { + appId: 12345, + privateKey: "-----BEGIN PRIVATE KEY-----\nZmFrZQ==\n-----END PRIVATE KEY-----", + webhookSecret: "a".repeat(32), + listenHost: "0.0.0.0", + tlsMode: "terminated-upstream", + stateDirectory: "/var/lib/synsec/state", + workspaceDirectory: "/var/lib/synsec/workspaces", + scannerIsolation: { + processBoundary: "container", + cpuLimit: true, + memoryLimit: true, + networkPolicy: "none", + repositoryFilesystem: "read-only", + }, +}; + +const completeCapabilities = { + atomicReplayClaim: true, + atomicQueueInsertion: true, + atomicQueueClaimWithFence: true, + compareAndSetLeaseRenewal: true, + fencedQueueTransitions: true, + transactionalInstallationState: true, + sharedAuthorizationState: true, +}; + +test("single-replica deployments retain the built-in filesystem state contract", () => { + for (const replicaCount of [undefined, 1]) { + const result = validateGitHubAppDeployment({ ...validConfig, replicaCount }); + assert.equal(result.ready, true); + assert.equal(result.issues.some((issue) => issue.code.startsWith("shared-state")), false); + } +}); + +test("multi-replica deployments fail closed on filesystem state", () => { + for (const stateBackend of [undefined, { kind: "filesystem" }]) { + const result = validateGitHubAppDeployment({ + ...validConfig, + replicaCount: 2, + stateBackend, + }); + assert.equal(result.ready, false); + assert.deepEqual( + result.issues.filter((issue) => issue.code.startsWith("shared-state")).map((issue) => issue.code), + ["shared-state-required"], + ); + } +}); + +test("shared-state capability assessment is deterministic and complete", () => { + assert.deepEqual( + REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES, + Object.keys(completeCapabilities), + ); + assert.deepEqual(assessGitHubAppSharedStateCapabilities(completeCapabilities), { + complete: true, + missing: [], + }); + assert.deepEqual(assessGitHubAppSharedStateCapabilities(undefined), { + complete: false, + missing: [...REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES], + }); +}); + +test("multi-replica deployments identify every missing transactional state guarantee", () => { + for (const missingCapability of Object.keys(completeCapabilities)) { + const capabilities = { ...completeCapabilities, [missingCapability]: false }; + const result = validateGitHubAppDeployment({ + ...validConfig, + replicaCount: 3, + stateBackend: { kind: "shared-transactional", capabilities }, + }); + assert.equal(result.ready, false, missingCapability); + const issue = result.issues.find((candidate) => candidate.code === "shared-state-capabilities-incomplete"); + assert.ok(issue, missingCapability); + assert.deepEqual(issue.missingCapabilities, [missingCapability], missingCapability); + assert.equal(issue.message.includes(missingCapability), false, "human message should not require identifier parsing"); + } +}); + +test("shared-state readiness returns all missing capabilities in stable contract order", () => { + const capabilities = { + ...completeCapabilities, + atomicQueueInsertion: false, + compareAndSetLeaseRenewal: false, + sharedAuthorizationState: false, + }; + const assessment = assessGitHubAppSharedStateCapabilities(capabilities); + assert.deepEqual(assessment, { + complete: false, + missing: ["atomicQueueInsertion", "compareAndSetLeaseRenewal", "sharedAuthorizationState"], + }); + + const result = validateGitHubAppDeployment({ + ...validConfig, + replicaCount: 4, + stateBackend: { kind: "shared-transactional", capabilities }, + }); + const issue = result.issues.find((candidate) => candidate.code === "shared-state-capabilities-incomplete"); + assert.ok(issue); + assert.deepEqual(issue.missingCapabilities, assessment.missing); +}); + +test("a complete shared transactional contract permits multiple replicas", () => { + const result = validateGitHubAppDeployment({ + ...validConfig, + replicaCount: 8, + stateBackend: { kind: "shared-transactional", capabilities: completeCapabilities }, + }); + assert.equal(result.ready, true); + assert.deepEqual(result.issues, []); +}); + +test("replica count is bounded before shared-state evaluation", () => { + for (const replicaCount of [0, -1, 1.5, Number.NaN, 1001]) { + const result = validateGitHubAppDeployment({ + ...validConfig, + replicaCount, + stateBackend: { kind: "shared-transactional", capabilities: completeCapabilities }, + }); + assert.equal(result.ready, false); + assert.ok(result.issues.some((issue) => issue.code === "invalid-replica-count")); + assert.equal(result.issues.some((issue) => issue.code.startsWith("shared-state")), false); + } +}); diff --git a/tests/github-app-shared-state-evidence-cli.test.mjs b/tests/github-app-shared-state-evidence-cli.test.mjs new file mode 100644 index 00000000..849ee4e0 --- /dev/null +++ b/tests/github-app-shared-state-evidence-cli.test.mjs @@ -0,0 +1,148 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { execFile } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { + REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES, +} from "@synsec/github/app-deployment"; +import { + GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS, +} from "@synsec/github/shared-state-conformance"; + +const exec = promisify(execFile); +const cli = new URL("../apps/cli/dist/github-app-shared-state-evidence-cli.js", import.meta.url); + +function createContract(overrides = {}) { + return { + contractVersion: 1, + backendId: "postgres-v1", + implementationVersion: "0.2.0-build.42", + capabilities: Object.fromEntries( + REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES.map((capability) => [capability, true]), + ), + evidence: REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES.map((capability) => ({ + capability, + mechanism: "shared-durable-store", + reference: `conformance-${capability}`, + })), + ...overrides, + }; +} + +function createReport(overrides = {}) { + const coveredScenarioIds = GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS.map((scenario) => scenario.id); + return { + schemaVersion: 1, + backendId: "postgres-v1", + implementationVersion: "0.2.0-build.42", + complete: true, + scenarioTimeoutMs: 5000, + results: coveredScenarioIds.map((id) => ({ id, status: "passed", durationMs: 1 })), + coverage: { + complete: true, + coveredScenarioIds, + missingScenarioIds: [], + missingCapabilities: [], + }, + ...overrides, + }; +} + +async function runExpectingExit(args, expectedCode) { + try { + await exec(process.execPath, [cli.pathname, ...args]); + assert.fail(`Expected exit code ${expectedCode}.`); + } catch (error) { + assert.equal(error.code, expectedCode); + return error; + } +} + +test("shared-state evidence CLI accepts complete identity-bound evidence", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-shared-state-evidence-cli-")); + try { + const contractPath = join(root, "contract.json"); + const reportPath = join(root, "report.json"); + await writeFile(contractPath, JSON.stringify(createContract()), "utf8"); + await writeFile(reportPath, JSON.stringify(createReport()), "utf8"); + + const { stdout } = await exec(process.execPath, [cli.pathname, contractPath, reportPath, "--json"]); + const output = JSON.parse(stdout); + assert.equal(output.ready, true); + assert.deepEqual(output.issues, []); + assert.deepEqual(output.missingScenarioIds, []); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("shared-state evidence CLI exits 2 on stale adapter evidence", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-shared-state-evidence-stale-")); + try { + const contractPath = join(root, "contract.json"); + const reportPath = join(root, "report.json"); + await writeFile(contractPath, JSON.stringify(createContract({ implementationVersion: "0.2.0-build.43" })), "utf8"); + await writeFile(reportPath, JSON.stringify(createReport()), "utf8"); + + const error = await runExpectingExit([contractPath, reportPath, "--json"], 2); + const output = JSON.parse(error.stdout); + assert.equal(output.ready, false); + assert.deepEqual(output.issues.map((issue) => issue.code), ["implementation-version-mismatch"]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("shared-state evidence CLI does not echo credential-shaped invalid contract values", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-shared-state-evidence-secret-")); + try { + const contractPath = join(root, "contract.json"); + const reportPath = join(root, "report.json"); + const secret = "postgres://user:must-not-echo@db.internal/synsec"; + await writeFile(contractPath, JSON.stringify(createContract({ backendId: secret })), "utf8"); + await writeFile(reportPath, JSON.stringify(createReport()), "utf8"); + + const error = await runExpectingExit([contractPath, reportPath, "--json"], 2); + assert.doesNotMatch(error.stdout, /must-not-echo|db\.internal/); + assert.doesNotMatch(error.stderr, /must-not-echo|db\.internal/); + assert.equal(JSON.parse(error.stdout).issues[0].code, "invalid-backend-contract"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("shared-state evidence CLI rejects malformed JSON without echoing file contents", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-shared-state-evidence-json-")); + try { + const contractPath = join(root, "contract.json"); + const reportPath = join(root, "report.json"); + await writeFile(contractPath, '{"password":"must-not-echo"', "utf8"); + await writeFile(reportPath, JSON.stringify(createReport()), "utf8"); + + const error = await runExpectingExit([contractPath, reportPath], 1); + assert.match(error.stderr, /must contain valid JSON/); + assert.doesNotMatch(error.stderr, /must-not-echo/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("shared-state evidence CLI rejects unsupported or duplicate flags", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-shared-state-evidence-flags-")); + try { + const contractPath = join(root, "contract.json"); + const reportPath = join(root, "report.json"); + await writeFile(contractPath, JSON.stringify(createContract()), "utf8"); + await writeFile(reportPath, JSON.stringify(createReport()), "utf8"); + + let error = await runExpectingExit([contractPath, reportPath, "--jsno"], 1); + assert.match(error.stderr, /Usage:/); + error = await runExpectingExit([contractPath, reportPath, "--json", "--json"], 1); + assert.match(error.stderr, /Usage:/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/github-app-status.test.mjs b/tests/github-app-status.test.mjs new file mode 100644 index 00000000..256c1e2b --- /dev/null +++ b/tests/github-app-status.test.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { FileGitHubInstallationStore } from "@synsec/github/installation-store"; +import { FileGitHubScanQueue } from "@synsec/github/scan-queue"; +import { buildGitHubAppRuntimeStatus } from "@synsec/github/app-status"; + +test("runtime status exposes aggregate installation and queue posture only", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-status-")); + const installationStore = new FileGitHubInstallationStore(join(root, "installations")); + const queue = new FileGitHubScanQueue(join(root, "queue")); + + await installationStore.put({ + installationId: 11, accountLogin: "sensitive-org", accountType: "Organization", repositorySelection: "all", + }); + await installationStore.put({ + installationId: 12, accountLogin: "other-org", accountType: "Organization", repositorySelection: "selected", + repositories: ["private/example"], suspendedAt: "2026-08-22T20:00:00.000Z", + }); + + await queue.enqueue({ deliveryId: "delivery-sensitive", installationId: 11, repository: "private/example", headSha: "a".repeat(40), event: "push" }); + await queue.enqueue({ deliveryId: "delivery-failed", installationId: 11, repository: "private/failed", headSha: "b".repeat(40), event: "push" }); + const leased = await queue.claimNext(); + await queue.fail(leased.jobId, leased.leaseId); + + const status = await buildGitHubAppRuntimeStatus({ installationStore, queue }); + assert.deepEqual(status, { + installations: { total: 2, active: 1, suspended: 1, allRepositories: 1, selectedRepositories: 1 }, + queue: { total: 2, pending: 1, leased: 0, expiredLeases: 0, failed: 1 }, + }); + + const serialized = JSON.stringify(status); + for (const secret of ["sensitive-org", "other-org", "private/example", "delivery-sensitive", "a".repeat(40)]) { + assert.equal(serialized.includes(secret), false); + } +}); + +test("runtime status surfaces expired leases without exposing job identity", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-status-expired-")); + const now = Date.parse("2026-08-22T21:00:00.000Z"); + const installationStore = new FileGitHubInstallationStore(join(root, "installations")); + const queue = new FileGitHubScanQueue(join(root, "queue"), { now: () => now - 20_000, leaseMs: 10_000 }); + + await queue.enqueue({ + deliveryId: "expired-sensitive-delivery", + installationId: 77, + repository: "private/expired-repository", + headSha: "c".repeat(40), + event: "push", + }); + const leased = await queue.claimNext(); + assert.equal(leased.status, "leased"); + + const status = await buildGitHubAppRuntimeStatus({ installationStore, queue, now: () => now }); + assert.deepEqual(status.queue, { total: 1, pending: 0, leased: 1, expiredLeases: 1, failed: 0 }); + const serialized = JSON.stringify(status); + assert.equal(serialized.includes("private/expired-repository"), false); + assert.equal(serialized.includes("expired-sensitive-delivery"), false); + assert.equal(serialized.includes(leased.jobId), false); + assert.equal(serialized.includes(leased.leaseId), false); +}); diff --git a/tests/github-app-token-provider.test.mjs b/tests/github-app-token-provider.test.mjs new file mode 100644 index 00000000..da27e432 --- /dev/null +++ b/tests/github-app-token-provider.test.mjs @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import { generateKeyPairSync } from "node:crypto"; +import test from "node:test"; + +import { createGitHubAppInstallationTokenProvider } from "@synsec/github/app-token-provider"; + +function privateKeyPem() { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + return privateKey.export({ type: "pkcs8", format: "pem" }); +} + +test("App token provider signs a fresh short-lived JWT per operation without caching installation tokens", async () => { + let now = Date.UTC(2026, 7, 22, 19, 30, 0); + const exchanges = []; + const provider = createGitHubAppInstallationTokenProvider({ + appId: 12345, + privateKey: privateKeyPem(), + now: () => now, + exchange: async (installationId, jwt) => { + const payload = JSON.parse(Buffer.from(jwt.split(".")[1], "base64url").toString("utf8")); + exchanges.push({ installationId, jwt, payload }); + return { + token: `installation-token-${exchanges.length}`, + expiresAt: new Date(now + 60 * 60 * 1000).toISOString(), + }; + }, + }); + + assert.equal(await provider(42), "installation-token-1"); + now += 1_000; + assert.equal(await provider(42), "installation-token-2"); + assert.equal(exchanges.length, 2); + assert.notEqual(exchanges[0].jwt, exchanges[1].jwt); + assert.equal(exchanges[0].payload.iss, "12345"); + assert.equal(exchanges[1].payload.iat - exchanges[0].payload.iat, 1); +}); + +test("App token provider enforces purpose-specific installation permissions before returning credentials", async () => { + const now = Date.UTC(2026, 7, 22, 19, 30, 0); + let permissions = { contents: "read", checks: "write", security_events: "write" }; + const provider = createGitHubAppInstallationTokenProvider({ + appId: 1, + privateKey: privateKeyPem(), + now: () => now, + requiredPermissionsByPurpose: { + acquire: { contents: "read" }, + publish: { checks: "write", security_events: "write" }, + }, + exchange: async () => ({ + token: "transport-secret", + expiresAt: new Date(now + 60 * 60 * 1000).toISOString(), + permissions, + }), + }); + + assert.equal(await provider(1, "acquire"), "transport-secret"); + assert.equal(await provider(1, "publish"), "transport-secret"); + permissions = { contents: "read", checks: "read", security_events: "write" }; + await assert.rejects(() => provider(1, "publish"), /checks:write/); + permissions = undefined; + await assert.rejects(() => provider(1, "acquire"), /missing permission metadata/); +}); + +test("write permission satisfies a read requirement without weakening write requirements", async () => { + const now = Date.UTC(2026, 7, 22, 19, 30, 0); + const provider = createGitHubAppInstallationTokenProvider({ + appId: 1, + privateKey: privateKeyPem(), + now: () => now, + requiredPermissionsByPurpose: { acquire: { contents: "read" } }, + exchange: async () => ({ + token: "transport-secret", + expiresAt: new Date(now + 60 * 60 * 1000).toISOString(), + permissions: { contents: "write" }, + }), + }); + assert.equal(await provider(1, "acquire"), "transport-secret"); +}); + +test("App token provider rejects malformed or nearly expired token metadata", async () => { + const now = Date.UTC(2026, 7, 22, 19, 30, 0); + const key = privateKeyPem(); + const invalidExpiry = createGitHubAppInstallationTokenProvider({ + appId: 1, + privateKey: key, + now: () => now, + exchange: async () => ({ token: "secret", expiresAt: "not-a-time" }), + }); + await assert.rejects(() => invalidExpiry(1), /invalid expiration timestamp/); + + const expiring = createGitHubAppInstallationTokenProvider({ + appId: 1, + privateKey: key, + now: () => now, + minRemainingMs: 30_000, + exchange: async () => ({ token: "secret", expiresAt: new Date(now + 29_999).toISOString() }), + }); + await assert.rejects(() => expiring(1), /expires too soon/); +}); + +test("App token provider redacts credentials from installation-token exchange failures", async () => { + const now = Date.UTC(2026, 7, 22, 19, 30, 0); + const githubToken = `ghs_${"c".repeat(36)}`; + const provider = createGitHubAppInstallationTokenProvider({ + appId: 1, + privateKey: privateKeyPem(), + now: () => now, + exchange: async () => { + throw new Error(`token exchange failed Authorization: Bearer ${githubToken} via https://proxy-user:proxy-password@proxy.internal`); + }, + }); + + let failure; + try { + await provider(1, "acquire"); + } catch (error) { + failure = error; + } + assert.ok(failure instanceof Error); + assert.match(failure.message, /token exchange failed/); + assert.equal(failure.message.includes(githubToken), false); + assert.equal(failure.message.includes("proxy-password"), false); +}); + +test("App token provider bounds private-key, lifetime, and permission configuration before exchange", () => { + assert.throws(() => createGitHubAppInstallationTokenProvider({ + appId: 1, + privateKey: "x".repeat(64 * 1024 + 1), + }), /private key exceeds/); + assert.throws(() => createGitHubAppInstallationTokenProvider({ + appId: 1, + privateKey: privateKeyPem(), + minRemainingMs: 600_001, + }), /minimum remaining lifetime/); + assert.throws(() => createGitHubAppInstallationTokenProvider({ + appId: 1, + privateKey: privateKeyPem(), + requiredPermissionsByPurpose: { publish: { "checks/write": "write" } }, + }), /invalid permission name/); +}); diff --git a/tests/github-app-upgrade.test.mjs b/tests/github-app-upgrade.test.mjs new file mode 100644 index 00000000..07a70273 --- /dev/null +++ b/tests/github-app-upgrade.test.mjs @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { assessSynSecGitHubAppUpgrade } from "@synsec/github/app-upgrade"; + +function replica(overrides = {}) { + return { + replicaId: "synsec-0", + releaseId: "0.2.0-old", + schemaVersion: 1, + ready: true, + acceptingWorkerRuns: false, + activeLeases: 0, + observedAt: "2026-08-25T03:00:00.000Z", + ...overrides, + }; +} + +function base(overrides = {}) { + return { + currentReleaseId: "0.2.0-old", + targetReleaseId: "0.2.0-new", + currentSchemaVersion: 1, + targetSchemaVersion: 1, + expectedReplicaIds: ["synsec-0", "synsec-1"], + replicas: [ + replica(), + replica({ replicaId: "synsec-1" }), + ], + assessedAt: "2026-08-25T03:01:00.000Z", + previousReleaseAvailable: true, + rollbackSchemaCompatible: true, + ...overrides, + }; +} + +test("rolling upgrade can begin only from an exact healthy drained previous fleet", () => { + const result = assessSynSecGitHubAppUpgrade(base()); + assert.equal(result.readyToBeginRollout, true); + assert.equal(result.readyToFinalizeRollout, false); + assert.equal(result.rollbackAllowed, true); + assert.equal(result.previousReplicaCount, 2); + assert.equal(result.targetReplicaCount, 0); +}); + +test("rolling upgrade can finalize only when the exact fleet is healthy, drained, and on the target release/schema", () => { + const result = assessSynSecGitHubAppUpgrade(base({ + replicas: [ + replica({ releaseId: "0.2.0-new" }), + replica({ replicaId: "synsec-1", releaseId: "0.2.0-new" }), + ], + })); + assert.equal(result.readyToBeginRollout, false); + assert.equal(result.readyToFinalizeRollout, true); + assert.equal(result.targetReplicaCount, 2); + assert.equal(result.previousReplicaCount, 0); +}); + +test("zero durable leases do not count as drained while worker admission remains open", () => { + const result = assessSynSecGitHubAppUpgrade(base({ + replicas: [ + replica({ acceptingWorkerRuns: true, activeLeases: 0 }), + replica({ replicaId: "synsec-1" }), + ], + })); + assert.equal(result.readyToBeginRollout, false); + assert.equal(result.readyToFinalizeRollout, false); + assert.ok(result.issues.some((issue) => issue.code === "worker-admission-open" && issue.replicaId === "synsec-0")); +}); + +test("active leases prevent both rollout start and finalization", () => { + const result = assessSynSecGitHubAppUpgrade(base({ + replicas: [ + replica({ activeLeases: 1 }), + replica({ replicaId: "synsec-1" }), + ], + })); + assert.equal(result.readyToBeginRollout, false); + assert.equal(result.readyToFinalizeRollout, false); + assert.ok(result.issues.some((issue) => issue.code === "active-work-remains" && issue.replicaId === "synsec-0")); +}); + +test("schema-changing rollout cannot begin when rollback compatibility is not explicitly available", () => { + const result = assessSynSecGitHubAppUpgrade(base({ + currentSchemaVersion: 1, + targetSchemaVersion: 2, + replicas: [ + replica({ schemaVersion: 1 }), + replica({ replicaId: "synsec-1", schemaVersion: 1 }), + ], + rollbackSchemaCompatible: false, + })); + assert.equal(result.rollbackAllowed, false); + assert.equal(result.readyToBeginRollout, false); + assert.ok(result.issues.some((issue) => issue.code === "rollback-schema-incompatible")); +}); + +test("stale, duplicate, missing, or unexpected replica observations fail closed", () => { + const result = assessSynSecGitHubAppUpgrade(base({ + replicas: [ + replica({ observedAt: "2026-08-25T02:00:00.000Z" }), + replica({ replicaId: "synsec-0" }), + replica({ replicaId: "synsec-extra" }), + ], + })); + assert.equal(result.readyToBeginRollout, false); + assert.equal(result.readyToFinalizeRollout, false); + assert.ok(result.issues.some((issue) => issue.code === "stale-observation")); + assert.ok(result.issues.some((issue) => issue.code === "duplicate-replica")); + assert.ok(result.issues.some((issue) => issue.code === "unexpected-replica")); + assert.ok(result.issues.some((issue) => issue.code === "missing-replica" && issue.replicaId === "synsec-1")); +}); + +test("target replicas reporting the wrong schema cannot finalize", () => { + const result = assessSynSecGitHubAppUpgrade(base({ + targetSchemaVersion: 2, + replicas: [ + replica({ releaseId: "0.2.0-new", schemaVersion: 1 }), + replica({ replicaId: "synsec-1", releaseId: "0.2.0-new", schemaVersion: 2 }), + ], + })); + assert.equal(result.readyToFinalizeRollout, false); + assert.ok(result.issues.some((issue) => issue.code === "target-schema-mismatch" && issue.replicaId === "synsec-0")); +}); + +test("assessment output is secret-free categorical metadata", () => { + const result = assessSynSecGitHubAppUpgrade(base({ previousReleaseAvailable: false })); + const serialized = JSON.stringify(result); + assert.doesNotMatch(serialized, /private.?key|webhook|postgresql:\/\//i); + assert.ok(result.issues.some((issue) => issue.code === "previous-release-unavailable")); +}); diff --git a/tests/github-app-worker-drain.test.mjs b/tests/github-app-worker-drain.test.mjs new file mode 100644 index 00000000..b41d4969 --- /dev/null +++ b/tests/github-app-worker-drain.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createSynSecGitHubAppWorkerDrainController } from "@synsec/github/app-worker-drain"; +import { runConfiguredGitHubAppWorkerOnce } from "@synsec/github/app-worker-runner"; + +function deferred() { + let resolve; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +test("worker drain closes new admission while allowing an admitted operation to finish", async () => { + const controller = createSynSecGitHubAppWorkerDrainController(); + const entered = deferred(); + const finish = deferred(); + + const active = controller.run(async () => { + entered.resolve(); + await finish.promise; + return "complete"; + }); + await entered.promise; + + assert.deepEqual(controller.beginDrain(), { acceptingWorkerRuns: false, activeWorkerRuns: 1 }); + const rejected = await controller.run(async () => { throw new Error("draining operation must not execute"); }); + assert.deepEqual(rejected, { admitted: false }); + + const drained = controller.waitForDrained(); + finish.resolve(); + assert.deepEqual(await active, { admitted: true, value: "complete" }); + await drained; + assert.deepEqual(controller.status(), { acceptingWorkerRuns: false, activeWorkerRuns: 0 }); + + controller.resumeAdmission(); + assert.deepEqual(await controller.run(async () => "resumed"), { admitted: true, value: "resumed" }); +}); + +test("waitForDrained fails closed while worker admission remains open", async () => { + const controller = createSynSecGitHubAppWorkerDrainController(); + await assert.rejects(controller.waitForDrained(), /admission must be draining/); +}); + +test("configured worker checks drain before queue claim admission", async () => { + const controller = createSynSecGitHubAppWorkerDrainController(); + controller.beginDrain(); + let claims = 0; + + const result = await runConfiguredGitHubAppWorkerOnce({ + workerDrain: controller, + queue: { + async claimNext() { claims += 1; return undefined; }, + async assertLease() { throw new Error("must not assert lease"); }, + async release() { throw new Error("must not release"); }, + async fail() { throw new Error("must not fail"); }, + async complete() { throw new Error("must not complete"); }, + }, + installationStore: { isRepositoryAllowed: async () => true }, + config: { scanners: ["opengrep"], parallelism: 1 }, + getInstallationToken: async () => { throw new Error("must not request token"); }, + }); + + assert.deepEqual(result, { status: "draining" }); + assert.equal(claims, 0); +}); diff --git a/tests/github-app-worker-error-redaction.test.mjs b/tests/github-app-worker-error-redaction.test.mjs new file mode 100644 index 00000000..0cd4208b --- /dev/null +++ b/tests/github-app-worker-error-redaction.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { runNextGitHubAppScanJob } from "@synsec/github/app-worker"; + +const job = { + version: 1, + jobId: "1".repeat(32), + deliveryId: "delivery-redaction", + installationId: 123, + repository: "owner/repo", + headSha: "a".repeat(40), + event: "push", + createdAt: "2026-08-23T00:00:00.000Z", + attempts: 1, + status: "leased", + leaseUntil: "2099-01-01T00:00:00.000Z", + leaseId: "2".repeat(32), +}; + +function queue() { + return { + async claimNext() { return job; }, + async assertLease() { return job; }, + async release() { return { ...job, status: "pending", leaseUntil: undefined, leaseId: undefined }; }, + async fail() { return { ...job, status: "failed", leaseUntil: undefined, leaseId: undefined }; }, + async complete() { return true; }, + }; +} + +test("hosted worker redacts credentials from retry errors", async () => { + const token = `ghp_${"A".repeat(40)}`; + const password = "super-secret-password"; + const result = await runNextGitHubAppScanJob({ + queue: queue(), + installationStore: { async isRepositoryAllowed() { return true; } }, + async getInstallationToken() { return token; }, + async acquire() { + throw new Error(`Authorization: Bearer ${token} url=https://user:${password}@example.test/repo.git`); + }, + async scan() { throw new Error("scan should not run"); }, + async publish() { throw new Error("publish should not run"); }, + }); + + assert.equal(result.status, "retry_scheduled"); + assert.equal(result.error.includes(token), false); + assert.equal(result.error.includes(password), false); + assert.match(result.error, /REDACTED/); +}); + +test("hosted worker redacts credentials when queue release also fails", async () => { + const token = `github_pat_${"B".repeat(40)}`; + const failingQueue = queue(); + failingQueue.release = async () => { + throw new Error(`api_key=${token}`); + }; + + await assert.rejects( + () => runNextGitHubAppScanJob({ + queue: failingQueue, + installationStore: { async isRepositoryAllowed() { return true; } }, + async getInstallationToken() { return token; }, + async acquire() { throw new Error(`Authorization: Bearer ${token}`); }, + async scan() { throw new Error("scan should not run"); }, + async publish() { throw new Error("publish should not run"); }, + }), + (error) => { + assert.equal(error.message.includes(token), false); + assert.match(error.message, /REDACTED/); + return true; + }, + ); +}); diff --git a/tests/github-app-worker-heartbeat.test.mjs b/tests/github-app-worker-heartbeat.test.mjs new file mode 100644 index 00000000..01406b2f --- /dev/null +++ b/tests/github-app-worker-heartbeat.test.mjs @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { setTimeout as delay } from "node:timers/promises"; +import { runNextGitHubAppScanJob } from "@synsec/github/app-worker"; + +const headSha = "0123456789abcdef0123456789abcdef01234567"; +const leaseId = "9".repeat(32); + +function leasedJob() { + return { + version: 1, + jobId: "8".repeat(32), + deliveryId: "heartbeat-delivery", + installationId: 42, + repository: "cmahmud/synsec", + headSha, + event: "push", + createdAt: "2026-08-22T21:00:00.000Z", + attempts: 1, + status: "leased", + leaseUntil: "2026-08-22T21:05:00.000Z", + leaseId, + }; +} + +function report() { + return { + schemaVersion: "1.0", + reportId: "heartbeat-report", + generatedAt: "2026-08-22T21:00:01.000Z", + toolVersion: "0.2.0", + target: { path: "/tmp/repo", commitSha: headSha }, + scanners: [], + rawFindingCount: 0, + findingCount: 0, + summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0, unknown: 0 }, + securityScore: 100, + findings: [], + }; +} + +test("worker renews its exact lease while a scan remains active", async () => { + const job = leasedJob(); + let renewals = 0; + let published = 0; + const queue = { + leaseMs: 3_000, + async claimNext() { return job; }, + async assertLease(id, expectedLeaseId) { + assert.equal(id, job.jobId); + assert.equal(expectedLeaseId, job.leaseId); + return job; + }, + async renew(id, expectedLeaseId) { + assert.equal(id, job.jobId); + assert.equal(expectedLeaseId, job.leaseId); + renewals += 1; + return job; + }, + async release() { throw new Error("must not release successful job"); }, + async fail() { throw new Error("must not fail successful job"); }, + async complete(id, expectedLeaseId) { + assert.equal(id, job.jobId); + assert.equal(expectedLeaseId, job.leaseId); + return true; + }, + }; + + const result = await runNextGitHubAppScanJob({ + queue, + installationStore: { isRepositoryAllowed: async () => true }, + getInstallationToken: async () => "transport-token", + acquire: async (input) => ({ + repository: input.repository, + commitSha: input.commitSha, + workspace: "/tmp/repo", + cleanup: async () => {}, + }), + scan: async () => { + await delay(1_100); + return report(); + }, + publish: async () => { published += 1; }, + }); + + assert.equal(result.status, "completed"); + assert.ok(renewals >= 1); + assert.equal(published, 1); +}); diff --git a/tests/github-app-worker-incremental.test.mjs b/tests/github-app-worker-incremental.test.mjs new file mode 100644 index 00000000..a4b7b7c5 --- /dev/null +++ b/tests/github-app-worker-incremental.test.mjs @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { runConfiguredGitHubAppWorkerOnce } from "@synsec/github/app-worker-runner"; + +const headSha = "0123456789abcdef0123456789abcdef01234567"; +const baseSha = "abcdef0123456789abcdef0123456789abcdef01"; +const leaseId = "e".repeat(32); + +function report(commitSha, baseline = false, scope) { + return { + schemaVersion: "1.0", + reportId: `report-${commitSha.slice(0, 8)}`, + generatedAt: "2026-08-22T19:00:00.000Z", + toolVersion: "0.2.0", + target: { path: "/tmp/repo", commitSha }, + scanners: [], + rawFindingCount: 0, + findingCount: 0, + summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0, unknown: 0 }, + securityScore: 100, + findings: [], + ...(scope ? { scope } : {}), + ...(baseline ? { baseline: { new: [], fixed: [], persisting: [] } } : {}), + }; +} + +function job() { + return { + version: 1, + jobId: "c".repeat(32), + deliveryId: "delivery-incremental-worker", + installationId: 42, + repository: "cmahmud/synsec", + headSha, + event: "pull_request", + baseSha, + pullRequestNumber: 2, + createdAt: "2026-08-22T19:00:00.000Z", + attempts: 1, + status: "leased", + leaseUntil: "2026-08-22T19:05:00.000Z", + leaseId, + }; +} + +function queueFor(jobValue) { + return { + async claimNext() { return jobValue; }, + async assertLease(id, expectedLeaseId) { + assert.equal(id, jobValue.jobId); + assert.equal(expectedLeaseId, jobValue.leaseId); + return jobValue; + }, + async release() { throw new Error("must not release successful job"); }, + async fail() { throw new Error("must not fail successful job"); }, + async complete(id, expectedLeaseId) { + assert.equal(id, jobValue.jobId); + assert.equal(expectedLeaseId, jobValue.leaseId); + return true; + }, + }; +} + +function acquisition(input) { + return { + repository: input.repository, + commitSha: input.commitSha, + workspace: "/tmp/head", + base: { commitSha: input.baseCommitSha, workspace: "/tmp/base" }, + cleanup: async () => {}, + }; +} + +const checkFetch = async (url) => { + if (url.endsWith("/check-runs")) return new Response(JSON.stringify({ id: 1 }), { status: 201 }); + if (url.endsWith("/code-scanning/sarifs")) return new Response(JSON.stringify({ id: "sarif" }), { status: 202 }); + throw new Error(`unexpected URL ${url}`); +}; + +test("hosted PR worker passes exact changed paths and base SHA into the head scan", async () => { + const scanInputs = []; + const result = await runConfiguredGitHubAppWorkerOnce({ + queue: queueFor(job()), + installationStore: { isRepositoryAllowed: async () => true }, + config: { scanners: ["opengrep"], parallelism: 1 }, + getInstallationToken: async () => "token", + acquire: async (input) => acquisition(input), + deriveChangedFiles: async () => ({ + mode: "changed-files", reason: "exact-tree-diff", changedFiles: ["src/a.ts", "src/b.ts"], deletedFiles: [], + interpretation: "exact-commit-tree-comparison-with-conservative-full-scan-fallback", + }), + scan: async (input) => { + scanInputs.push(input); + const isBase = input.rootPath === "/tmp/base"; + return { + report: report(isBase ? baseSha : headSha, Boolean(input.baseline), input.changedOnly + ? { mode: "changed-files", baseRef: input.changedBase, changedFiles: [...input.changedFiles] } + : { mode: "repository" }), + repositoryIndex: { schemaVersion: 1, generatedAt: "2026-08-22T19:00:00.000Z", indexedFileCount: 0, moduleEdges: [], routes: [], authSignals: [], sinks: [] }, + statuses: [], failures: [], shouldFail: false, + }; + }, + fetch: checkFetch, + }); + + assert.equal(result.status, "completed"); + assert.equal(scanInputs.length, 2); + assert.equal(scanInputs[0].changedOnly, false); + assert.equal(scanInputs[1].changedOnly, true); + assert.equal(scanInputs[1].changedBase, baseSha); + assert.deepEqual(scanInputs[1].changedFiles, ["src/a.ts", "src/b.ts"]); +}); + +test("hosted PR worker keeps SARIF publication on a full head scan even when an exact diff exists", async () => { + const scanInputs = []; + const result = await runConfiguredGitHubAppWorkerOnce({ + queue: queueFor(job()), + installationStore: { isRepositoryAllowed: async () => true }, + config: { scanners: ["opengrep"], parallelism: 1 }, + getInstallationToken: async () => "token", + acquire: async (input) => acquisition(input), + deriveChangedFiles: async () => ({ + mode: "changed-files", reason: "exact-tree-diff", changedFiles: ["src/a.ts"], deletedFiles: [], + interpretation: "exact-commit-tree-comparison-with-conservative-full-scan-fallback", + }), + scan: async (input) => { + scanInputs.push(input); + const isBase = input.rootPath === "/tmp/base"; + return { + report: report(isBase ? baseSha : headSha, Boolean(input.baseline), { mode: "repository" }), + repositoryIndex: { schemaVersion: 1, generatedAt: "2026-08-22T19:00:00.000Z", indexedFileCount: 0, moduleEdges: [], routes: [], authSignals: [], sinks: [] }, + statuses: [], failures: [], shouldFail: false, + }; + }, + publishSarif: true, + fetch: checkFetch, + }); + + assert.equal(result.status, "completed"); + assert.equal(scanInputs[1].changedOnly, false); + assert.equal(scanInputs[1].changedFiles, undefined); +}); diff --git a/tests/github-app-worker-runner.test.mjs b/tests/github-app-worker-runner.test.mjs new file mode 100644 index 00000000..6adf4544 --- /dev/null +++ b/tests/github-app-worker-runner.test.mjs @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { runConfiguredGitHubAppWorkerOnce } from "@synsec/github/app-worker-runner"; + +const headSha = "0123456789abcdef0123456789abcdef01234567"; +const baseSha = "abcdef0123456789abcdef0123456789abcdef01"; +const leaseId = "f".repeat(32); + +function report(commitSha, baseline) { + return { + schemaVersion: "1.0", + reportId: `configured-worker-report-${commitSha.slice(0, 8)}`, + generatedAt: "2026-08-22T19:00:00.000Z", + toolVersion: "0.2.0", + target: { path: "/tmp/acquired", commitSha }, + scanners: [], rawFindingCount: 0, findingCount: 0, + summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0, unknown: 0 }, + securityScore: 100, findings: [], + ...(baseline ? { baseline: { new: [], fixed: [], persisting: [] } } : {}), + }; +} + +function leasedPrJob() { + return { + version: 1, jobId: "b".repeat(32), deliveryId: "delivery-configured-worker", installationId: 42, + repository: "cmahmud/synsec", headSha, event: "pull_request", baseSha, pullRequestNumber: 2, + createdAt: "2026-08-22T19:00:00.000Z", attempts: 1, status: "leased", + leaseUntil: "2026-08-22T19:05:00.000Z", leaseId, + }; +} + +test("configured PR worker scans exact base then head and publishes one baseline-aware report", async () => { + const job = leasedPrJob(); + const completed = []; + const scanInputs = []; + const tokenPurposes = []; + const requests = []; + let cleanupCalls = 0; + const queue = { + async claimNext() { return job; }, + async assertLease(id, expectedLeaseId) { + assert.equal(id, job.jobId); + assert.equal(expectedLeaseId, job.leaseId); + return job; + }, + async release() { throw new Error("must not release successful job"); }, + async fail() { throw new Error("must not fail successful job"); }, + async complete(id, expectedLeaseId) { + assert.equal(expectedLeaseId, job.leaseId); + completed.push(id); + return true; + }, + }; + const fakeFetch = async (url, init) => { + requests.push({ url, init }); + if (url.endsWith("/check-runs")) return new Response(JSON.stringify({ id: 123, status: "completed", conclusion: "success" }), { status: 201 }); + if (url.endsWith("/code-scanning/sarifs")) return new Response(JSON.stringify({ id: "sarif-upload-1" }), { status: 202 }); + throw new Error(`unexpected publication URL: ${url}`); + }; + + const result = await runConfiguredGitHubAppWorkerOnce({ + queue, + installationStore: { isRepositoryAllowed: async () => true }, + config: { scanners: ["opengrep"], parallelism: 1 }, + getInstallationToken: async (_installationId, purpose) => { + tokenPurposes.push(purpose); + return purpose === "acquire" ? "acquire-token" : "publish-token"; + }, + acquire: async (input) => ({ + repository: input.repository, commitSha: input.commitSha, workspace: "/tmp/acquired-head", + base: { commitSha: input.baseCommitSha, workspace: "/tmp/acquired-base" }, + cleanup: async () => { cleanupCalls += 1; }, + }), + scan: async (input) => { + scanInputs.push(input); + const isBase = input.rootPath === "/tmp/acquired-base"; + return { + report: report(isBase ? baseSha : headSha, Boolean(input.baseline)), + repositoryIndex: { version: 1, root: input.rootPath, files: [] }, + statuses: [], failures: [], shouldFail: false, + }; + }, + publishSarif: true, + fetch: fakeFetch, + }); + + assert.equal(result.status, "completed"); + assert.deepEqual(completed, [job.jobId]); + assert.equal(scanInputs.length, 2); + assert.equal(scanInputs[0].rootPath, "/tmp/acquired-base"); + assert.equal(scanInputs[0].baseline, undefined); + assert.equal(scanInputs[0].changedOnly, false); + assert.equal(scanInputs[1].rootPath, "/tmp/acquired-head"); + assert.equal(scanInputs[1].baseline.target.commitSha, baseSha); + assert.equal(scanInputs[1].changedOnly, false); + assert.deepEqual(tokenPurposes, ["acquire", "publish"]); + assert.equal(requests.length, 2); + assert.equal(requests[0].url, "https://api.github.com/repos/cmahmud/synsec/check-runs"); + assert.equal(requests[1].url, "https://api.github.com/repos/cmahmud/synsec/code-scanning/sarifs"); + const checkBody = JSON.parse(requests[0].init.body); + assert.equal(checkBody.head_sha, headSha); + assert.match(checkBody.output.summary, /New:/); + const sarifBody = JSON.parse(requests[1].init.body); + assert.equal(sarifBody.commit_sha, headSha); + assert.equal(sarifBody.ref, "refs/pull/2/head"); + assert.equal(requests.some((request) => request.url.includes("attacker.invalid")), false); + assert.equal(cleanupCalls, 1); +}); + +test("configured PR worker refuses a baseline report that does not bind to the queued base", async () => { + const job = leasedPrJob(); + const releases = []; + const result = await runConfiguredGitHubAppWorkerOnce({ + queue: { + async claimNext() { return job; }, + async assertLease() { return job; }, + async release(id, expectedLeaseId) { + assert.equal(expectedLeaseId, job.leaseId); + releases.push(id); + return { ...job, status: "pending", leaseUntil: undefined, leaseId: undefined }; + }, + async fail() { throw new Error("must not fail"); }, + async complete() { throw new Error("must not complete"); }, + }, + installationStore: { isRepositoryAllowed: async () => true }, + config: { scanners: ["opengrep"], parallelism: 1 }, + getInstallationToken: async () => "token", + acquire: async (input) => ({ + repository: input.repository, commitSha: input.commitSha, workspace: "/tmp/acquired-head", + base: { commitSha: baseSha, workspace: "/tmp/acquired-base" }, cleanup: async () => {}, + }), + scan: async () => ({ + report: report(headSha), repositoryIndex: { version: 1, root: "/tmp", files: [] }, + statuses: [], failures: [], shouldFail: false, + }), + }); + + assert.equal(result.status, "retry_scheduled"); + assert.match(result.error, /baseline report commit does not match/); + assert.deepEqual(releases, [job.jobId]); +}); diff --git a/tests/github-app-worker.test.mjs b/tests/github-app-worker.test.mjs new file mode 100644 index 00000000..48fec431 --- /dev/null +++ b/tests/github-app-worker.test.mjs @@ -0,0 +1,241 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { runNextGitHubAppScanJob } from "@synsec/github/app-worker"; + +const headSha = "0123456789abcdef0123456789abcdef01234567"; +const leaseId = "d".repeat(32); + +function job() { + return { + version: 1, + jobId: "a".repeat(32), + deliveryId: "delivery-worker-1", + installationId: 42, + repository: "cmahmud/synsec", + headSha, + event: "push", + createdAt: "2026-08-22T18:45:00.000Z", + attempts: 1, + status: "leased", + leaseUntil: "2026-08-22T18:50:00.000Z", + leaseId, + }; +} + +function report(commitSha = headSha) { + return { + schemaVersion: "1.0", + reportId: "report-1", + generatedAt: "2026-08-22T18:46:00.000Z", + toolVersion: "0.2.0", + target: { path: "/tmp/repo", commitSha }, + scanners: [], + rawFindingCount: 0, + findingCount: 0, + summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0, unknown: 0 }, + securityScore: 100, + findings: [], + }; +} + +class MemoryQueue { + constructor(next = job()) { + this.next = next; + this.completed = []; + this.released = []; + this.failed = []; + this.asserted = []; + this.renewed = []; + this.leaseValid = true; + } + async claimNext() { + const next = this.next; + this.next = undefined; + return next; + } + async assertLease(id, expectedLeaseId) { + this.asserted.push([id, expectedLeaseId]); + if (!this.leaseValid) throw new Error("GitHub scan job lease is stale or no longer owned by this worker."); + return job(); + } + async renew(id, expectedLeaseId) { + this.renewed.push([id, expectedLeaseId]); + if (!this.leaseValid) throw new Error("GitHub scan job lease is stale or no longer owned by this worker."); + return { ...job(), leaseUntil: "2026-08-22T18:55:00.000Z" }; + } + async release(id, expectedLeaseId) { + this.released.push([id, expectedLeaseId]); + if (!this.leaseValid) throw new Error("GitHub scan job lease is stale or no longer owned by this worker."); + return { ...job(), status: "pending", leaseUntil: undefined, leaseId: undefined }; + } + async fail(id, expectedLeaseId) { + this.failed.push([id, expectedLeaseId]); + if (!this.leaseValid) throw new Error("GitHub scan job lease is stale or no longer owned by this worker."); + return { ...job(), status: "failed", leaseUntil: undefined, leaseId: undefined }; + } + async complete(id, expectedLeaseId) { + this.completed.push([id, expectedLeaseId]); + if (!this.leaseValid) throw new Error("GitHub scan job lease is stale or no longer owned by this worker."); + return true; + } +} + +const fenced = ["a".repeat(32), leaseId]; + +test("worker returns idle when no queued job is available", async () => { + const queue = new MemoryQueue(null); + const result = await runNextGitHubAppScanJob({ + queue, + installationStore: { isRepositoryAllowed: async () => true }, + getInstallationToken: async () => "token", + acquire: async () => { throw new Error("must not acquire"); }, + scan: async () => { throw new Error("must not scan"); }, + publish: async () => { throw new Error("must not publish"); }, + }); + assert.deepEqual(result, { status: "idle" }); +}); + +test("worker rechecks authorization before obtaining credentials or repository content", async () => { + const queue = new MemoryQueue(); + let tokenCalls = 0; + let acquisitionCalls = 0; + const result = await runNextGitHubAppScanJob({ + queue, + installationStore: { isRepositoryAllowed: async () => false }, + getInstallationToken: async () => { tokenCalls += 1; return "token"; }, + acquire: async () => { acquisitionCalls += 1; throw new Error("must not acquire"); }, + scan: async () => { throw new Error("must not scan"); }, + publish: async () => { throw new Error("must not publish"); }, + }); + assert.equal(result.status, "revoked"); + assert.deepEqual(queue.failed, [fenced]); + assert.equal(tokenCalls, 0); + assert.equal(acquisitionCalls, 0); +}); + +test("worker isolates transport credentials from scanning and publishes only a commit-bound report", async () => { + const queue = new MemoryQueue(); + const tokenPurposes = []; + const acquisitionTokens = []; + const publicationTokens = []; + let cleanupCalls = 0; + let scannedWorkspace; + const result = await runNextGitHubAppScanJob({ + queue, + installationStore: { isRepositoryAllowed: async () => true }, + getInstallationToken: async (_installationId, purpose) => { + tokenPurposes.push(purpose); + return purpose === "acquire" ? "acquisition-secret" : "publication-secret"; + }, + acquire: async (input) => { + acquisitionTokens.push(input.installationToken); + return { + repository: input.repository, + commitSha: input.commitSha, + workspace: "/tmp/synsec-worker-repo", + cleanup: async () => { cleanupCalls += 1; }, + }; + }, + scan: async (_job, workspace) => { + scannedWorkspace = workspace; + return report(); + }, + publish: async (_job, _report, token) => { + publicationTokens.push(token); + }, + }); + + assert.equal(result.status, "completed"); + assert.deepEqual(tokenPurposes, ["acquire", "publish"]); + assert.deepEqual(acquisitionTokens, ["acquisition-secret"]); + assert.deepEqual(publicationTokens, ["publication-secret"]); + assert.equal(scannedWorkspace, "/tmp/synsec-worker-repo"); + assert.deepEqual(queue.asserted, [fenced]); + assert.deepEqual(queue.completed, [fenced]); + assert.deepEqual(queue.released, []); + assert.equal(cleanupCalls, 1); +}); + +test("worker renews the exact lease fence while long-running work is active", async () => { + const queue = new MemoryQueue(); + queue.leaseMs = 3_000; + const result = await runNextGitHubAppScanJob({ + queue, + installationStore: { isRepositoryAllowed: async () => true }, + getInstallationToken: async () => "token", + acquire: async (input) => ({ + repository: input.repository, + commitSha: input.commitSha, + workspace: "/tmp/synsec-worker-repo", + cleanup: async () => {}, + }), + scan: async () => { + await new Promise((resolve) => setTimeout(resolve, 1_100)); + return report(); + }, + publish: async () => {}, + }); + + assert.equal(result.status, "completed"); + assert.ok(queue.renewed.length >= 1); + assert.ok(queue.renewed.every((entry) => entry[0] === fenced[0] && entry[1] === fenced[1])); + assert.deepEqual(queue.completed, [fenced]); +}); + +test("worker refuses stale scan output, cleans the workspace, and schedules bounded queue retry", async () => { + const queue = new MemoryQueue(); + let publishCalls = 0; + let cleanupCalls = 0; + const result = await runNextGitHubAppScanJob({ + queue, + installationStore: { isRepositoryAllowed: async () => true }, + getInstallationToken: async () => "token", + acquire: async (input) => ({ + repository: input.repository, + commitSha: input.commitSha, + workspace: "/tmp/synsec-worker-repo", + cleanup: async () => { cleanupCalls += 1; }, + }), + scan: async () => report("abcdef0123456789abcdef0123456789abcdef01"), + publish: async () => { publishCalls += 1; }, + }); + + assert.equal(result.status, "retry_scheduled"); + assert.match(result.error, /report commit does not match/); + assert.equal(publishCalls, 0); + assert.deepEqual(queue.released, [fenced]); + assert.deepEqual(queue.completed, []); + assert.equal(cleanupCalls, 1); +}); + +test("worker revalidates the current lease identity before minting publication credentials", async () => { + const queue = new MemoryQueue(); + const tokenPurposes = []; + let publishCalls = 0; + const resultPromise = runNextGitHubAppScanJob({ + queue, + installationStore: { isRepositoryAllowed: async () => true }, + getInstallationToken: async (_installationId, purpose) => { + tokenPurposes.push(purpose); + return "token"; + }, + acquire: async (input) => ({ + repository: input.repository, + commitSha: input.commitSha, + workspace: "/tmp/synsec-worker-repo", + cleanup: async () => {}, + }), + scan: async () => { + queue.leaseValid = false; + return report(); + }, + publish: async () => { publishCalls += 1; }, + }); + + await assert.rejects(resultPromise, /Queue release also failed/); + assert.deepEqual(tokenPurposes, ["acquire"]); + assert.equal(publishCalls, 0); + assert.deepEqual(queue.asserted, [fenced]); + assert.deepEqual(queue.released, [fenced]); +}); diff --git a/tests/github-app.test.mjs b/tests/github-app.test.mjs new file mode 100644 index 00000000..021bcdc6 --- /dev/null +++ b/tests/github-app.test.mjs @@ -0,0 +1,191 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHmac, generateKeyPairSync, verify as cryptoVerify } from "node:crypto"; + +import { + createGitHubAppJwt, + createGitHubInstallationToken, + parseVerifiedGitHubAppWebhook, + shouldScanGitHubAppWebhook, + verifyGitHubWebhookSignature, +} from "../packages/github/dist/app.js"; + +const webhookSecret = "synsec-webhook-secret"; + +function signature(body) { + return `sha256=${createHmac("sha256", webhookSecret).update(body).digest("hex")}`; +} + +test("verifyGitHubWebhookSignature validates the exact payload bytes", () => { + const body = Buffer.from('{"repository":{"full_name":"cmahmud/synsec"}}'); + assert.equal(verifyGitHubWebhookSignature(body, signature(body), webhookSecret), true); + assert.equal(verifyGitHubWebhookSignature(Buffer.from(`${body} `), signature(body), webhookSecret), false); + assert.equal(verifyGitHubWebhookSignature(body, "sha256=not-a-signature", webhookSecret), false); +}); + +test("parseVerifiedGitHubAppWebhook normalizes pull requests without trusting payload URLs", () => { + const body = Buffer.from(JSON.stringify({ + action: "synchronize", + installation: { id: 42 }, + repository: { + full_name: "cmahmud/synsec", + clone_url: "https://attacker.invalid/repository.git", + }, + number: 7, + pull_request: { + head: { sha: "abc123", repo: { clone_url: "https://attacker.invalid/head.git" } }, + base: { sha: "def456" }, + }, + })); + + assert.deepEqual(parseVerifiedGitHubAppWebhook({ + body, + signatureHeader: signature(body), + webhookSecret, + eventName: "pull_request", + deliveryId: "delivery-1", + }), { + event: "pull_request", + action: "synchronize", + deliveryId: "delivery-1", + installationId: 42, + repository: "cmahmud/synsec", + headSha: "abc123", + baseSha: "def456", + pullRequestNumber: 7, + }); +}); + +test("parseVerifiedGitHubAppWebhook rejects unsupported, unsigned, or incomplete events", () => { + const body = Buffer.from(JSON.stringify({ installation: { id: 1 } })); + assert.throws(() => parseVerifiedGitHubAppWebhook({ + body, + signatureHeader: signature(body), + webhookSecret, + eventName: "issues", + }), /Unsupported GitHub App event/); + + assert.throws(() => parseVerifiedGitHubAppWebhook({ + body, + signatureHeader: "sha256=0000000000000000000000000000000000000000000000000000000000000000", + webhookSecret, + eventName: "installation", + }), /signature verification failed/); + + const incomplete = Buffer.from(JSON.stringify({ installation: { id: 1 }, repository: { full_name: "cmahmud/synsec" } })); + assert.throws(() => parseVerifiedGitHubAppWebhook({ + body: incomplete, + signatureHeader: signature(incomplete), + webhookSecret, + eventName: "push", + }), /missing required repository, installation, or commit identity/); +}); + +test("shouldScanGitHubAppWebhook allows only push and selected PR lifecycle events", () => { + const pr = { + event: "pull_request", + action: "synchronize", + installationId: 42, + repository: "cmahmud/synsec", + headSha: "abc123", + baseSha: "def456", + pullRequestNumber: 7, + }; + + assert.equal(shouldScanGitHubAppWebhook(pr), true); + assert.equal(shouldScanGitHubAppWebhook({ ...pr, action: "closed" }), false); + assert.equal(shouldScanGitHubAppWebhook({ ...pr, action: "converted_to_draft" }), false); + assert.equal(shouldScanGitHubAppWebhook({ ...pr, headSha: undefined }), false); + assert.equal(shouldScanGitHubAppWebhook({ + event: "push", + installationId: 42, + repository: "cmahmud/synsec", + headSha: "abc123", + }), true); + assert.equal(shouldScanGitHubAppWebhook({ + event: "installation", + action: "created", + installationId: 42, + }), false); + assert.equal(shouldScanGitHubAppWebhook({ + event: "installation_repositories", + action: "added", + installationId: 42, + }), false); +}); + +test("createGitHubAppJwt creates a short-lived verifiable RS256 token", () => { + const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const privatePem = privateKey.export({ type: "pkcs8", format: "pem" }); + const now = Date.UTC(2026, 7, 22, 16, 0, 0); + const token = createGitHubAppJwt(12345, privatePem, now); + const [encodedHeader, encodedPayload, encodedSignature] = token.split("."); + const header = JSON.parse(Buffer.from(encodedHeader, "base64url").toString("utf8")); + const payload = JSON.parse(Buffer.from(encodedPayload, "base64url").toString("utf8")); + + assert.deepEqual(header, { alg: "RS256", typ: "JWT" }); + assert.equal(payload.iss, "12345"); + assert.equal(payload.iat, Math.floor(now / 1000) - 30); + assert.equal(payload.exp - payload.iat, 9 * 60); + assert.equal(cryptoVerify( + "RSA-SHA256", + Buffer.from(`${encodedHeader}.${encodedPayload}`), + publicKey, + Buffer.from(encodedSignature, "base64url"), + ), true); +}); + +test("createGitHubInstallationToken posts only to the fixed GitHub installation endpoint and validates permission metadata", async () => { + let request; + const fakeFetch = async (url, init) => { + request = { url, init }; + return new Response(JSON.stringify({ + token: "installation-token", + expires_at: "2026-08-22T17:00:00Z", + permissions: { contents: "read", checks: "write" }, + repository_selection: "selected", + }), { status: 201 }); + }; + + const result = await createGitHubInstallationToken(42, "app-jwt", { fetch: fakeFetch }); + assert.equal(request.url, "https://api.github.com/app/installations/42/access_tokens"); + assert.equal(request.init.method, "POST"); + assert.equal(request.init.redirect, "error"); + assert.equal(request.init.headers.Authorization, "Bearer app-jwt"); + assert.equal(request.init.body, "{}"); + assert.deepEqual(result, { + token: "installation-token", + expiresAt: "2026-08-22T17:00:00Z", + permissions: { contents: "read", checks: "write" }, + repositorySelection: "selected", + }); +}); + +test("installation-token metadata validation fails closed", async () => { + const invalidPermission = async () => new Response(JSON.stringify({ + token: "installation-token", + expires_at: "2026-08-22T17:00:00Z", + permissions: { checks: "admin" }, + }), { status: 201 }); + await assert.rejects(() => createGitHubInstallationToken(42, "app-jwt", { fetch: invalidPermission }), /invalid permission metadata/); + + const invalidSelection = async () => new Response(JSON.stringify({ + token: "installation-token", + expires_at: "2026-08-22T17:00:00Z", + repository_selection: "surprise", + }), { status: 201 }); + await assert.rejects(() => createGitHubInstallationToken(42, "app-jwt", { fetch: invalidSelection }), /repository-selection metadata/); +}); + +test("installation-token errors do not expose the app JWT", async () => { + const secretJwt = "secret-app-jwt"; + const fakeFetch = async () => new Response(JSON.stringify({ message: "Bad credentials" }), { status: 401 }); + await assert.rejects( + () => createGitHubInstallationToken(42, secretJwt, { fetch: fakeFetch }), + (error) => { + assert.match(error.message, /HTTP 401/); + assert.equal(error.message.includes(secretJwt), false); + return true; + }, + ); +}); diff --git a/tests/github-base-scan.test.mjs b/tests/github-base-scan.test.mjs new file mode 100644 index 00000000..4c092daf --- /dev/null +++ b/tests/github-base-scan.test.mjs @@ -0,0 +1,84 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { scanGitHubBaseCommit } from "../packages/github/dist/base-scan.js"; + +const exec = promisify(execFile); +const config = { + version: 1, + scanners: ["opengrep"], + failOn: "high", + parallelism: 1, + timeoutMs: 60_000, +}; + +async function git(root, ...args) { + return exec("git", ["-C", root, ...args], { encoding: "utf8" }); +} + +function outcome(rootPath, commitSha) { + return { + report: { + schemaVersion: "1.0", + reportId: "base-report", + generatedAt: "2026-08-22T15:00:00.000Z", + toolVersion: "0.2.0", + target: { path: rootPath, commitSha }, + scanners: [], + rawFindingCount: 0, + findingCount: 0, + summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0, unknown: 0 }, + securityScore: 100, + findings: [], + scope: { mode: "repository" }, + }, + repositoryIndex: { schemaVersion: "1.0", root: rootPath, files: [] }, + statuses: [], + failures: [], + shouldFail: false, + changedFiles: [], + }; +} + +test("base scan uses a detached local worktree and binds the report to the requested commit", async () => { + const repository = await mkdtemp(join(tmpdir(), "synsec-base-test-")); + let scannedRoot; + try { + await git(repository, "init"); + await git(repository, "config", "user.email", "synsec@example.invalid"); + await git(repository, "config", "user.name", "SynSec Test"); + await writeFile(join(repository, "app.js"), "export const secure = true;\n", "utf8"); + await git(repository, "add", "app.js"); + await git(repository, "commit", "-m", "base"); + const { stdout } = await git(repository, "rev-parse", "HEAD"); + const baseSha = stdout.trim(); + + const result = await scanGitHubBaseCommit(config, repository, baseSha, { + scan: async (input) => { + scannedRoot = input.rootPath; + assert.notEqual(scannedRoot, repository); + assert.equal(input.changedOnly, false); + assert.equal(await readFile(join(scannedRoot, "app.js"), "utf8"), "export const secure = true;\n"); + return outcome(scannedRoot, baseSha); + }, + }); + + assert.equal(result.report.target.commitSha, baseSha); + await assert.rejects(() => access(scannedRoot)); + assert.equal((await git(repository, "status", "--porcelain")).stdout, ""); + } finally { + await rm(repository, { recursive: true, force: true }); + } +}); + +test("base scan rejects non-SHA revisions before invoking git", async () => { + await assert.rejects( + () => scanGitHubBaseCommit(config, process.cwd(), "origin/main"), + /valid commit SHA/, + ); +}); diff --git a/tests/github-baseline.test.mjs b/tests/github-baseline.test.mjs new file mode 100644 index 00000000..fb49ecc7 --- /dev/null +++ b/tests/github-baseline.test.mjs @@ -0,0 +1,98 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { loadValidatedGitHubBaseline } from "../packages/github/dist/baseline.js"; + +function report(commitSha) { + return { + schemaVersion: "1.0", + reportId: `baseline-${commitSha}`, + generatedAt: "2026-08-22T15:45:00.000Z", + toolVersion: "0.2.0", + target: { path: "/workspace", commitSha }, + scanners: [{ scanner: "opengrep", startedAt: "a", completedAt: "b", findingCount: 0, artifactCount: 0, diagnostics: [] }], + rawFindingCount: 0, + findingCount: 0, + summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0, unknown: 0 }, + securityScore: 100, + findings: [], + scope: { mode: "repository" }, + }; +} + +async function withBaseline(commitSha, callback) { + const root = await mkdtemp(join(tmpdir(), "synsec-github-baseline-")); + const path = join(root, "baseline.json"); + await writeFile(path, JSON.stringify(report(commitSha))); + try { + await callback(path); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +test("local PR baseline must match the event payload base commit", async () => { + await withBaseline("abcdef1234567890", async (path) => { + const loaded = await loadValidatedGitHubBaseline(path, { + repository: "cmahmud/synsec", + sha: "9999999999999999", + baseSha: "abcdef1234567890", + baseRef: "main", + pullRequestNumber: 2, + }); + assert.equal(loaded.target.commitSha, "abcdef1234567890"); + }); +}); + +test("baseline commit comparison accepts an unambiguous git SHA prefix", async () => { + await withBaseline("abcdef1234567890abcdef1234567890abcdef12", async (path) => { + const loaded = await loadValidatedGitHubBaseline(path, { + repository: "cmahmud/synsec", + sha: "9999999999999999", + baseSha: "abcdef123456", + pullRequestNumber: 2, + }); + assert.equal(loaded.reportId.startsWith("baseline-abcdef"), true); + }); +}); + +test("stale local baselines fail before scanning", async () => { + await withBaseline("1111111111111111", async (path) => { + await assert.rejects( + () => loadValidatedGitHubBaseline(path, { + repository: "cmahmud/synsec", + sha: "9999999999999999", + baseSha: "2222222222222222", + pullRequestNumber: 2, + }), + /baseline report commit does not match the expected base commit/, + ); + }); +}); + +test("PR baseline loading fails closed when no expected base commit is available", async () => { + await withBaseline("abcdef1234567890", async (path) => { + await assert.rejects( + () => loadValidatedGitHubBaseline(path, { + repository: "cmahmud/synsec", + sha: "9999999999999999", + pullRequestNumber: 2, + }), + /requires the pull-request base SHA or an explicit expected commit SHA/, + ); + }); +}); + +test("an explicit expected baseline commit supports non-PR/synthetic contexts", async () => { + await withBaseline("abcdef1234567890", async (path) => { + const loaded = await loadValidatedGitHubBaseline(path, { + repository: "cmahmud/synsec", + sha: "9999999999999999", + ref: "refs/heads/main", + }, { expectedCommitSha: "abcdef1234567890" }); + assert.equal(loaded.target.commitSha, "abcdef1234567890"); + }); +}); diff --git a/tests/github-context-base.test.mjs b/tests/github-context-base.test.mjs new file mode 100644 index 00000000..79428fa4 --- /dev/null +++ b/tests/github-context-base.test.mjs @@ -0,0 +1,27 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { detectGitHubContext } from "../packages/github/dist/index.js"; + +test("pull-request event context retains both head and base commit SHAs", () => { + const context = detectGitHubContext( + { + GITHUB_REPOSITORY: "cmahmud/synsec", + GITHUB_SHA: "synthetic-merge-sha", + GITHUB_REF: "refs/pull/42/merge", + }, + { + repository: { full_name: "cmahmud/synsec" }, + pull_request: { + number: 42, + head: { sha: "head-commit", ref: "feature/security" }, + base: { sha: "base-commit", ref: "main" }, + }, + }, + ); + + assert.equal(context.sha, "head-commit"); + assert.equal(context.baseSha, "base-commit"); + assert.equal(context.baseRef, "main"); + assert.equal(context.headRef, "feature/security"); +}); diff --git a/tests/github-exact-tree-diff.test.mjs b/tests/github-exact-tree-diff.test.mjs new file mode 100644 index 00000000..4d386e15 --- /dev/null +++ b/tests/github-exact-tree-diff.test.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { deriveExactChangedFiles } from "@synsec/github/exact-tree-diff"; + +function tree(entries) { + return entries.map(({ mode = "100644", type = "blob", object, path }) => `${mode} ${type} ${object}\t${path}\0`).join(""); +} + +const a = "a".repeat(40); +const b = "b".repeat(40); +const c = "c".repeat(40); + +function runner(outputs) { + let call = 0; + return async (_command, args) => { + assert.equal(args.includes("ls-tree"), true); + const stdout = outputs[call++]; + return { exitCode: 0, stdout, stderr: "" }; + }; +} + +test("exact tree diff returns only head paths whose blobs changed", async () => { + const base = tree([ + { object: a, path: "src/a.ts" }, + { object: b, path: "src/b.ts" }, + ]); + const head = tree([ + { object: c, path: "src/a.ts" }, + { object: b, path: "src/b.ts" }, + { object: a, path: "src/new.ts" }, + ]); + const plan = await deriveExactChangedFiles("/base", "/head", { run: runner([base, head]) }); + assert.equal(plan.mode, "changed-files"); + assert.equal(plan.reason, "exact-tree-diff"); + assert.deepEqual(plan.changedFiles, ["src/a.ts", "src/new.ts"]); + assert.deepEqual(plan.deletedFiles, []); +}); + +test("exact tree diff falls back to full scan when a path was deleted", async () => { + const base = tree([{ object: a, path: "src/deleted.ts" }]); + const head = tree([]); + const plan = await deriveExactChangedFiles("/base", "/head", { run: runner([base, head]) }); + assert.equal(plan.mode, "full-repository"); + assert.equal(plan.reason, "deletion-requires-full-scan"); + assert.deepEqual(plan.deletedFiles, ["src/deleted.ts"]); + assert.deepEqual(plan.changedFiles, []); +}); + +test("exact tree diff falls back on changed non-blob entries", async () => { + const base = tree([{ object: a, path: "vendor/submodule" }]); + const head = tree([{ mode: "160000", type: "commit", object: b, path: "vendor/submodule" }]); + const plan = await deriveExactChangedFiles("/base", "/head", { run: runner([base, head]) }); + assert.equal(plan.mode, "full-repository"); + assert.equal(plan.reason, "unsupported-tree-change"); +}); + +test("exact tree diff refuses malformed or unsafe tree paths", async () => { + const malformed = `100644 blob ${a}\t../escape.ts\0`; + const plan = await deriveExactChangedFiles("/base", "/head", { run: runner([malformed, malformed]) }); + assert.equal(plan.mode, "full-repository"); + assert.equal(plan.reason, "tree-read-failed"); +}); + +test("exact tree diff falls back when the changed set exceeds its bound", async () => { + const base = tree([]); + const head = tree([ + { object: a, path: "src/a.ts" }, + { object: b, path: "src/b.ts" }, + ]); + const plan = await deriveExactChangedFiles("/base", "/head", { + run: runner([base, head]), + maxChangedFiles: 1, + }); + assert.equal(plan.mode, "full-repository"); + assert.equal(plan.reason, "too-many-changed-files"); +}); diff --git a/tests/github-installation-store.test.mjs b/tests/github-installation-store.test.mjs new file mode 100644 index 00000000..5a50eb1a --- /dev/null +++ b/tests/github-installation-store.test.mjs @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { FileGitHubInstallationStore } from "@synsec/github/installation-store"; + +async function withStore(fn) { + const directory = await mkdtemp(join(tmpdir(), "synsec-installations-")); + return fn(new FileGitHubInstallationStore(directory), directory); +} + +test("installation store persists only bounded authorization metadata", async () => { + await withStore(async (store, directory) => { + const record = await store.put({ + installationId: 42, + accountLogin: "example-org", + accountType: "Organization", + repositorySelection: "selected", + repositories: ["example-org/b", "example-org/a", "example-org/a"], + updatedAt: "2026-08-22T18:00:00.000Z", + }); + assert.deepEqual(record.repositories, ["example-org/a", "example-org/b"]); + assert.equal(await store.isRepositoryAllowed(42, "example-org/a"), true); + assert.equal(await store.isRepositoryAllowed(42, "example-org/c"), false); + + const stored = await readFile(join(directory, "42.json"), "utf8"); + assert.equal(stored.includes("token"), false); + assert.equal(stored.includes("privateKey"), false); + assert.equal(stored.includes("clone_url"), false); + if (process.platform !== "win32") assert.equal((await stat(join(directory, "42.json"))).mode & 0o777, 0o600); + }); +}); + +test("all-repository installations do not persist an enumerated target list", async () => { + await withStore(async (store) => { + await assert.rejects(() => store.put({ + installationId: 1, + accountLogin: "owner", + accountType: "User", + repositorySelection: "all", + repositories: ["owner/repo"], + }), /must not persist/); + + await store.put({ + installationId: 1, + accountLogin: "owner", + accountType: "User", + repositorySelection: "all", + }); + assert.equal(await store.isRepositoryAllowed(1, "owner/anything"), true); + }); +}); + +test("suspended and removed installations cannot authorize scans", async () => { + await withStore(async (store) => { + await store.put({ + installationId: 9, + accountLogin: "example", + accountType: "Organization", + repositorySelection: "selected", + repositories: ["example/repo"], + suspendedAt: "2026-08-22T18:01:00.000Z", + }); + assert.equal(await store.isRepositoryAllowed(9, "example/repo"), false); + assert.equal(await store.remove(9), true); + assert.equal(await store.remove(9), false); + assert.equal(await store.get(9), undefined); + }); +}); + +test("installation store fails closed on corrupt or mismatched records", async () => { + await withStore(async (store, directory) => { + await writeFile(join(directory, "7.json"), JSON.stringify({ + version: 1, + installationId: 8, + accountLogin: "example", + accountType: "Organization", + repositorySelection: "selected", + repositories: ["example/repo"], + updatedAt: "2026-08-22T18:00:00.000Z", + })); + await assert.rejects(() => store.get(7), /does not match/); + }); +}); + +test("installation listing is deterministic", async () => { + await withStore(async (store) => { + await store.put({ installationId: 20, accountLogin: "b", accountType: "User", repositorySelection: "selected", repositories: [] }); + await store.put({ installationId: 3, accountLogin: "a", accountType: "User", repositorySelection: "selected", repositories: [] }); + assert.deepEqual((await store.list()).map((entry) => entry.installationId), [3, 20]); + }); +}); diff --git a/tests/github-installation-sync-concurrency.test.mjs b/tests/github-installation-sync-concurrency.test.mjs new file mode 100644 index 00000000..08980476 --- /dev/null +++ b/tests/github-installation-sync-concurrency.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { synchronizeGitHubInstallationState } from "@synsec/github/installation-sync"; + +class SnapshotYieldStore { + constructor() { + this.records = new Map(); + } + + async get(id) { + const record = this.records.get(id); + const snapshot = record ? { ...record, repositories: [...record.repositories] } : undefined; + await new Promise((resolve) => setImmediate(resolve)); + return snapshot; + } + + async put(input) { + const record = { + version: 1, + installationId: input.installationId, + accountLogin: input.accountLogin, + accountType: input.accountType, + repositorySelection: input.repositorySelection, + repositories: [...(input.repositories ?? [])].sort(), + ...(input.suspendedAt ? { suspendedAt: input.suspendedAt } : {}), + updatedAt: input.updatedAt ?? new Date().toISOString(), + }; + this.records.set(record.installationId, record); + return record; + } + + async remove(id) { + return this.records.delete(id); + } +} + +function addRepository(installationId, repository) { + return { + event: "installation_repositories", + action: "added", + installationId, + accountLogin: "example-org", + accountType: "Organization", + repositorySelection: "selected", + repositories: [], + repositoriesAdded: [repository], + repositoriesRemoved: [], + }; +} + +test("concurrent repository deltas for one installation are serialized within a runtime", async () => { + const store = new SnapshotYieldStore(); + await store.put({ + installationId: 7, + accountLogin: "example-org", + accountType: "Organization", + repositorySelection: "selected", + repositories: ["example-org/a"], + updatedAt: "2026-08-22T18:00:00.000Z", + }); + + await Promise.all([ + synchronizeGitHubInstallationState(addRepository(7, "example-org/b"), store, Date.UTC(2026, 7, 22, 18, 31)), + synchronizeGitHubInstallationState(addRepository(7, "example-org/c"), store, Date.UTC(2026, 7, 22, 18, 32)), + ]); + + const record = await store.get(7); + assert.deepEqual(record.repositories, ["example-org/a", "example-org/b", "example-org/c"]); +}); diff --git a/tests/github-installation-sync.test.mjs b/tests/github-installation-sync.test.mjs new file mode 100644 index 00000000..d1f8d233 --- /dev/null +++ b/tests/github-installation-sync.test.mjs @@ -0,0 +1,212 @@ +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import test from "node:test"; + +import { + parseVerifiedGitHubInstallationStateEvent, + synchronizeGitHubInstallationState, +} from "@synsec/github/installation-sync"; + +const secret = "synsec-installation-sync-secret"; + +function signature(body) { + return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; +} + +class MemoryInstallationStore { + constructor() { + this.records = new Map(); + } + async get(id) { + return this.records.get(id); + } + async put(input) { + const record = { + version: 1, + installationId: input.installationId, + accountLogin: input.accountLogin, + accountType: input.accountType, + repositorySelection: input.repositorySelection, + repositories: [...(input.repositories ?? [])].sort(), + ...(input.suspendedAt ? { suspendedAt: input.suspendedAt } : {}), + updatedAt: input.updatedAt ?? new Date().toISOString(), + }; + this.records.set(record.installationId, record); + return record; + } + async remove(id) { + return this.records.delete(id); + } +} + +test("verified installation creation normalizes only authorization state", async () => { + const body = Buffer.from(JSON.stringify({ + action: "created", + installation: { + id: 42, + account: { login: "example-org", type: "Organization" }, + repository_selection: "selected", + suspended_at: null, + access_tokens_url: "https://attacker.invalid/token", + }, + repositories: [ + { full_name: "example-org/b", clone_url: "https://attacker.invalid/b.git" }, + { full_name: "example-org/a" }, + ], + })); + + const event = parseVerifiedGitHubInstallationStateEvent({ + body, + signatureHeader: signature(body), + webhookSecret: secret, + eventName: "installation", + }); + assert.deepEqual(event, { + event: "installation", + action: "created", + installationId: 42, + accountLogin: "example-org", + accountType: "Organization", + repositorySelection: "selected", + repositories: ["example-org/a", "example-org/b"], + repositoriesAdded: [], + repositoriesRemoved: [], + }); + + const store = new MemoryInstallationStore(); + const result = await synchronizeGitHubInstallationState(event, store, Date.UTC(2026, 7, 22, 18, 30)); + assert.equal(result.status, "updated"); + assert.equal(await store.get(42).then((record) => record.repositories.includes("example-org/a")), true); + assert.equal(JSON.stringify(await store.get(42)).includes("attacker.invalid"), false); +}); + +test("new installation creation replaces stale selected repository authorization", async () => { + const store = new MemoryInstallationStore(); + await store.put({ + installationId: 42, + accountLogin: "example-org", + accountType: "Organization", + repositorySelection: "selected", + repositories: ["example-org/stale-repo"], + }); + + const result = await synchronizeGitHubInstallationState({ + event: "installation", + action: "created", + installationId: 42, + accountLogin: "example-org", + accountType: "Organization", + repositorySelection: "selected", + repositories: [], + repositoriesAdded: [], + repositoriesRemoved: [], + }, store, Date.UTC(2026, 7, 22, 18, 30)); + + assert.equal(result.status, "updated"); + assert.deepEqual(result.record.repositories, []); +}); + +test("repository-selection deltas preserve bounded selected authorization", async () => { + const store = new MemoryInstallationStore(); + await store.put({ + installationId: 7, + accountLogin: "example-org", + accountType: "Organization", + repositorySelection: "selected", + repositories: ["example-org/a", "example-org/b"], + updatedAt: "2026-08-22T18:00:00.000Z", + }); + const event = { + event: "installation_repositories", + action: "added", + installationId: 7, + accountLogin: "example-org", + accountType: "Organization", + repositorySelection: "selected", + repositories: [], + repositoriesAdded: ["example-org/c"], + repositoriesRemoved: ["example-org/a"], + }; + const result = await synchronizeGitHubInstallationState(event, store, Date.UTC(2026, 7, 22, 18, 31)); + assert.equal(result.status, "updated"); + assert.deepEqual(result.record.repositories, ["example-org/b", "example-org/c"]); +}); + +test("suspend and unsuspend events fail closed and preserve selected repositories", async () => { + const store = new MemoryInstallationStore(); + await store.put({ + installationId: 9, + accountLogin: "example-org", + accountType: "Organization", + repositorySelection: "selected", + repositories: ["example-org/repo"], + }); + const base = { + event: "installation", + installationId: 9, + accountLogin: "example-org", + accountType: "Organization", + repositorySelection: "selected", + repositories: [], + repositoriesAdded: [], + repositoriesRemoved: [], + }; + const suspended = await synchronizeGitHubInstallationState({ ...base, action: "suspend" }, store, Date.UTC(2026, 7, 22, 18, 32)); + assert.ok(suspended.record.suspendedAt); + assert.deepEqual(suspended.record.repositories, ["example-org/repo"]); + const unsuspended = await synchronizeGitHubInstallationState({ ...base, action: "unsuspend" }, store, Date.UTC(2026, 7, 22, 18, 33)); + assert.equal(unsuspended.record.suspendedAt, undefined); + assert.deepEqual(unsuspended.record.repositories, ["example-org/repo"]); +}); + +test("deleted installations are removed without requiring payload account metadata", async () => { + const store = new MemoryInstallationStore(); + await store.put({ installationId: 3, accountLogin: "owner", accountType: "User", repositorySelection: "all" }); + const result = await synchronizeGitHubInstallationState({ + event: "installation", + action: "deleted", + installationId: 3, + repositories: [], + repositoriesAdded: [], + repositoriesRemoved: [], + }, store); + assert.deepEqual(result, { status: "removed", installationId: 3, existed: true }); + assert.equal(await store.get(3), undefined); +}); + +test("installation synchronization rejects unsafe repositories, unsigned payloads, and inconsistent deltas", async () => { + const unsafeBody = Buffer.from(JSON.stringify({ + action: "created", + installation: { + id: 1, + account: { login: "owner", type: "User" }, + repository_selection: "selected", + }, + repositories: [{ full_name: "github.com@attacker.invalid/repo" }], + })); + assert.throws(() => parseVerifiedGitHubInstallationStateEvent({ + body: unsafeBody, + signatureHeader: signature(unsafeBody), + webhookSecret: secret, + eventName: "installation", + }), /unsafe/); + assert.throws(() => parseVerifiedGitHubInstallationStateEvent({ + body: unsafeBody, + signatureHeader: "sha256=" + "0".repeat(64), + webhookSecret: secret, + eventName: "installation", + }), /signature verification failed/); + + const store = new MemoryInstallationStore(); + await assert.rejects(() => synchronizeGitHubInstallationState({ + event: "installation_repositories", + action: "added", + installationId: 999, + accountLogin: "owner", + accountType: "User", + repositorySelection: "selected", + repositories: [], + repositoriesAdded: ["owner/repo"], + repositoriesRemoved: [], + }, store), /before installation state was initialized/); +}); diff --git a/tests/github-orchestrator.test.mjs b/tests/github-orchestrator.test.mjs new file mode 100644 index 00000000..95f1d9c6 --- /dev/null +++ b/tests/github-orchestrator.test.mjs @@ -0,0 +1,98 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { publishSynSecReportToGitHub } from "../packages/github/dist/orchestrator.js"; + +function report() { + return { + schemaVersion: "1.0", + reportId: "report-orchestrator", + generatedAt: "2026-08-22T14:00:00.000Z", + toolVersion: "0.2.0", + target: { path: ".", commitSha: "real-head-sha" }, + scanners: [{ scanner: "opengrep", startedAt: "a", completedAt: "b", findingCount: 1, artifactCount: 0, diagnostics: [] }], + rawFindingCount: 1, + findingCount: 1, + summary: { critical: 0, high: 1, medium: 0, low: 0, info: 0, unknown: 0 }, + securityScore: 90, + findings: [{ + fingerprint: "fp-orchestrator", + primary: { + id: "finding-1", + title: "Unsafe input", + description: "Untrusted input reaches a sensitive operation.", + category: "sast", + severity: "high", + confidence: 0.95, + scanner: { name: "opengrep", ruleId: "unsafe-input" }, + location: { path: "src/app.ts", startLine: 8, endLine: 8 }, + }, + duplicates: [], + sources: [{ name: "opengrep", ruleId: "unsafe-input" }], + }], + scope: { mode: "changed-files", baseRef: "main", changedFiles: ["src/app.ts"] }, + baseline: { new: ["fp-orchestrator"], fixed: [], persisting: [] }, + }; +} + +test("publishSynSecReportToGitHub resolves PR head context, builds, and publishes one completed check", async () => { + let request; + const fakeFetch = async (url, init) => { + request = { url, init }; + return new Response(JSON.stringify({ id: 321, status: "completed", conclusion: "failure" }), { status: 201 }); + }; + + const result = await publishSynSecReportToGitHub(report(), "installation-token", { + env: { + GITHUB_REPOSITORY: "cmahmud/synsec", + GITHUB_SHA: "real-head-sha", + GITHUB_REF: "refs/pull/2/head", + GITHUB_BASE_REF: "main", + GITHUB_HEAD_REF: "feature/multi-scanner-mvp", + }, + fetch: fakeFetch, + threshold: "high", + }); + + assert.equal(result.context.sha, "real-head-sha"); + assert.equal(result.context.pullRequestNumber, 2); + assert.equal(result.check.headSha, "real-head-sha"); + assert.equal(result.check.conclusion, "failure"); + assert.equal(result.check.output.annotations.length, 1); + assert.equal(request.url, "https://api.github.com/repos/cmahmud/synsec/check-runs"); + assert.equal(JSON.parse(request.init.body).head_sha, "real-head-sha"); + assert.equal(result.publication.id, 321); +}); + +test("publication orchestration fails before transport when GitHub context is missing", async () => { + let called = false; + await assert.rejects( + () => publishSynSecReportToGitHub(report(), "token", { + env: {}, + fetch: async () => { + called = true; + throw new Error("transport should not run"); + }, + }), + /Unable to resolve a valid GitHub repository and commit context/, + ); + assert.equal(called, false); +}); + +test("publication orchestration rejects a report generated for a different commit", async () => { + let called = false; + const stale = report(); + stale.target.commitSha = "old-head-sha"; + + await assert.rejects( + () => publishSynSecReportToGitHub(stale, "token", { + env: { GITHUB_REPOSITORY: "cmahmud/synsec", GITHUB_SHA: "new-head-sha" }, + fetch: async () => { + called = true; + throw new Error("transport should not run"); + }, + }), + /report commit does not match the GitHub commit/, + ); + assert.equal(called, false); +}); diff --git a/tests/github-private-state-directories.test.mjs b/tests/github-private-state-directories.test.mjs new file mode 100644 index 00000000..e67ec178 --- /dev/null +++ b/tests/github-private-state-directories.test.mjs @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { chmod, mkdir, mkdtemp, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { FileGitHubInstallationStore } from "@synsec/github/installation-store"; +import { FileGitHubWebhookReplayStore } from "@synsec/github/replay-store"; +import { FileGitHubScanQueue } from "@synsec/github/scan-queue"; +import { ensurePrivateDirectory } from "../packages/github/dist/private-directory.js"; + +const symlinkError = /real directory|EEXIST|not a directory/i; + +test("GitHub durable stores repair permissive pre-existing directories where supported", async () => { + if (process.platform === "win32") return; + const root = await mkdtemp(join(tmpdir(), "synsec-private-state-")); + const installationDirectory = join(root, "installations"); + const replayDirectory = join(root, "replay"); + const queueDirectory = join(root, "queue"); + + try { + for (const directory of [installationDirectory, replayDirectory, queueDirectory]) { + await mkdir(directory, { recursive: true, mode: 0o755 }); + await chmod(directory, 0o755); + assert.equal((await stat(directory)).mode & 0o777, 0o755); + } + + const installations = new FileGitHubInstallationStore(installationDirectory); + await installations.put({ + installationId: 1, + accountLogin: "example", + accountType: "Organization", + repositorySelection: "selected", + repositories: ["example/repo"], + }); + + const replay = new FileGitHubWebhookReplayStore(replayDirectory); + assert.equal((await replay.claim("delivery-private-mode")).accepted, true); + + const queue = new FileGitHubScanQueue(queueDirectory); + await queue.enqueue({ + deliveryId: "queue-private-mode", + installationId: 1, + repository: "example/repo", + headSha: "a".repeat(40), + event: "push", + }); + + for (const directory of [installationDirectory, replayDirectory, queueDirectory]) { + assert.equal((await stat(directory)).mode & 0o777, 0o700); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("private durable directory handling refuses a symlink final path", async () => { + if (process.platform === "win32") return; + const root = await mkdtemp(join(tmpdir(), "synsec-private-state-symlink-")); + const realDirectory = join(root, "real"); + const linkedDirectory = join(root, "linked"); + try { + await mkdir(realDirectory, { mode: 0o755 }); + await symlink(realDirectory, linkedDirectory, "dir"); + await assert.rejects(() => ensurePrivateDirectory(linkedDirectory), symlinkError); + assert.equal((await stat(realDirectory)).mode & 0o777, 0o755); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("durable stores validate a symlink directory even when the first operation is a read or release", async () => { + if (process.platform === "win32") return; + const root = await mkdtemp(join(tmpdir(), "synsec-private-state-first-access-")); + const realDirectory = join(root, "real"); + const linkedDirectory = join(root, "linked"); + try { + await mkdir(realDirectory, { mode: 0o755 }); + await symlink(realDirectory, linkedDirectory, "dir"); + + const installations = new FileGitHubInstallationStore(linkedDirectory); + await assert.rejects(() => installations.get(1), symlinkError); + await assert.rejects(() => installations.remove(1), symlinkError); + + const replay = new FileGitHubWebhookReplayStore(linkedDirectory); + await assert.rejects( + () => replay.release("delivery-first-access", "2026-08-22T21:00:00.000Z"), + symlinkError, + ); + + const queue = new FileGitHubScanQueue(linkedDirectory); + await assert.rejects(() => queue.deleteFailed("a".repeat(32)), symlinkError); + + assert.equal((await stat(realDirectory)).mode & 0o777, 0o755); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("durable stores refuse symlink-shaped record files", async () => { + if (process.platform === "win32") return; + const root = await mkdtemp(join(tmpdir(), "synsec-private-record-symlink-")); + const installationDirectory = join(root, "installations"); + const replayDirectory = join(root, "replay"); + const queueDirectory = join(root, "queue"); + const target = join(root, "target.json"); + const deliveryId = "delivery-record-symlink"; + const replayName = `${createHash("sha256").update(deliveryId).digest("hex")}.json`; + const queueId = "a".repeat(32); + + try { + await writeFile(target, "{}\n", "utf8"); + for (const directory of [installationDirectory, replayDirectory, queueDirectory]) { + await mkdir(directory, { recursive: true, mode: 0o700 }); + } + await symlink(target, join(installationDirectory, "1.json")); + await symlink(target, join(replayDirectory, replayName)); + await symlink(target, join(queueDirectory, `${queueId}.json`)); + + const installations = new FileGitHubInstallationStore(installationDirectory); + await assert.rejects(() => installations.get(1), /symlinked/); + await assert.rejects(() => installations.remove(1), /symlinked/); + + const replay = new FileGitHubWebhookReplayStore(replayDirectory); + await assert.rejects( + () => replay.release(deliveryId, "2026-08-22T21:00:00.000Z"), + /symlinked/, + ); + + const queue = new FileGitHubScanQueue(queueDirectory); + await assert.rejects(() => queue.deleteFailed(queueId), /symlinked/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/github-publisher.test.mjs b/tests/github-publisher.test.mjs new file mode 100644 index 00000000..bfdde984 --- /dev/null +++ b/tests/github-publisher.test.mjs @@ -0,0 +1,78 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { publishGitHubCheck, toGitHubCheckRunRequest } from "../packages/github/dist/publisher.js"; + +const context = { repository: "cmahmud/synsec", sha: "head-sha" }; +const check = { + name: "SynSec repository security", + headSha: "head-sha", + conclusion: "failure", + output: { + title: "SynSec found findings at or above high", + summary: "High: **1**", + text: "Report report-1 scanned changed files.", + annotations: [{ + path: "src/app.ts", + start_line: 4, + end_line: 4, + annotation_level: "failure", + title: "[HIGH] Unsafe input", + message: "Unsafe input reaches a sink.", + }], + }, +}; + +test("toGitHubCheckRunRequest emits a completed check-run payload", () => { + assert.deepEqual(toGitHubCheckRunRequest(check), { + name: check.name, + head_sha: "head-sha", + status: "completed", + conclusion: "failure", + output: check.output, + }); +}); + +test("publishGitHubCheck posts only to the fixed GitHub Checks API endpoint", async () => { + let request; + const fakeFetch = async (url, init) => { + request = { url, init }; + return new Response(JSON.stringify({ + id: 123, + html_url: "https://github.com/cmahmud/synsec/runs/123", + status: "completed", + conclusion: "failure", + }), { status: 201, headers: { "content-type": "application/json" } }); + }; + + const published = await publishGitHubCheck(check, context, "installation-token", { fetch: fakeFetch }); + assert.equal(request.url, "https://api.github.com/repos/cmahmud/synsec/check-runs"); + assert.equal(request.init.method, "POST"); + assert.equal(request.init.redirect, "error"); + assert.equal(request.init.headers.Authorization, "Bearer installation-token"); + assert.equal(JSON.parse(request.init.body).head_sha, "head-sha"); + assert.deepEqual(published, { + id: 123, + htmlUrl: "https://github.com/cmahmud/synsec/runs/123", + status: "completed", + conclusion: "failure", + }); +}); + +test("publishGitHubCheck fails closed on invalid repository or missing token", async () => { + await assert.rejects(() => publishGitHubCheck(check, { repository: "not a repo", sha: "x" }, "token", { fetch: async () => { throw new Error("should not run"); } }), /Invalid GitHub repository/); + await assert.rejects(() => publishGitHubCheck(check, context, " ", { fetch: async () => { throw new Error("should not run"); } }), /token is required/); +}); + +test("publisher surfaces API errors without including the bearer token", async () => { + const secret = "very-secret-token"; + const fakeFetch = async () => new Response(JSON.stringify({ message: "Resource not accessible by integration" }), { status: 403 }); + await assert.rejects( + () => publishGitHubCheck(check, context, secret, { fetch: fakeFetch }), + (error) => { + assert.match(error.message, /HTTP 403/); + assert.equal(error.message.includes(secret), false); + return true; + }, + ); +}); diff --git a/tests/github-remediation-writer.test.mjs b/tests/github-remediation-writer.test.mjs new file mode 100644 index 00000000..00879ebe --- /dev/null +++ b/tests/github-remediation-writer.test.mjs @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { createApprovedGitHubRemediationPullRequest } from "@synsec/github/remediation-writer"; +import { + approveRemediationProposal, + authorizeRemediationExecution, + createRemediationProposal, +} from "@synsec/workflows/remediation"; +import { getWorkflow } from "@synsec/workflows"; + +const target = "a".repeat(40); +const commit = "b".repeat(40); +const token = "installation-token-that-must-not-appear-in-argv"; + +function execution() { + const proposal = createRemediationProposal(getWorkflow("repository-review"), { + targetCommitSha: target, + findingIds: ["finding-1", "finding-2"], + summary: "Apply the reviewed repository-local hardening patch.", + changes: [ + { + path: "src/handler.ts", + operation: "modify", + patch: "diff --git a/src/handler.ts b/src/handler.ts\n--- a/src/handler.ts\n+++ b/src/handler.ts\n@@ -1 +1 @@\n-old\n+new\n", + }, + { + path: "tests/handler.test.ts", + operation: "create", + patch: "diff --git a/tests/handler.test.ts b/tests/handler.test.ts\nnew file mode 100644\n--- /dev/null\n+++ b/tests/handler.test.ts\n@@ -0,0 +1 @@\n+test('guard', () => {});\n", + }, + ], + }); + const approval = approveRemediationProposal(proposal, { + proposalId: proposal.proposalId, + approvedBy: "security-reviewer", + approvedAt: "2026-08-22T20:30:00.000Z", + }); + return authorizeRemediationExecution({ proposal, approval, currentHeadSha: target }); +} + +function gitHarness({ baseSha = target, staged = "M\tsrc/handler.ts\nA\ttests/handler.test.ts\n", existingBranchSha } = {}) { + const calls = []; + const runner = async (args, options) => { + calls.push({ args: [...args], options }); + assert.equal(args.some((value) => value.includes(token)), false); + if (args[0] === "rev-parse") { + const remediationCommitCreated = calls.some((call) => call.args[0] === "commit"); + return { exitCode: 0, stdout: `${remediationCommitCreated ? commit : target}\n`, stderr: "" }; + } + if (args[0] === "ls-remote") { + const ref = args.at(-1); + if (ref === "refs/heads/main") return { exitCode: 0, stdout: `${baseSha}\t${ref}\n`, stderr: "" }; + if (existingBranchSha) return { exitCode: 0, stdout: `${existingBranchSha}\t${ref}\n`, stderr: "" }; + return { exitCode: 2, stdout: "", stderr: "" }; + } + if (args[0] === "diff") return { exitCode: 0, stdout: staged, stderr: "" }; + return { exitCode: 0, stdout: "", stderr: "" }; + }; + return { calls, runner }; +} + +function fetchHarness() { + const calls = []; + const fetchImpl = async (url, init) => { + calls.push({ url, init }); + return new Response(JSON.stringify({ + number: 17, + html_url: "https://github.com/example/repo/pull/17", + }), { status: 201, headers: { "content-type": "application/json" } }); + }; + return { calls, fetchImpl }; +} + +test("GitHub remediation writer rechecks provenance, applies only approved paths, pushes non-force, and opens a PR", async () => { + const workspace = await mkdtemp(join(tmpdir(), "synsec-remediation-writer-")); + const git = gitHarness(); + const http = fetchHarness(); + const result = await createApprovedGitHubRemediationPullRequest({ + repository: "example/repo", + baseBranch: "main", + workspace, + installationToken: token, + execution: execution(), + }, { gitRunner: git.runner, fetch: http.fetchImpl }); + + assert.equal(result.repository, "example/repo"); + assert.equal(result.commitSha, commit); + assert.equal(result.pullRequestNumber, 17); + assert.match(result.branch, /^synsec\/remediation\/[a-f0-9]{20}$/); + + const commands = git.calls.map((call) => call.args); + assert.deepEqual(commands.slice(0, 2).map((args) => args[0]), ["rev-parse", "ls-remote"]); + assert.ok(commands.some((args) => args[0] === "apply" && args.includes("--check"))); + assert.ok(commands.some((args) => args[0] === "diff" && args.includes("--no-renames"))); + const push = commands.find((args) => args[0] === "push"); + assert.ok(push); + assert.equal(push.includes("--force"), false); + assert.equal(push[1], "https://github.com/example/repo.git"); + + assert.equal(http.calls.length, 1); + assert.equal(http.calls[0].url, "https://api.github.com/repos/example/repo/pulls"); + assert.equal(http.calls[0].init.redirect, "error"); + assert.equal(http.calls[0].init.headers.authorization, `Bearer ${token}`); + const body = JSON.parse(http.calls[0].init.body); + assert.equal(body.base, "main"); + assert.equal(body.head, result.branch); + assert.match(body.body, /explicit approval/); +}); + +test("GitHub remediation writer refuses a moved base before applying patches", async () => { + const workspace = await mkdtemp(join(tmpdir(), "synsec-remediation-stale-")); + const git = gitHarness({ baseSha: "c".repeat(40) }); + const http = fetchHarness(); + await assert.rejects(() => createApprovedGitHubRemediationPullRequest({ + repository: "example/repo", + baseBranch: "main", + workspace, + installationToken: token, + execution: execution(), + }, { gitRunner: git.runner, fetch: http.fetchImpl }), /base branch moved/); + assert.equal(git.calls.some((call) => call.args[0] === "apply"), false); + assert.equal(http.calls.length, 0); +}); + +test("GitHub remediation writer rejects staged scope expansion before commit or push", async () => { + const workspace = await mkdtemp(join(tmpdir(), "synsec-remediation-scope-")); + const git = gitHarness({ staged: "M\tsrc/handler.ts\nA\ttests/handler.test.ts\nA\tunexpected.txt\n" }); + const http = fetchHarness(); + await assert.rejects(() => createApprovedGitHubRemediationPullRequest({ + repository: "example/repo", + baseBranch: "main", + workspace, + installationToken: token, + execution: execution(), + }, { gitRunner: git.runner, fetch: http.fetchImpl }), /staged paths differ/); + assert.equal(git.calls.some((call) => call.args[0] === "commit"), false); + assert.equal(git.calls.some((call) => call.args[0] === "push"), false); + assert.equal(http.calls.length, 0); +}); + +test("GitHub remediation writer revalidates approval hashes immediately before any Git operation", async () => { + const workspace = await mkdtemp(join(tmpdir(), "synsec-remediation-tamper-")); + const approved = execution(); + approved.proposal.changes[0].patch = approved.proposal.changes[0].patch.replace("+new", "+tampered"); + const git = gitHarness(); + const http = fetchHarness(); + + await assert.rejects(() => createApprovedGitHubRemediationPullRequest({ + repository: "example/repo", + baseBranch: "main", + workspace, + installationToken: token, + execution: approved, + }, { gitRunner: git.runner, fetch: http.fetchImpl }), /patch contents no longer match/); + assert.equal(git.calls.length, 0); + assert.equal(http.calls.length, 0); +}); + +test("GitHub remediation writer treats the deterministic branch as idempotent only when commit contents match", async () => { + const workspace = await mkdtemp(join(tmpdir(), "synsec-remediation-idempotent-")); + const same = gitHarness({ existingBranchSha: commit }); + const http = fetchHarness(); + await createApprovedGitHubRemediationPullRequest({ + repository: "example/repo", + baseBranch: "main", + workspace, + installationToken: token, + execution: execution(), + }, { gitRunner: same.runner, fetch: http.fetchImpl }); + assert.equal(same.calls.some((call) => call.args[0] === "push"), false); + assert.equal(http.calls.length, 1); + + const conflicting = gitHarness({ existingBranchSha: "d".repeat(40) }); + await assert.rejects(() => createApprovedGitHubRemediationPullRequest({ + repository: "example/repo", + baseBranch: "main", + workspace, + installationToken: token, + execution: execution(), + }, { gitRunner: conflicting.runner, fetch: http.fetchImpl }), /already exists with different contents/); +}); diff --git a/tests/github-replay-store.test.mjs b/tests/github-replay-store.test.mjs new file mode 100644 index 00000000..5953c13f --- /dev/null +++ b/tests/github-replay-store.test.mjs @@ -0,0 +1,94 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, readdir, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { FileGitHubWebhookReplayStore } from "../packages/github/dist/replay-store.js"; + +const HOUR = 60 * 60 * 1000; + +test("replay store accepts one delivery and rejects a duplicate atomically", async () => { + const directory = await mkdtemp(join(tmpdir(), "synsec-replay-")); + const now = Date.UTC(2026, 7, 22, 17, 0, 0); + const store = new FileGitHubWebhookReplayStore(directory, { now: () => now, retentionMs: HOUR }); + + const claims = await Promise.all(Array.from({ length: 8 }, () => store.claim("01234567-89ab-cdef-0123-456789abcdef"))); + assert.equal(claims.filter((claim) => claim.accepted).length, 1); + assert.equal(claims.filter((claim) => !claim.accepted).length, 7); + + const files = await readdir(directory); + assert.equal(files.length, 1); + assert.match(files[0], /^[a-f0-9]{64}\.json$/); + assert.equal(files[0].includes("01234567"), false); + + const record = JSON.parse(await readFile(join(directory, files[0]), "utf8")); + assert.equal(record.deliveryId, "01234567-89ab-cdef-0123-456789abcdef"); + assert.equal(record.receivedAt, new Date(now).toISOString()); + if (process.platform !== "win32") { + const mode = (await stat(join(directory, files[0]))).mode & 0o777; + assert.equal(mode, 0o600); + } +}); + +test("replay store permits reuse only after bounded retention expires", async () => { + const directory = await mkdtemp(join(tmpdir(), "synsec-replay-expiry-")); + let now = Date.UTC(2026, 7, 22, 17, 0, 0); + const store = new FileGitHubWebhookReplayStore(directory, { now: () => now, retentionMs: HOUR }); + + assert.equal((await store.claim("delivery-1")).accepted, true); + now += HOUR - 1; + assert.equal((await store.claim("delivery-1")).accepted, false); + now += 2; + assert.equal((await store.claim("delivery-1")).accepted, true); +}); + +test("replay claim release permits retry only for the exact current unexpired claim", async () => { + const directory = await mkdtemp(join(tmpdir(), "synsec-replay-release-")); + let now = Date.UTC(2026, 7, 22, 17, 0, 0); + const store = new FileGitHubWebhookReplayStore(directory, { now: () => now, retentionMs: HOUR }); + + const first = await store.claim("delivery-release"); + assert.equal(first.accepted, true); + assert.equal(await store.release("delivery-release", "2026-08-22T16:59:59.000Z"), false); + assert.equal((await store.claim("delivery-release")).accepted, false); + assert.equal(await store.release("delivery-release", first.receivedAt), true); + const retried = await store.claim("delivery-release"); + assert.equal(retried.accepted, true); + + now += HOUR + 1; + assert.equal(await store.release("delivery-release", retried.receivedAt), false); +}); + +test("replay store validates ids and retention bounds", async () => { + const directory = await mkdtemp(join(tmpdir(), "synsec-replay-validation-")); + assert.throws(() => new FileGitHubWebhookReplayStore(directory, { retentionMs: HOUR - 1 }), /retention must be an integer/); + + const store = new FileGitHubWebhookReplayStore(directory, { retentionMs: HOUR }); + await assert.rejects(() => store.claim("../delivery"), /unsupported characters/); + await assert.rejects(() => store.claim("x".repeat(129)), /exceeds 128 characters/); + await assert.rejects(() => store.release("delivery", "not-a-date"), /receivedAt must be an ISO timestamp/); +}); + +test("replay store rejects corrupt existing records instead of treating them as duplicates", async () => { + const directory = await mkdtemp(join(tmpdir(), "synsec-replay-corrupt-")); + const now = Date.UTC(2026, 7, 22, 17, 0, 0); + const store = new FileGitHubWebhookReplayStore(directory, { now: () => now, retentionMs: HOUR }); + await store.claim("delivery-corrupt"); + + const [file] = await readdir(directory); + const { writeFile } = await import("node:fs/promises"); + await writeFile(join(directory, file), "not json", "utf8"); + + await assert.rejects(() => store.claim("delivery-corrupt"), /invalid JSON/); +}); + +test("pruneExpired removes only old replay marker files", async () => { + const directory = await mkdtemp(join(tmpdir(), "synsec-replay-prune-")); + let now = Date.UTC(2026, 7, 22, 17, 0, 0); + const store = new FileGitHubWebhookReplayStore(directory, { now: () => now, retentionMs: HOUR }); + await store.claim("delivery-old"); + now += HOUR + 1; + assert.equal(await store.pruneExpired(), 1); + assert.deepEqual(await readdir(directory), []); +}); diff --git a/tests/github-repository-acquisition.test.mjs b/tests/github-repository-acquisition.test.mjs new file mode 100644 index 00000000..55bd4292 --- /dev/null +++ b/tests/github-repository-acquisition.test.mjs @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import { access, mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + acquireGitHubRepositoryCommit, + acquireGitHubRepositoryScanTarget, + validateGitHubCommitSha, + validateGitHubRepositoryIdentity, +} from "@synsec/github/repository-acquisition"; + +const sha = "0123456789abcdef0123456789abcdef01234567"; +const baseSha = "abcdef0123456789abcdef0123456789abcdef01"; + +test("repository acquisition validates fixed-host owner/name identities", () => { + assert.equal(validateGitHubRepositoryIdentity("cmahmud/synsec"), "cmahmud/synsec"); + assert.throws(() => validateGitHubRepositoryIdentity("github.com@attacker.invalid/repo"), /unsafe/); + assert.throws(() => validateGitHubRepositoryIdentity("owner/repo/extra"), /owner\/name/); + assert.throws(() => validateGitHubRepositoryIdentity("owner/../repo"), /owner\/name/); + assert.equal(validateGitHubCommitSha(sha.toUpperCase()), sha); + assert.throws(() => validateGitHubCommitSha("main"), /commit SHA/); +}); + +test("exact-commit acquisition keeps the installation token out of git argv and verifies HEAD", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-acquire-test-")); + const calls = []; + const token = "ghs_test-installation-token"; + const gitRunner = async (args, options) => { + calls.push({ args: [...args], options }); + if (args[0] === "rev-parse") return { exitCode: 0, stdout: `${sha}\n`, stderr: "" }; + return { exitCode: 0, stdout: "", stderr: "" }; + }; + + const acquired = await acquireGitHubRepositoryCommit({ + repository: "cmahmud/synsec", + commitSha: sha, + installationToken: token, + }, { workspaceRoot: root, gitRunner, timeoutMs: 10_000 }); + + assert.equal(acquired.repository, "cmahmud/synsec"); + assert.equal(acquired.commitSha, sha); + assert.equal(calls.length, 4); + assert.deepEqual(calls[1].args, [ + "fetch", + "--quiet", + "--no-tags", + "--depth=1", + "https://github.com/cmahmud/synsec.git", + sha, + ]); + assert.equal(calls.some((call) => call.args.some((arg) => arg.includes(token))), false); + assert.equal(calls[0].options.env.GIT_TERMINAL_PROMPT, "0"); + assert.equal(calls[0].options.env.GIT_CONFIG_NOSYSTEM, "1"); + assert.equal(calls[0].options.env.GIT_CONFIG_KEY_0, "http.https://github.com/.extraheader"); + assert.match(calls[0].options.env.GIT_CONFIG_VALUE_0, /^AUTHORIZATION: basic /); + assert.equal(calls[0].options.env.GITHUB_TOKEN, undefined); + assert.equal(calls[0].options.env.HTTPS_PROXY, undefined); + assert.equal(calls[0].options.env.GIT_LFS_SKIP_SMUDGE, "1"); + + await acquired.cleanup(); + await assert.rejects(() => access(acquired.workspace), /ENOENT/); +}); + +test("PR acquisition materializes exact head and base in isolated workspaces with one cleanup", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-acquire-pr-")); + const workspaces = new Map(); + const gitRunner = async (args, options) => { + if (args[0] === "fetch") workspaces.set(options.cwd, args.at(-1)); + if (args[0] === "rev-parse") { + return { exitCode: 0, stdout: `${workspaces.get(options.cwd)}\n`, stderr: "" }; + } + return { exitCode: 0, stdout: "", stderr: "" }; + }; + + const acquired = await acquireGitHubRepositoryScanTarget({ + repository: "cmahmud/synsec", + commitSha: sha, + baseCommitSha: baseSha, + installationToken: "token", + }, { workspaceRoot: root, gitRunner, timeoutMs: 10_000 }); + + assert.equal(acquired.commitSha, sha); + assert.equal(acquired.base?.commitSha, baseSha); + assert.notEqual(acquired.workspace, acquired.base?.workspace); + const headWorkspace = acquired.workspace; + const baseWorkspace = acquired.base.workspace; + await acquired.cleanup(); + await assert.rejects(() => access(headWorkspace), /ENOENT/); + await assert.rejects(() => access(baseWorkspace), /ENOENT/); +}); + +test("PR acquisition cleans an already-acquired head if exact base acquisition fails", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-acquire-pr-fail-")); + const workspaces = new Map(); + let headWorkspace; + const gitRunner = async (args, options) => { + if (!headWorkspace) headWorkspace = options.cwd; + if (args[0] === "fetch") { + const requested = args.at(-1); + workspaces.set(options.cwd, requested); + if (requested === baseSha) return { exitCode: 1, stdout: "", stderr: "base unavailable" }; + } + if (args[0] === "rev-parse") { + return { exitCode: 0, stdout: `${workspaces.get(options.cwd)}\n`, stderr: "" }; + } + return { exitCode: 0, stdout: "", stderr: "" }; + }; + + await assert.rejects(() => acquireGitHubRepositoryScanTarget({ + repository: "cmahmud/synsec", + commitSha: sha, + baseCommitSha: baseSha, + installationToken: "token", + }, { workspaceRoot: root, gitRunner, timeoutMs: 10_000 }), /base unavailable/); + assert.ok(headWorkspace); + await assert.rejects(() => access(headWorkspace), /ENOENT/); +}); + +test("acquisition rejects malformed transport identity before invoking git", async () => { + let called = false; + await assert.rejects(() => acquireGitHubRepositoryCommit({ + repository: "github.com@attacker.invalid/repo", + commitSha: sha, + installationToken: "token", + }, { + gitRunner: async () => { + called = true; + return { exitCode: 0, stdout: "", stderr: "" }; + }, + }), /unsafe/); + assert.equal(called, false); +}); + +test("acquisition removes the temporary workspace when commit provenance mismatches", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-acquire-mismatch-")); + let workspace; + const otherSha = "fedcba9876543210fedcba9876543210fedcba98"; + const gitRunner = async (args, options) => { + workspace = options.cwd; + if (args[0] === "rev-parse") return { exitCode: 0, stdout: `${otherSha}\n`, stderr: "" }; + return { exitCode: 0, stdout: "", stderr: "" }; + }; + + await assert.rejects(() => acquireGitHubRepositoryCommit({ + repository: "cmahmud/synsec", + commitSha: sha, + installationToken: "token", + }, { workspaceRoot: root, gitRunner, timeoutMs: 10_000 }), /different from the requested SHA/); + + assert.ok(workspace); + await assert.rejects(() => access(workspace), /ENOENT/); +}); diff --git a/tests/github-sarif-publisher.test.mjs b/tests/github-sarif-publisher.test.mjs new file mode 100644 index 00000000..fcdd8434 --- /dev/null +++ b/tests/github-sarif-publisher.test.mjs @@ -0,0 +1,127 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { gunzipSync } from "node:zlib"; + +import { publishGitHubSarif, sarifRefForContext } from "../packages/github/dist/sarif-publisher.js"; + +function report(commitSha = "abcdef1234567890") { + return { + schemaVersion: "1.0", + reportId: "report-sarif", + generatedAt: "2026-08-22T15:30:00.000Z", + toolVersion: "0.2.0", + target: { path: "/workspace", commitSha }, + scanners: [{ scanner: "opengrep", startedAt: "a", completedAt: "b", findingCount: 1, artifactCount: 0, diagnostics: [] }], + rawFindingCount: 1, + findingCount: 1, + summary: { critical: 0, high: 1, medium: 0, low: 0, info: 0, unknown: 0 }, + securityScore: 90, + findings: [{ + fingerprint: "fp-sarif", + primary: { + id: "finding-1", + title: "Unsafe input", + description: "Untrusted input reaches a sensitive operation.", + category: "sast", + severity: "high", + confidence: 0.95, + scanner: { name: "opengrep", ruleId: "unsafe-input" }, + location: { path: "src/app.ts", startLine: 8, endLine: 8 }, + }, + duplicates: [], + sources: [{ name: "opengrep", ruleId: "unsafe-input" }], + }], + scope: { mode: "repository" }, + }; +} + +test("SARIF ref uses the PR head ref that corresponds to the published head SHA", () => { + assert.equal(sarifRefForContext({ + repository: "cmahmud/synsec", + sha: "abcdef1234567890", + ref: "refs/pull/2/merge", + headRef: "feature/multi-scanner-mvp", + pullRequestNumber: 2, + }), "refs/pull/2/head"); + assert.equal(sarifRefForContext({ + repository: "cmahmud/synsec", + sha: "abcdef1234567890", + ref: "refs/heads/main", + }), "refs/heads/main"); +}); + +test("publishGitHubSarif uploads gzip/base64 SARIF only to GitHub code scanning", async () => { + let request; + const context = { + repository: "cmahmud/synsec", + sha: "abcdef1234567890", + ref: "refs/pull/2/merge", + pullRequestNumber: 2, + }; + const result = await publishGitHubSarif(report(), context, "installation-token", { + fetch: async (url, init) => { + request = { url, init }; + return new Response(JSON.stringify({ id: "sarif-upload-1", url: "https://api.github.com/uploads/1" }), { status: 202 }); + }, + }); + + assert.equal(request.url, "https://api.github.com/repos/cmahmud/synsec/code-scanning/sarifs"); + assert.equal(request.init.redirect, "error"); + assert.equal(request.init.headers.authorization, "Bearer installation-token"); + const body = JSON.parse(request.init.body); + assert.equal(body.commit_sha, "abcdef1234567890"); + assert.equal(body.ref, "refs/pull/2/head"); + const decoded = JSON.parse(gunzipSync(Buffer.from(body.sarif, "base64")).toString("utf8")); + assert.equal(decoded.version, "2.1.0"); + assert.equal(decoded.runs[0].results.length, 1); + assert.equal(result.id, "sarif-upload-1"); + assert.equal(result.ref, "refs/pull/2/head"); + assert.equal(result.compressedBytes > 0, true); +}); + +test("SARIF publication rejects stale reports before transport", async () => { + let called = false; + await assert.rejects( + () => publishGitHubSarif(report("1111111111111111"), { + repository: "cmahmud/synsec", + sha: "2222222222222222", + ref: "refs/heads/main", + }, "token", { + fetch: async () => { + called = true; + throw new Error("transport should not run"); + }, + }), + /report commit does not match.*SARIF publication/, + ); + assert.equal(called, false); +}); + +test("SARIF publication redacts a token if GitHub error text reflects it", async () => { + await assert.rejects( + () => publishGitHubSarif(report(), { + repository: "cmahmud/synsec", + sha: "abcdef1234567890", + ref: "refs/heads/main", + }, "super-secret-token", { + fetch: async () => new Response("failed super-secret-token\nsecond line", { status: 403 }), + }), + (error) => { + assert.match(error.message, /HTTP 403/); + assert.match(error.message, /\[REDACTED\]/); + assert.equal(error.message.includes("super-secret-token"), false); + assert.equal(error.message.includes("\n"), false); + return true; + }, + ); +}); + +test("SARIF publication requires a fully qualified ref outside pull requests", async () => { + await assert.rejects( + () => publishGitHubSarif(report(), { + repository: "cmahmud/synsec", + sha: "abcdef1234567890", + }, "token", { fetch: async () => new Response("", { status: 202 }) }), + /requires a fully qualified repository ref/, + ); +}); diff --git a/tests/github-scan-queue.test.mjs b/tests/github-scan-queue.test.mjs new file mode 100644 index 00000000..896a8743 --- /dev/null +++ b/tests/github-scan-queue.test.mjs @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { FileGitHubScanQueue } from "@synsec/github/scan-queue"; + +async function setup(options = {}) { + const directory = await mkdtemp(join(tmpdir(), "synsec-queue-")); + return { directory, queue: new FileGitHubScanQueue(directory, options) }; +} + +test("scan queue persists commit-pinned repository jobs without credentials", async () => { + const { directory, queue } = await setup({ now: () => Date.parse("2026-08-22T18:20:00.000Z") }); + const job = await queue.enqueue({ + deliveryId: "delivery-1", + installationId: 44, + repository: "example/repo", + headSha: "a".repeat(40), + event: "pull_request", + baseSha: "b".repeat(40), + pullRequestNumber: 12, + }); + const raw = await readFile(join(directory, `${job.jobId}.json`), "utf8"); + assert.equal(raw.includes("token"), false); + assert.equal(raw.includes("clone_url"), false); + assert.equal(raw.includes("privateKey"), false); + if (process.platform !== "win32") assert.equal((await stat(join(directory, `${job.jobId}.json`))).mode & 0o777, 0o600); +}); + +test("scan queue leases, releases, and completes work deterministically", async () => { + let now = Date.parse("2026-08-22T18:20:00.000Z"); + const { queue } = await setup({ now: () => now, leaseMs: 10_000 }); + const first = await queue.enqueue({ deliveryId: "a", installationId: 1, repository: "o/a", headSha: "a".repeat(40), event: "push" }); + now += 1; + await queue.enqueue({ deliveryId: "b", installationId: 1, repository: "o/b", headSha: "b".repeat(40), event: "push" }); + const leased = await queue.claimNext(); + assert.equal(leased.jobId, first.jobId); + assert.equal(leased.attempts, 1); + assert.match(leased.leaseId, /^[a-f0-9]{32}$/); + await queue.release(leased.jobId, leased.leaseId); + const reclaimed = await queue.claimNext(); + assert.equal(reclaimed.jobId, first.jobId); + assert.notEqual(reclaimed.leaseId, leased.leaseId); + assert.equal(await queue.complete(first.jobId, reclaimed.leaseId), true); + assert.equal((await queue.claimNext()).repository, "o/b"); +}); + +test("concurrent claims on one local queue instance are serialized", async () => { + const { queue } = await setup({ leaseMs: 10_000 }); + const pending = await queue.enqueue({ deliveryId: "serialized", installationId: 1, repository: "o/r", headSha: "a".repeat(40), event: "push" }); + const results = await Promise.all([queue.claimNext(), queue.claimNext()]); + const claimed = results.filter(Boolean); + const idle = results.filter((value) => value === undefined); + assert.equal(claimed.length, 1); + assert.equal(idle.length, 1); + assert.equal(claimed[0].jobId, pending.jobId); + assert.equal(claimed[0].attempts, 1); +}); + +test("concurrent duplicate enqueues on one local queue instance persist only one delivery", async () => { + const { queue } = await setup(); + const input = { deliveryId: "duplicate-race", installationId: 1, repository: "o/r", headSha: "a".repeat(40), event: "push" }; + const results = await Promise.allSettled([queue.enqueue(input), queue.enqueue(input)]); + assert.equal(results.filter((result) => result.status === "fulfilled").length, 1); + const rejected = results.find((result) => result.status === "rejected"); + assert.ok(rejected); + assert.match(String(rejected.reason), /already queued/); + const jobs = await queue.list(); + assert.equal(jobs.length, 1); + assert.equal(jobs[0].deliveryId, input.deliveryId); +}); + +test("expired leases can be reclaimed but active leases cannot", async () => { + let now = Date.parse("2026-08-22T18:20:00.000Z"); + const { queue } = await setup({ now: () => now, leaseMs: 10_000 }); + await queue.enqueue({ deliveryId: "lease", installationId: 2, repository: "o/r", headSha: "c".repeat(40), event: "push" }); + const first = await queue.claimNext(); + assert.equal(await queue.claimNext(), undefined); + now += 10_001; + const second = await queue.claimNext(); + assert.equal(second.jobId, first.jobId); + assert.equal(second.attempts, 2); + assert.notEqual(second.leaseId, first.leaseId); +}); + +test("stale lease identities cannot release, fail, or complete reclaimed work", async () => { + let now = Date.parse("2026-08-22T18:20:00.000Z"); + const { queue } = await setup({ now: () => now, leaseMs: 10_000 }); + await queue.enqueue({ deliveryId: "fence", installationId: 2, repository: "o/r", headSha: "c".repeat(40), event: "push" }); + const first = await queue.claimNext(); + now += 10_001; + const second = await queue.claimNext(); + + await assert.rejects(() => queue.release(first.jobId, first.leaseId), /stale or no longer owned/); + await assert.rejects(() => queue.fail(first.jobId, first.leaseId), /stale or no longer owned/); + await assert.rejects(() => queue.complete(first.jobId, first.leaseId), /stale or no longer owned/); + const current = (await queue.list())[0]; + assert.equal(current.leaseId, second.leaseId); + assert.equal(current.status, "leased"); +}); + +test("expired lease identities cannot mutate work even before another worker reclaims it", async () => { + let now = Date.parse("2026-08-22T18:20:00.000Z"); + const { queue } = await setup({ now: () => now, leaseMs: 10_000 }); + await queue.enqueue({ deliveryId: "expired", installationId: 2, repository: "o/r", headSha: "c".repeat(40), event: "push" }); + const leased = await queue.claimNext(); + now += 10_001; + await assert.rejects(() => queue.assertLease(leased.jobId, leased.leaseId), /lease has expired/); + await assert.rejects(() => queue.release(leased.jobId, leased.leaseId), /lease has expired/); +}); + +test("lease renewal extends only the current unique fence", async () => { + let now = Date.parse("2026-08-22T18:20:00.000Z"); + const { queue } = await setup({ now: () => now, leaseMs: 10_000 }); + await queue.enqueue({ deliveryId: "renew", installationId: 2, repository: "o/r", headSha: "c".repeat(40), event: "push" }); + const leased = await queue.claimNext(); + const initialUntil = leased.leaseUntil; + now += 4_000; + const renewed = await queue.renew(leased.jobId, leased.leaseId); + assert.equal(renewed.leaseId, leased.leaseId); + assert.ok(Date.parse(renewed.leaseUntil) > Date.parse(initialUntil)); + await assert.rejects(() => queue.renew(leased.jobId, "f".repeat(32)), /stale or no longer owned/); +}); + +test("queue rejects duplicate deliveries and malformed PR jobs", async () => { + const { queue } = await setup(); + await queue.enqueue({ deliveryId: "same", installationId: 3, repository: "o/r", headSha: "d".repeat(40), event: "push" }); + await assert.rejects(() => queue.enqueue({ deliveryId: "same", installationId: 3, repository: "o/r2", headSha: "e".repeat(40), event: "push" }), /already queued/); + await assert.rejects(() => queue.enqueue({ deliveryId: "pr", installationId: 3, repository: "o/r", headSha: "e".repeat(40), event: "pull_request" }), /require base SHA/); +}); + +test("failed jobs are retained and not claimed again", async () => { + const { queue } = await setup(); + await queue.enqueue({ deliveryId: "fail", installationId: 4, repository: "o/r", headSha: "f".repeat(40), event: "push" }); + const leased = await queue.claimNext(); + const failed = await queue.fail(leased.jobId, leased.leaseId); + assert.equal(failed.status, "failed"); + assert.equal(failed.leaseId, undefined); + assert.equal(await queue.claimNext(), undefined); + assert.equal((await queue.list())[0].status, "failed"); +}); diff --git a/tests/github-scanner-isolation-profile.test.mjs b/tests/github-scanner-isolation-profile.test.mjs new file mode 100644 index 00000000..a31df100 --- /dev/null +++ b/tests/github-scanner-isolation-profile.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + assessSynSecScannerIsolationProfile, + REQUIRED_SYNSEC_SCANNER_ISOLATION_CONTROLS, +} from "@synsec/github/scanner-isolation-profile"; + +const completeProfile = { + schemaVersion: 1, + runtime: "container", + cpuLimit: true, + memoryLimit: true, + networkPolicy: "none", + repositoryReadOnly: true, + rootFilesystemReadOnly: true, + scratchSeparated: true, + credentialsExcluded: true, + durableStateExcluded: true, + privileged: false, + allowPrivilegeEscalation: false, + runAsNonRoot: true, + capabilitiesDropped: true, + hostNetwork: false, + hostPid: false, + hostIpc: false, + hostSocketMounts: false, +}; + +test("scanner isolation profile accepts the complete production control set", () => { + assert.deepEqual(assessSynSecScannerIsolationProfile(completeProfile), { + complete: true, + missing: [], + interpretation: "declared-infrastructure-controls-not-runtime-certification", + }); +}); + +test("scanner isolation profile reports exact missing controls in deterministic order", () => { + const result = assessSynSecScannerIsolationProfile({ + ...completeProfile, + cpuLimit: false, + repositoryReadOnly: false, + rootFilesystemReadOnly: false, + privileged: true, + allowPrivilegeEscalation: true, + runAsNonRoot: false, + capabilitiesDropped: false, + hostNetwork: true, + }); + assert.equal(result.complete, false); + assert.deepEqual(result.missing, [ + "cpu-limit", + "read-only-repository", + "read-only-root-filesystem", + "not-privileged", + "no-privilege-escalation", + "run-as-non-root", + "capabilities-dropped", + "no-host-network", + ]); +}); + +test("scanner isolation profile treats an unsupported schema as wholly untrusted", () => { + const result = assessSynSecScannerIsolationProfile({ ...completeProfile, schemaVersion: 2 }); + assert.equal(result.complete, false); + assert.deepEqual(result.missing, [...REQUIRED_SYNSEC_SCANNER_ISOLATION_CONTROLS]); +}); + +test("scanner isolation profile accepts an explicitly filtered sandbox network", () => { + const result = assessSynSecScannerIsolationProfile({ + ...completeProfile, + runtime: "sandbox", + networkPolicy: "egress-filtered", + }); + assert.equal(result.complete, true); +}); + +test("scanner isolation profile fails closed when no declaration is supplied", () => { + const result = assessSynSecScannerIsolationProfile(undefined); + assert.equal(result.complete, false); + assert.deepEqual(result.missing, [...REQUIRED_SYNSEC_SCANNER_ISOLATION_CONTROLS]); +}); diff --git a/tests/github-scanner-production-readiness.test.mjs b/tests/github-scanner-production-readiness.test.mjs new file mode 100644 index 00000000..42944fb9 --- /dev/null +++ b/tests/github-scanner-production-readiness.test.mjs @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + assessGitHubAppScannerProductionReadiness, + assertGitHubAppScannerProductionReady, +} from "@synsec/github/scanner-production-readiness"; + +const deployment = { + appId: 12345, + privateKey: "-----BEGIN PRIVATE KEY-----\nZmFrZQ==\n-----END PRIVATE KEY-----", + webhookSecret: "a".repeat(32), + listenHost: "0.0.0.0", + tlsMode: "terminated-upstream", + stateDirectory: "/var/lib/synsec/state", + workspaceDirectory: "/var/lib/synsec/workspaces", + scannerIsolation: { + processBoundary: "container", + cpuLimit: true, + memoryLimit: true, + networkPolicy: "none", + repositoryFilesystem: "read-only", + }, +}; + +const scannerIsolationProfile = { + schemaVersion: 1, + runtime: "container", + cpuLimit: true, + memoryLimit: true, + networkPolicy: "none", + repositoryReadOnly: true, + rootFilesystemReadOnly: true, + scratchSeparated: true, + credentialsExcluded: true, + durableStateExcluded: true, + privileged: false, + allowPrivilegeEscalation: false, + runAsNonRoot: true, + capabilitiesDropped: true, + hostNetwork: false, + hostPid: false, + hostIpc: false, + hostSocketMounts: false, +}; + +test("scanner production readiness requires both deployment and detailed isolation declarations", () => { + const result = assessGitHubAppScannerProductionReadiness({ deployment, scannerIsolationProfile }); + assert.equal(result.ready, true); + assert.equal(result.deployment.ready, true); + assert.equal(result.scannerIsolation.complete, true); + assert.equal(result.interpretation, "deployment-and-isolation-declarations-not-runtime-certification"); + assert.doesNotThrow(() => assertGitHubAppScannerProductionReady({ deployment, scannerIsolationProfile })); +}); + +test("scanner production readiness forces strict deployment isolation even when caller leaves it advisory", () => { + const { scannerIsolation, ...unisolatedDeployment } = deployment; + const result = assessGitHubAppScannerProductionReadiness({ + deployment: unisolatedDeployment, + scannerIsolationProfile, + }); + assert.equal(result.ready, false); + assert.ok(result.deployment.issues.some((issue) => + issue.level === "error" && issue.code === "scanner-isolation-missing")); +}); + +test("scanner production readiness fails when the detailed profile exposes a container escape surface", () => { + const result = assessGitHubAppScannerProductionReadiness({ + deployment, + scannerIsolationProfile: { + ...scannerIsolationProfile, + privileged: true, + allowPrivilegeEscalation: true, + capabilitiesDropped: false, + hostSocketMounts: true, + }, + }); + assert.equal(result.ready, false); + assert.deepEqual(result.scannerIsolation.missing, [ + "not-privileged", + "no-privilege-escalation", + "capabilities-dropped", + "no-host-socket-mounts", + ]); + assert.throws( + () => assertGitHubAppScannerProductionReady({ + deployment, + scannerIsolationProfile: { + ...scannerIsolationProfile, + privileged: true, + allowPrivilegeEscalation: true, + capabilitiesDropped: false, + hostSocketMounts: true, + }, + }), + /scanner-isolation:not-privileged, scanner-isolation:no-privilege-escalation, scanner-isolation:capabilities-dropped, scanner-isolation:no-host-socket-mounts/, + ); +}); + +test("scanner production readiness assertion reports codes instead of credential values", () => { + const secret = "must-not-appear"; + assert.throws( + () => assertGitHubAppScannerProductionReady({ + deployment: { ...deployment, webhookSecret: secret }, + scannerIsolationProfile, + }), + (error) => { + assert.match(error.message, /deployment:weak-webhook-secret/); + assert.doesNotMatch(error.message, new RegExp(secret)); + return true; + }, + ); +}); diff --git a/tests/github-shared-state-conformance-runner.test.mjs b/tests/github-shared-state-conformance-runner.test.mjs new file mode 100644 index 00000000..4400793a --- /dev/null +++ b/tests/github-shared-state-conformance-runner.test.mjs @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + runGitHubAppSharedStateConformance, +} from "@synsec/github/shared-state-conformance-runner"; +import { + GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS, +} from "@synsec/github/shared-state-conformance"; + +function createPassingAdapter(overrides = {}) { + const calls = []; + const scenarios = Object.fromEntries( + GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS.map(({ id }) => [ + id, + async () => { + calls.push(id); + }, + ]), + ); + Object.assign(scenarios, overrides); + return { + calls, + adapter: { + backendId: "postgres-v1", + implementationVersion: "0.2.0-test.1", + async reset() { + calls.push("reset"); + }, + scenarios, + }, + }; +} + +test("shared-state conformance runner executes the canonical matrix in stable order", async () => { + const { adapter, calls } = createPassingAdapter(); + let clock = 0; + const report = await runGitHubAppSharedStateConformance(adapter, { + now: () => ++clock, + }); + + assert.equal(report.schemaVersion, 1); + assert.equal(report.backendId, "postgres-v1"); + assert.equal(report.implementationVersion, "0.2.0-test.1"); + assert.equal(report.complete, true); + assert.equal(report.results.length, GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS.length); + assert.deepEqual( + report.results.map(({ id, status }) => [id, status]), + GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS.map(({ id }) => [id, "passed"]), + ); + assert.deepEqual( + calls, + GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS.flatMap(({ id }) => ["reset", id]), + ); + assert.equal(report.coverage.complete, true); + assert.deepEqual(report.coverage.missingScenarioIds, []); +}); + +test("shared-state conformance runner fails closed without exposing adapter errors", async () => { + const failedId = GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS[2].id; + const secret = "postgres://synsec:very-secret-password@db.internal/synsec"; + const { adapter } = createPassingAdapter({ + [failedId]: async () => { + throw new Error(`database failed at ${secret}`); + }, + }); + + const report = await runGitHubAppSharedStateConformance(adapter); + assert.equal(report.complete, false); + assert.equal(report.results.find((result) => result.id === failedId)?.status, "failed"); + assert.deepEqual(report.coverage.missingScenarioIds, [failedId]); + assert.equal(JSON.stringify(report).includes(secret), false); + assert.equal(JSON.stringify(report).includes("db.internal"), false); +}); + +test("shared-state conformance runner treats reset failure as scenario failure and continues", async () => { + const { adapter, calls } = createPassingAdapter(); + let resetCount = 0; + adapter.reset = async () => { + calls.push("reset"); + resetCount += 1; + if (resetCount === 2) throw new Error("reset failed"); + }; + + const report = await runGitHubAppSharedStateConformance(adapter); + assert.equal(report.complete, false); + assert.equal(report.results[1].status, "failed"); + assert.equal(report.results[2].status, "passed"); + assert.equal(resetCount, GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS.length); +}); + +test("shared-state conformance runner times out a hung scenario and continues", async () => { + const timedOutId = GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS[0].id; + const { adapter } = createPassingAdapter({ + [timedOutId]: () => new Promise(() => {}), + }); + + const report = await runGitHubAppSharedStateConformance(adapter, { scenarioTimeoutMs: 100 }); + assert.equal(report.complete, false); + assert.equal(report.results[0].status, "timed-out"); + assert.equal(report.results[1].status, "passed"); +}); + +test("shared-state conformance runner rejects incomplete or expanded scenario maps", async () => { + const { adapter } = createPassingAdapter(); + const missingId = GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS[0].id; + delete adapter.scenarios[missingId]; + await assert.rejects( + runGitHubAppSharedStateConformance(adapter), + /must implement exactly the required scenario ids/, + ); + + const { adapter: expanded } = createPassingAdapter(); + expanded.scenarios["queue.magic-lock"] = async () => {}; + await assert.rejects( + runGitHubAppSharedStateConformance(expanded), + /must implement exactly the required scenario ids/, + ); +}); + +test("shared-state conformance runner rejects unsafe identity fields", async () => { + const { adapter } = createPassingAdapter(); + adapter.backendId = "postgres://user:secret@db.internal/synsec"; + await assert.rejects(runGitHubAppSharedStateConformance(adapter), /backend id must be a bounded non-secret identifier/); + + const { adapter: invalidVersion } = createPassingAdapter(); + invalidVersion.implementationVersion = "build 1 with spaces"; + await assert.rejects(runGitHubAppSharedStateConformance(invalidVersion), /implementation version must be a bounded non-secret identifier/); +}); + +test("shared-state conformance runner bounds per-scenario timeouts", async () => { + const { adapter } = createPassingAdapter(); + await assert.rejects(runGitHubAppSharedStateConformance(adapter, { scenarioTimeoutMs: 99 }), /between 100 and 120000/); + await assert.rejects(runGitHubAppSharedStateConformance(adapter, { scenarioTimeoutMs: 120001 }), /between 100 and 120000/); + await assert.rejects(runGitHubAppSharedStateConformance(adapter, { scenarioTimeoutMs: 1.5 }), /between 100 and 120000/); +}); diff --git a/tests/github-shared-state-evidence.test.mjs b/tests/github-shared-state-evidence.test.mjs new file mode 100644 index 00000000..57c88283 --- /dev/null +++ b/tests/github-shared-state-evidence.test.mjs @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES, +} from "@synsec/github/app-deployment"; +import { + runGitHubAppSharedStateConformance, +} from "@synsec/github/shared-state-conformance-runner"; +import { + GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS, +} from "@synsec/github/shared-state-conformance"; +import { + assessGitHubAppSharedStateConformanceEvidence, +} from "@synsec/github/shared-state-evidence"; + +function createContract(overrides = {}) { + return { + contractVersion: 1, + backendId: "postgres-v1", + implementationVersion: "0.2.0-build.42", + capabilities: Object.fromEntries(REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES.map((capability) => [capability, true])), + evidence: REQUIRED_GITHUB_APP_SHARED_STATE_CAPABILITIES.map((capability) => ({ + capability, + mechanism: "shared-durable-store", + reference: `conformance-${capability}`, + })), + ...overrides, + }; +} + +async function createReport(overrides = {}) { + const scenarios = Object.fromEntries( + GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS.map(({ id }) => [id, async () => {}]), + ); + return runGitHubAppSharedStateConformance({ + backendId: "postgres-v1", + implementationVersion: "0.2.0-build.42", + async reset() {}, + scenarios, + ...overrides, + }); +} + +test("shared-state evidence gate accepts complete identity-bound evidence", async () => { + const report = await createReport(); + const assessment = assessGitHubAppSharedStateConformanceEvidence(createContract(), report); + + assert.equal(assessment.ready, true); + assert.deepEqual(assessment.issues, []); + assert.equal(assessment.passedScenarioIds.length, GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS.length); + assert.deepEqual(assessment.missingScenarioIds, []); +}); + +test("shared-state evidence gate rejects backend and implementation identity mismatch", async () => { + const report = await createReport(); + const assessment = assessGitHubAppSharedStateConformanceEvidence( + createContract({ backendId: "cockroach-v1", implementationVersion: "0.2.1" }), + report, + ); + + assert.equal(assessment.ready, false); + assert.deepEqual( + assessment.issues.map((issue) => issue.code), + ["backend-id-mismatch", "implementation-version-mismatch"], + ); +}); + +test("shared-state evidence gate recomputes scenario truth instead of trusting complete", async () => { + const report = await createReport(); + const failedId = GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS[0].id; + report.results[0].status = "failed"; + report.complete = true; + + const assessment = assessGitHubAppSharedStateConformanceEvidence(createContract(), report); + assert.equal(assessment.ready, false); + assert.deepEqual(assessment.issues.map((issue) => issue.code), ["invalid-conformance-report"]); + assert.deepEqual( + assessment.missingScenarioIds, + GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS.map((scenario) => scenario.id), + ); + assert.equal(assessment.passedScenarioIds.includes(failedId), false); +}); + +test("shared-state evidence gate rejects tampered derived coverage", async () => { + const report = await createReport(); + report.coverage.coveredScenarioIds = []; + report.coverage.missingScenarioIds = ["replay.concurrent-duplicate-claim"]; + + const assessment = assessGitHubAppSharedStateConformanceEvidence(createContract(), report); + assert.equal(assessment.ready, false); + assert.deepEqual(assessment.issues.map((issue) => issue.code), ["invalid-conformance-report"]); +}); + +test("shared-state evidence gate rejects incomplete but structurally honest report", async () => { + const failedId = GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS[3].id; + const report = await createReport({ + scenarios: Object.fromEntries( + GITHUB_APP_SHARED_STATE_CONFORMANCE_SCENARIOS.map(({ id }) => [ + id, + id === failedId ? async () => { throw new Error("expected test failure"); } : async () => {}, + ]), + ), + }); + + const assessment = assessGitHubAppSharedStateConformanceEvidence(createContract(), report); + assert.equal(assessment.ready, false); + assert.deepEqual(assessment.issues.map((issue) => issue.code), ["incomplete-conformance"]); + assert.deepEqual(assessment.missingScenarioIds, [failedId]); +}); + +test("shared-state evidence gate rejects duplicate, unknown, or malformed scenario results", async () => { + const report = await createReport(); + report.results[1] = { ...report.results[0] }; + let assessment = assessGitHubAppSharedStateConformanceEvidence(createContract(), report); + assert.deepEqual(assessment.issues.map((issue) => issue.code), ["invalid-conformance-report"]); + + const unknown = await createReport(); + unknown.results[0].id = "queue.invented-proof"; + assessment = assessGitHubAppSharedStateConformanceEvidence(createContract(), unknown); + assert.deepEqual(assessment.issues.map((issue) => issue.code), ["invalid-conformance-report"]); +}); + +test("shared-state evidence gate rejects invalid backend contract without echoing values", async () => { + const report = await createReport(); + const secret = "postgres://user:secret@db.internal/synsec"; + const assessment = assessGitHubAppSharedStateConformanceEvidence( + createContract({ backendId: secret }), + report, + ); + + assert.equal(assessment.ready, false); + assert.equal(assessment.issues[0].code, "invalid-backend-contract"); + assert.equal(JSON.stringify(assessment).includes(secret), false); + assert.equal(JSON.stringify(assessment).includes("db.internal"), false); +}); diff --git a/tests/github-threshold-none.test.mjs b/tests/github-threshold-none.test.mjs new file mode 100644 index 00000000..f01cf017 --- /dev/null +++ b/tests/github-threshold-none.test.mjs @@ -0,0 +1,40 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { buildGitHubCheck, reportFailsThreshold } from "../packages/github/dist/index.js"; + +const report = { + schemaVersion: "1.0", + reportId: "threshold-none", + generatedAt: "2026-08-22T16:00:00.000Z", + toolVersion: "0.2.0", + target: { path: ".", commitSha: "abcdef1234567890" }, + scanners: [{ scanner: "opengrep", startedAt: "a", completedAt: "b", findingCount: 1, artifactCount: 0, diagnostics: [] }], + rawFindingCount: 1, + findingCount: 1, + summary: { critical: 1, high: 0, medium: 0, low: 0, info: 0, unknown: 0 }, + securityScore: 70, + findings: [{ + fingerprint: "fp-critical", + primary: { + id: "critical", + title: "Critical finding", + description: "Evidence-backed critical repository finding.", + category: "sast", + severity: "critical", + confidence: 0.99, + scanner: { name: "opengrep", ruleId: "critical" }, + location: { path: "src/app.ts", startLine: 1 }, + }, + duplicates: [], + sources: [{ name: "opengrep", ruleId: "critical" }], + }], + scope: { mode: "repository" }, +}; + +test("failOn none never produces a failing GitHub conclusion", () => { + assert.equal(reportFailsThreshold(report, "none"), false); + const check = buildGitHubCheck(report, { repository: "cmahmud/synsec", sha: "abcdef1234567890" }, { threshold: "none" }); + assert.equal(check.conclusion, "neutral"); + assert.match(check.output.summary, /CI threshold: \*\*none\*\*/); +}); diff --git a/tests/github-webhook-rotation.test.mjs b/tests/github-webhook-rotation.test.mjs new file mode 100644 index 00000000..c0ebd83e --- /dev/null +++ b/tests/github-webhook-rotation.test.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import test from "node:test"; + +import { + parseVerifiedGitHubAppWebhook, + verifyGitHubWebhookSignature, +} from "@synsec/github/app"; + +const oldSecret = "o".repeat(32); +const newSecret = "n".repeat(32); +const body = JSON.stringify({ + after: "0123456789abcdef0123456789abcdef01234567", + installation: { id: 42 }, + repository: { full_name: "cmahmud/synsec" }, +}); + +function signature(secret) { + return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; +} + +test("webhook rotation pair accepts either active secret without changing normalized event identity", () => { + assert.equal(verifyGitHubWebhookSignature(body, signature(newSecret), [newSecret, oldSecret]), true); + assert.equal(verifyGitHubWebhookSignature(body, signature(oldSecret), [newSecret, oldSecret]), true); + + const webhook = parseVerifiedGitHubAppWebhook({ + body, + signatureHeader: signature(oldSecret), + webhookSecret: [newSecret, oldSecret], + eventName: "push", + deliveryId: "delivery-rotation", + }); + assert.deepEqual(webhook, { + event: "push", + deliveryId: "delivery-rotation", + installationId: 42, + repository: "cmahmud/synsec", + headSha: "0123456789abcdef0123456789abcdef01234567", + }); +}); + +test("webhook rotation remains bounded and rejects duplicate or oversized secret sets", () => { + assert.throws( + () => verifyGitHubWebhookSignature(body, signature(newSecret), []), + /between 1 and 2 secrets/, + ); + assert.throws( + () => verifyGitHubWebhookSignature(body, signature(newSecret), [newSecret, oldSecret, "x".repeat(32)]), + /between 1 and 2 secrets/, + ); + assert.throws( + () => verifyGitHubWebhookSignature(body, signature(newSecret), [newSecret, newSecret]), + /contains duplicates/, + ); +}); + +test("a secret outside the configured rotation pair is rejected", () => { + assert.equal( + verifyGitHubWebhookSignature(body, signature("x".repeat(32)), [newSecret, oldSecret]), + false, + ); +}); diff --git a/tests/github-workspace-ownership.test.mjs b/tests/github-workspace-ownership.test.mjs new file mode 100644 index 00000000..7858bd61 --- /dev/null +++ b/tests/github-workspace-ownership.test.mjs @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import { access, mkdir, mkdtemp, readFile, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + markGitHubWorkspaceOwned, + reconcileGitHubOwnedWorkspaces, +} from "@synsec/github/workspace-ownership"; + +const hour = 60 * 60 * 1000; +const now = Date.parse("2026-08-22T20:00:00.000Z"); + +async function ownedWorkspace(root, name, createdAt) { + const workspace = join(root, name); + await mkdir(workspace, { mode: 0o700 }); + await markGitHubWorkspaceOwned(workspace, () => createdAt); + return workspace; +} + +test("workspace reconciliation observes valid owned workspaces without deleting by default", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-workspace-observe-")); + const stale = await ownedWorkspace(root, "synsec-github-stale", now - 2 * hour); + const fresh = await ownedWorkspace(root, "synsec-github-fresh", now - 30 * 60 * 1000); + + const result = await reconcileGitHubOwnedWorkspaces(root, { + retentionMs: hour, + now: () => now, + }); + + assert.deepEqual(result, { inspected: 2, owned: 2, stale: 1, deleted: 0, skipped: 0 }); + await access(stale); + await access(fresh); +}); + +test("workspace reconciliation deletes only stale directories with valid ownership markers", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-workspace-delete-")); + const stale = await ownedWorkspace(root, "synsec-github-owned", now - 2 * hour); + const unrelated = join(root, "synsec-github-unowned"); + await mkdir(unrelated); + await writeFile(join(unrelated, "keep.txt"), "keep"); + await mkdir(join(root, "not-synsec")); + + const result = await reconcileGitHubOwnedWorkspaces(root, { + retentionMs: hour, + deleteOwned: true, + now: () => now, + }); + + assert.deepEqual(result, { inspected: 2, owned: 1, stale: 1, deleted: 1, skipped: 1 }); + await assert.rejects(() => access(stale), /ENOENT/); + await access(unrelated); + await access(join(root, "not-synsec")); +}); + +test("workspace reconciliation refuses malformed markers and symlink-shaped candidates", async (t) => { + const root = await mkdtemp(join(tmpdir(), "synsec-workspace-unsafe-")); + const malformed = join(root, "synsec-github-malformed"); + await mkdir(malformed); + await writeFile(join(malformed, ".synsec-workspace.json"), JSON.stringify({ version: 1, workspaceId: "bad", createdAt: "nope" })); + + if (process.platform !== "win32") { + const target = join(root, "target"); + await mkdir(target); + await symlink(target, join(root, "synsec-github-link"), "dir"); + } else { + t.diagnostic("directory symlink assertion skipped on Windows"); + } + + const result = await reconcileGitHubOwnedWorkspaces(root, { + retentionMs: hour, + deleteOwned: true, + now: () => now, + }); + + assert.equal(result.deleted, 0); + assert.equal(result.owned, 0); + assert.ok(result.skipped >= 1); + await access(malformed); +}); + +test("workspace reconciliation bounds retention and deletion batches", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-workspace-bounds-")); + for (let index = 0; index < 3; index += 1) { + await ownedWorkspace(root, `synsec-github-${index}`, now - 2 * hour); + } + + const result = await reconcileGitHubOwnedWorkspaces(root, { + retentionMs: hour, + maxDeletes: 2, + deleteOwned: true, + now: () => now, + }); + assert.equal(result.stale, 3); + assert.equal(result.deleted, 2); + + await assert.rejects(() => reconcileGitHubOwnedWorkspaces(root, { retentionMs: hour - 1 }), /retention/); + await assert.rejects(() => reconcileGitHubOwnedWorkspaces(root, { retentionMs: 31 * 24 * hour }), /retention/); + await assert.rejects(() => reconcileGitHubOwnedWorkspaces(root, { maxDeletes: 0 }), /maxDeletes/); +}); + +test("ownership markers contain no repository, commit, or credential identity", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-workspace-marker-")); + const workspace = join(root, "synsec-github-marker"); + await mkdir(workspace); + const marker = await markGitHubWorkspaceOwned(workspace, () => now); + assert.match(marker.workspaceId, /^[a-f0-9]{32}$/); + const raw = await readFile(join(workspace, ".synsec-workspace.json"), "utf8"); + for (const forbidden of ["repository", "commit", "token", "installation", "github.com"]) { + assert.equal(raw.includes(forbidden), false); + } +}); diff --git a/tests/github.test.mjs b/tests/github.test.mjs new file mode 100644 index 00000000..1f5e9294 --- /dev/null +++ b/tests/github.test.mjs @@ -0,0 +1,199 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + buildGitHubAnnotations, + buildGitHubCheck, + detectGitHubContext, + loadGitHubContext, + reportFailsThreshold, +} from "../packages/github/dist/index.js"; + +function report(overrides = {}) { + const finding = { + fingerprint: "fp-high", + primary: { + id: "f-1", + title: "Unsafe deserialization", + description: "Untrusted input reaches a deserializer.\nReview the data boundary.", + category: "sast", + severity: "high", + confidence: 0.96, + scanner: { name: "opengrep", ruleId: "unsafe-deserialize" }, + location: { path: "./src\\handler.ts", startLine: 14, endLine: 16 }, + remediation: "Use a typed parser and validate the payload before decoding.", + }, + duplicates: [], + sources: [{ name: "opengrep", ruleId: "unsafe-deserialize" }], + }; + + return { + schemaVersion: "1.0", + reportId: "report-1", + generatedAt: "2026-08-22T12:00:00.000Z", + toolVersion: "0.2.0", + target: { path: ".", commitSha: "abc123" }, + scanners: [{ scanner: "opengrep", startedAt: "a", completedAt: "b", findingCount: 1, artifactCount: 0, diagnostics: [] }], + rawFindingCount: 1, + findingCount: 1, + summary: { critical: 0, high: 1, medium: 0, low: 0, info: 0, unknown: 0 }, + securityScore: 88, + findings: [finding], + scope: { mode: "changed-files", baseRef: "main", changedFiles: ["src/handler.ts"] }, + baseline: { new: ["fp-high"], fixed: [], persisting: [] }, + ...overrides, + }; +} + +test("detectGitHubContext extracts safe repository and pull-request metadata", () => { + assert.deepEqual( + detectGitHubContext({ + GITHUB_REPOSITORY: "cmahmud/synsec", + GITHUB_SHA: "abc123", + GITHUB_REF: "refs/pull/42/merge", + GITHUB_BASE_REF: "main", + GITHUB_HEAD_REF: "feature/security", + }), + { + repository: "cmahmud/synsec", + sha: "abc123", + ref: "refs/pull/42/merge", + baseRef: "main", + headRef: "feature/security", + pullRequestNumber: 42, + }, + ); + + assert.equal(detectGitHubContext({ GITHUB_REPOSITORY: "bad repo", GITHUB_SHA: "abc" }), undefined); +}); + +test("pull-request event payload overrides the synthetic merge SHA", () => { + const context = detectGitHubContext( + { + GITHUB_REPOSITORY: "cmahmud/synsec", + GITHUB_SHA: "synthetic-merge-sha", + GITHUB_REF: "refs/pull/42/merge", + GITHUB_BASE_REF: "stale-base", + GITHUB_HEAD_REF: "stale-head", + }, + { + repository: { full_name: "cmahmud/synsec" }, + pull_request: { + number: 42, + head: { sha: "real-head-sha", ref: "feature/security" }, + base: { ref: "main" }, + }, + }, + ); + + assert.equal(context.sha, "real-head-sha"); + assert.equal(context.pullRequestNumber, 42); + assert.equal(context.baseRef, "main"); + assert.equal(context.headRef, "feature/security"); +}); + +test("push payload can supply repository and after SHA", () => { + assert.deepEqual( + detectGitHubContext({}, { + repository: { full_name: "cmahmud/synsec" }, + after: "push-head", + ref: "refs/heads/main", + }), + { repository: "cmahmud/synsec", sha: "push-head", ref: "refs/heads/main" }, + ); +}); + +test("loadGitHubContext reads a bounded local Actions event payload", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-github-")); + const eventPath = join(root, "event.json"); + await writeFile(eventPath, JSON.stringify({ + repository: { full_name: "cmahmud/synsec" }, + pull_request: { + number: 7, + head: { sha: "head-seven", ref: "feature/seven" }, + base: { ref: "main" }, + }, + })); + + try { + const context = await loadGitHubContext({ + GITHUB_EVENT_PATH: eventPath, + GITHUB_SHA: "merge-seven", + GITHUB_REF: "refs/pull/7/merge", + }); + assert.equal(context.sha, "head-seven"); + assert.equal(context.repository, "cmahmud/synsec"); + assert.equal(context.pullRequestNumber, 7); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("loadGitHubContext rejects invalid event JSON instead of guessing", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-github-invalid-")); + const eventPath = join(root, "event.json"); + await writeFile(eventPath, "{not-json"); + try { + await assert.rejects(() => loadGitHubContext({ GITHUB_EVENT_PATH: eventPath }), /does not contain valid JSON/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("GitHub annotations normalize paths, collapse newlines, and use severity levels", () => { + const [annotation] = buildGitHubAnnotations(report()); + assert.equal(annotation.path, "src/handler.ts"); + assert.equal(annotation.start_line, 14); + assert.equal(annotation.end_line, 16); + assert.equal(annotation.annotation_level, "failure"); + assert.equal(annotation.message.includes("\n"), false); + assert.match(annotation.raw_details, /SynSec fingerprint: fp-high/); +}); + +test("baseline mode annotates new findings only", () => { + const base = report(); + const oldFinding = { + ...base.findings[0], + fingerprint: "fp-old", + primary: { ...base.findings[0].primary, id: "f-2", title: "Persisting finding", location: { path: "src/old.ts", startLine: 2 } }, + }; + const withPersisting = { + ...base, + findingCount: 2, + findings: [...base.findings, oldFinding], + baseline: { new: ["fp-high"], fixed: [], persisting: ["fp-old"] }, + }; + assert.equal(buildGitHubAnnotations(withPersisting, { onlyNew: true }).length, 1); + assert.equal(buildGitHubAnnotations(withPersisting, { onlyNew: false }).length, 2); +}); + +test("check result respects configured severity threshold", () => { + const context = { repository: "cmahmud/synsec", sha: "abc123" }; + assert.equal(reportFailsThreshold(report(), "high"), true); + assert.equal(reportFailsThreshold(report(), "critical"), false); + + const failed = buildGitHubCheck(report(), context, { threshold: "high" }); + assert.equal(failed.conclusion, "failure"); + assert.equal(failed.headSha, "abc123"); + assert.match(failed.output.summary, /New: \*\*1\*\*/); + + const neutral = buildGitHubCheck(report(), context, { threshold: "critical" }); + assert.equal(neutral.conclusion, "neutral"); +}); + +test("annotation count is hard-capped to GitHub's per-request maximum", () => { + const base = report(); + const findings = Array.from({ length: 75 }, (_, index) => ({ + ...base.findings[0], + fingerprint: `fp-${index}`, + primary: { + ...base.findings[0].primary, + id: `f-${index}`, + location: { path: `src/${index}.ts`, startLine: index + 1 }, + }, + })); + assert.equal(buildGitHubAnnotations({ ...base, findings, findingCount: findings.length }, { onlyNew: false, maxAnnotations: 999 }).length, 50); +}); diff --git a/tests/gitleaks-incremental.test.mjs b/tests/gitleaks-incremental.test.mjs new file mode 100644 index 00000000..48653788 --- /dev/null +++ b/tests/gitleaks-incremental.test.mjs @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { chmod, mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import { GitleaksAdapter, normalizeGitleaksChangedFiles } from "../packages/scanners/dist/gitleaks.js"; + +test("Gitleaks changed-file paths are bounded, deduplicated, and repository-relative", () => { + assert.deepEqual(normalizeGitleaksChangedFiles(["./src/a.ts", "src/a.ts", "src/b.ts"]), ["src/a.ts", "src/b.ts"]); + assert.deepEqual(normalizeGitleaksChangedFiles([]), []); + assert.equal(normalizeGitleaksChangedFiles(undefined), undefined); + for (const unsafe of ["../outside", "src/../../outside", "/tmp/outside", "C:/outside", "bad\0name"]) { + assert.throws(() => normalizeGitleaksChangedFiles([unsafe]), /unsafe repository path/); + } + assert.throws( + () => normalizeGitleaksChangedFiles(Array.from({ length: 501 }, (_, index) => `src/${index}.ts`)), + /500-file adapter limit/, + ); +}); + +test("Gitleaks incremental scan stages only changed regular files and preserves repository-relative findings", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-gitleaks-root-")); + const bin = await mkdtemp(join(tmpdir(), "synsec-gitleaks-bin-")); + const previousPath = process.env.PATH; + try { + await mkdir(join(root, "src"), { recursive: true }); + await writeFile(join(root, "src/a.ts"), "const token = 'fixture';\n", "utf8"); + await writeFile(join(root, "src/b.ts"), "const other = 'fixture';\n", "utf8"); + await writeFile(join(root, "unrelated.txt"), "must not be staged\n", "utf8"); + await writeFile(join(root, ".gitleaks.toml"), "title = 'fixture'\n", "utf8"); + + const fake = join(bin, "gitleaks"); + await writeFile(fake, `#!/usr/bin/env node +import { access, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +const args = process.argv.slice(2); +if (args[0] !== "dir") process.exit(20); +const reportIndex = args.indexOf("--report-path"); +const report = args[reportIndex + 1]; +const target = args.at(-1); +await access(join(target, "src/a.ts")); +await access(join(target, "src/b.ts")); +await access(join(target, ".gitleaks.toml")); +try { await access(join(target, "unrelated.txt")); process.exit(21); } catch {} +await writeFile(report, JSON.stringify([{ + RuleID: "fixture-secret", + Description: "Fixture secret", + File: join(target, "src/a.ts"), + StartLine: 1, + Fingerprint: "fixture-fingerprint" +}])); +`, "utf8"); + await chmod(fake, 0o755); + process.env.PATH = `${bin}${delimiter}${previousPath ?? ""}`; + + const result = await new GitleaksAdapter().scan({ + target: { path: root }, + changedFiles: ["src/a.ts", "src/b.ts"], + timeoutMs: 10_000, + }); + assert.equal(result.findings.length, 1); + assert.equal(result.findings[0].location?.path, "src/a.ts"); + assert.match(result.diagnostics.join("\n"), /scanned 2 staged changed file/); + } finally { + process.env.PATH = previousPath; + await rm(root, { recursive: true, force: true }); + await rm(bin, { recursive: true, force: true }); + } +}); + +test("Gitleaks incremental scan falls back to full repository scope for symlink ambiguity", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-gitleaks-fallback-root-")); + const outside = await mkdtemp(join(tmpdir(), "synsec-gitleaks-outside-")); + const bin = await mkdtemp(join(tmpdir(), "synsec-gitleaks-fallback-bin-")); + const previousPath = process.env.PATH; + try { + await writeFile(join(outside, "secret.txt"), "fixture\n", "utf8"); + await symlink(join(outside, "secret.txt"), join(root, "linked.txt")); + + const fake = join(bin, "gitleaks"); + await writeFile(fake, `#!/usr/bin/env node +import { writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +const args = process.argv.slice(2); +const report = args[args.indexOf("--report-path") + 1]; +const target = args.at(-1); +if (resolve(target) !== resolve(${JSON.stringify(root)})) process.exit(22); +await writeFile(report, "[]"); +`, "utf8"); + await chmod(fake, 0o755); + process.env.PATH = `${bin}${delimiter}${previousPath ?? ""}`; + + const result = await new GitleaksAdapter().scan({ + target: { path: root }, + changedFiles: ["linked.txt"], + timeoutMs: 10_000, + }); + assert.deepEqual(result.findings, []); + assert.match(result.diagnostics.join("\n"), /fell back to a full repository scan/); + } finally { + process.env.PATH = previousPath; + await rm(root, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + await rm(bin, { recursive: true, force: true }); + } +}); diff --git a/tests/host-secret-source.test.mjs b/tests/host-secret-source.test.mjs new file mode 100644 index 00000000..d9dfc188 --- /dev/null +++ b/tests/host-secret-source.test.mjs @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import { mkdtemp, writeFile, symlink, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { secretValueFromEnvironmentOrFile } from "../scripts/host-secret-source.mjs"; + +test("host secret source accepts a direct bounded environment value", async () => { + const value = await secretValueFromEnvironmentOrFile( + { SYNSEC_POSTGRES_URL: "postgresql://user:pass@db/synsec" }, + "SYNSEC_POSTGRES_URL", + "PostgreSQL credential", + ); + assert.equal(value, "postgresql://user:pass@db/synsec"); +}); + +test("host secret source reads one bounded absolute regular file and strips one terminal newline", async () => { + const directory = await mkdtemp(join(tmpdir(), "synsec-secret-")); + try { + const path = join(directory, "postgres.url"); + await writeFile(path, "postgresql://user:pass@db/synsec\n", { mode: 0o600 }); + const value = await secretValueFromEnvironmentOrFile( + { SYNSEC_POSTGRES_URL_FILE: path }, + "SYNSEC_POSTGRES_URL", + "PostgreSQL credential", + ); + assert.equal(value, "postgresql://user:pass@db/synsec"); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("host secret source fails closed when direct and file modes are both supplied", async () => { + await assert.rejects( + secretValueFromEnvironmentOrFile( + { SYNSEC_POSTGRES_URL: "postgresql://direct/db", SYNSEC_POSTGRES_URL_FILE: "/run/credentials/postgres.url" }, + "SYNSEC_POSTGRES_URL", + "PostgreSQL credential", + ), + /exactly one/, + ); +}); + +test("host secret source rejects relative paths and symlinks", async () => { + await assert.rejects( + secretValueFromEnvironmentOrFile( + { SYNSEC_POSTGRES_URL_FILE: "postgres.url" }, + "SYNSEC_POSTGRES_URL", + "PostgreSQL credential", + ), + /absolute/, + ); + + const directory = await mkdtemp(join(tmpdir(), "synsec-secret-")); + try { + const target = join(directory, "target"); + const link = join(directory, "link"); + await writeFile(target, "postgresql://user:pass@db/synsec", { mode: 0o600 }); + await symlink(target, link); + await assert.rejects( + secretValueFromEnvironmentOrFile( + { SYNSEC_POSTGRES_URL_FILE: link }, + "SYNSEC_POSTGRES_URL", + "PostgreSQL credential", + ), + /regular non-symlink/, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("host secret source rejects empty, oversized, and NUL-bearing secret files", async () => { + const directory = await mkdtemp(join(tmpdir(), "synsec-secret-")); + try { + const empty = join(directory, "empty"); + const oversized = join(directory, "oversized"); + const nul = join(directory, "nul"); + await writeFile(empty, ""); + await writeFile(oversized, "x".repeat(8193)); + await writeFile(nul, "postgresql://db/synsec\0suffix"); + + for (const path of [empty, oversized, nul]) { + await assert.rejects( + secretValueFromEnvironmentOrFile( + { SYNSEC_POSTGRES_URL_FILE: path }, + "SYNSEC_POSTGRES_URL", + "PostgreSQL credential", + ), + ); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/tests/hosted-installation-ownership.test.mjs b/tests/hosted-installation-ownership.test.mjs new file mode 100644 index 00000000..8d09a6e6 --- /dev/null +++ b/tests/hosted-installation-ownership.test.mjs @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { verifyAndClaimSynSecHostedGitHubInstallation } from "@synsec/github/hosted-installation-ownership"; + +function principal(overrides = {}) { + return { + subject: "user_123", + tenantId: "tenant-a", + githubUserId: 101, + ...overrides, + }; +} + +function accessible(overrides = {}) { + return { + id: 9001, + account: { id: 5001, login: "synsec-org", type: "Organization" }, + repositorySelection: "selected", + ...overrides, + }; +} + +function transport(overrides = {}) { + return { + async getAuthenticatedUser() { return { id: 101, login: "maintainer" }; }, + async getAccessibleInstallation() { return accessible(); }, + ...overrides, + }; +} + +test("hosted installation ownership verifies the bound GitHub user and returns secret-free structural evidence", async () => { + let claimed; + const result = await verifyAndClaimSynSecHostedGitHubInstallation({ + principal: principal(), + installationId: 9001, + transport: transport(), + store: { + async claim(input) { + claimed = input; + return "claimed"; + }, + }, + }); + assert.equal(result.status, "verified"); + assert.equal(result.tenantId, "tenant-a"); + assert.equal(result.installationId, 9001); + assert.equal(result.ownership, "claimed"); + assert.equal(result.interpretation, "authenticated-user-access-and-atomic-tenant-claim-only"); + assert.deepEqual(claimed, { + tenantId: "tenant-a", + installationId: 9001, + githubUserId: 101, + accountId: 5001, + accountLogin: "synsec-org", + accountType: "Organization", + }); + assert.doesNotMatch(JSON.stringify(result), /token|secret|authorization/i); +}); + +test("hosted installation ownership rejects a GitHub identity that does not match the authenticated session", async () => { + await assert.rejects( + verifyAndClaimSynSecHostedGitHubInstallation({ + principal: principal(), + installationId: 9001, + transport: transport({ async getAuthenticatedUser() { return { id: 202, login: "other" }; } }), + store: { async claim() { throw new Error("must not claim"); } }, + }), + /does not match the hosted session/, + ); +}); + +test("hosted installation ownership fails closed when the user cannot access the installation or it is suspended", async () => { + await assert.rejects( + verifyAndClaimSynSecHostedGitHubInstallation({ + principal: principal(), + installationId: 9001, + transport: transport({ async getAccessibleInstallation() { return undefined; } }), + store: { async claim() { return "claimed"; } }, + }), + /not accessible/, + ); + await assert.rejects( + verifyAndClaimSynSecHostedGitHubInstallation({ + principal: principal(), + installationId: 9001, + transport: transport({ async getAccessibleInstallation() { return accessible({ suspendedAt: "2026-08-25T00:00:00Z" }); } }), + store: { async claim() { return "claimed"; } }, + }), + /Suspended GitHub installations cannot be claimed/, + ); +}); + +test("hosted installation ownership rejects cross-tenant claims and sanitizes transport/persistence failures", async () => { + await assert.rejects( + verifyAndClaimSynSecHostedGitHubInstallation({ + principal: principal(), + installationId: 9001, + transport: transport(), + store: { async claim() { return "conflict"; } }, + }), + /already claimed by another hosted tenant/, + ); + + await assert.rejects( + verifyAndClaimSynSecHostedGitHubInstallation({ + principal: principal(), + installationId: 9001, + transport: transport({ async getAccessibleInstallation() { throw new Error("Authorization: Bearer gh-secret"); } }), + store: { async claim() { return "claimed"; } }, + }), + (error) => error instanceof Error + && error.message === "GitHub installation ownership verification failed." + && !error.message.includes("gh-secret"), + ); + + await assert.rejects( + verifyAndClaimSynSecHostedGitHubInstallation({ + principal: principal(), + installationId: 9001, + transport: transport(), + store: { async claim() { throw new Error("postgresql://user:password@db/tenant"); } }, + }), + (error) => error instanceof Error + && error.message === "Hosted installation ownership persistence failed." + && !error.message.includes("password"), + ); +}); diff --git a/tests/hosted-installation-reverification-sweep.test.mjs b/tests/hosted-installation-reverification-sweep.test.mjs new file mode 100644 index 00000000..e7e7de55 --- /dev/null +++ b/tests/hosted-installation-reverification-sweep.test.mjs @@ -0,0 +1,222 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + runSynSecHostedInstallationReverificationSweep, + SynSecHostedInstallationReverificationSweepController, +} from "@synsec/github/hosted-installation-reverification-sweep"; + +function target(installationId, tenantId = `tenant-${installationId}`) { + return { + principal: { + subject: `subject-${installationId}`, + tenantId, + githubUserId: 1000 + installationId, + }, + installationId, + }; +} + +function storeFor(results = new Map()) { + return { + async beginReverification(tenantId, installationId, githubUserId) { + return { + epoch: installationId, + tenantId, + installationId, + githubUserId, + accountId: 2000 + installationId, + accountType: "Organization", + }; + }, + async finishVerified(input) { + return results.get(input.installationId) ?? "applied"; + }, + async finishRevoked(input) { + return results.get(input.installationId) ?? "applied"; + }, + async isFreshlyAuthorized() { + return true; + }, + }; +} + +function transportFor(targetValue, behavior = "verified") { + return { + async getAuthenticatedUser() { + return { + id: targetValue.principal.githubUserId, + login: `user-${targetValue.installationId}`, + }; + }, + async getAccessibleInstallation() { + if (behavior === "inaccessible") return undefined; + return { + id: targetValue.installationId, + account: { + id: 2000 + targetValue.installationId, + login: `org-${targetValue.installationId}`, + type: "Organization", + }, + repositorySelection: "selected", + }; + }, + }; +} + +test("re-verification sweep returns only aggregate evidence and respects bounded concurrency", async () => { + const targets = [target(1), target(2), target(3), target(4)]; + let active = 0; + let maximumActive = 0; + const result = await runSynSecHostedInstallationReverificationSweep({ + concurrency: 2, + store: storeFor(new Map([[3, "stale"]])), + provider: { + async listTargets() { + return targets; + }, + async createTransport(targetValue) { + active += 1; + maximumActive = Math.max(maximumActive, active); + await new Promise((resolve) => setTimeout(resolve, 5)); + active -= 1; + return transportFor(targetValue, targetValue.installationId === 2 ? "inaccessible" : "verified"); + }, + }, + }); + + assert.deepEqual(result, { + status: "completed", + attempted: 4, + verified: 2, + revoked: 1, + superseded: 1, + failed: 0, + interpretation: "scheduler-observation-only-not-authorization-evidence", + }); + assert.equal(maximumActive, 2); + const serialized = JSON.stringify(result); + assert.doesNotMatch(serialized, /tenant-|subject-|org-|user-/); +}); + +test("re-verification sweep rejects duplicate tenant installation targets before credentials are requested", async () => { + let transportCalls = 0; + await assert.rejects( + runSynSecHostedInstallationReverificationSweep({ + store: storeFor(), + provider: { + async listTargets() { + return [target(10, "tenant-a"), target(10, "tenant-a")]; + }, + async createTransport(targetValue) { + transportCalls += 1; + return transportFor(targetValue); + }, + }, + }), + /duplicate tenant installation/, + ); + assert.equal(transportCalls, 0); +}); + +test("target discovery errors are categorical and do not reflect backend details", async () => { + await assert.rejects( + runSynSecHostedInstallationReverificationSweep({ + store: storeFor(), + provider: { + async listTargets() { + throw new Error("postgresql://secret-user:secret-pass@db.internal/tenant-data"); + }, + async createTransport() { + throw new Error("not reached"); + }, + }, + }), + (error) => { + assert.equal(error.message, "Hosted installation re-verification target discovery failed."); + assert.doesNotMatch(error.message, /postgresql|secret|tenant-data/); + return true; + }, + ); +}); + +test("per-target credential or transport failures are counted without disclosure", async () => { + const result = await runSynSecHostedInstallationReverificationSweep({ + store: storeFor(), + provider: { + async listTargets() { + return [target(21), target(22)]; + }, + async createTransport(targetValue) { + if (targetValue.installationId === 21) { + throw new Error("github-token ghp_supersecret transport failed for tenant-21"); + } + return transportFor(targetValue); + }, + }, + }); + assert.equal(result.failed, 1); + assert.equal(result.verified, 1); + assert.doesNotMatch(JSON.stringify(result), /ghp_|supersecret|tenant-21/); +}); + +test("process-local controller coalesces overlapping sweeps and exposes only aggregate scheduler status", async () => { + let targetDiscoveryCalls = 0; + let releaseDiscovery; + const discoveryGate = new Promise((resolve) => { + releaseDiscovery = resolve; + }); + const controller = new SynSecHostedInstallationReverificationSweepController({ + store: storeFor(), + provider: { + async listTargets() { + targetDiscoveryCalls += 1; + await discoveryGate; + return [target(31)]; + }, + async createTransport(targetValue) { + return transportFor(targetValue); + }, + }, + }); + + const first = controller.runOnce(); + const second = controller.runOnce(); + assert.equal(first, second); + assert.deepEqual(controller.status(), { + active: true, + completedSweeps: 0, + interpretation: "process-local-scheduler-status-only", + }); + + releaseDiscovery(); + const result = await first; + assert.equal(targetDiscoveryCalls, 1); + assert.equal(result.verified, 1); + assert.deepEqual(controller.status(), { + active: false, + completedSweeps: 1, + lastResult: result, + interpretation: "process-local-scheduler-status-only", + }); +}); + +test("sweep validates concurrency before target discovery", async () => { + let discovered = false; + assert.throws( + () => new SynSecHostedInstallationReverificationSweepController({ + concurrency: 33, + store: storeFor(), + provider: { + async listTargets() { + discovered = true; + return []; + }, + async createTransport(targetValue) { + return transportFor(targetValue); + }, + }, + }), + /concurrency must be between 1 and 32/, + ); + assert.equal(discovered, false); +}); diff --git a/tests/hosted-installation-reverification.test.mjs b/tests/hosted-installation-reverification.test.mjs new file mode 100644 index 00000000..36d006ea --- /dev/null +++ b/tests/hosted-installation-reverification.test.mjs @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + isSynSecHostedInstallationFreshlyAuthorized, + reverifySynSecHostedGitHubInstallation, +} from "@synsec/github/hosted-installation-reverification"; + +function principal(overrides = {}) { + return { subject: "user_123", tenantId: "tenant-a", githubUserId: 101, ...overrides }; +} + +function installation(overrides = {}) { + return { + id: 9001, + account: { id: 5001, login: "synsec-org", type: "Organization" }, + repositorySelection: "selected", + ...overrides, + }; +} + +function transport(overrides = {}) { + return { + async getAuthenticatedUser() { return { id: 101, login: "maintainer" }; }, + async getAccessibleInstallation() { return installation(); }, + ...overrides, + }; +} + +function store(overrides = {}) { + return { + async beginReverification(tenantId, installationId, githubUserId) { + return { epoch: 7, tenantId, installationId, githubUserId, accountId: 5001, accountType: "Organization" }; + }, + async finishVerified() { return "applied"; }, + async finishRevoked() { return "applied"; }, + async isFreshlyAuthorized() { return true; }, + ...overrides, + }; +} + +test("hosted ownership reverification applies fresh user access with a durable fence", async () => { + let finished; + const result = await reverifySynSecHostedGitHubInstallation({ + principal: principal(), + installationId: 9001, + transport: transport(), + store: store({ async finishVerified(input) { finished = input; return "applied"; } }), + }); + assert.deepEqual(result, { + status: "verified", + tenantId: "tenant-a", + installationId: 9001, + epoch: 7, + interpretation: "fresh-user-access-and-fenced-durable-reverification-only", + }); + assert.equal(finished.accountLogin, "synsec-org"); + assert.doesNotMatch(JSON.stringify(result), /token|secret|authorization/i); +}); + +test("definitive inaccessible and suspended observations revoke but retain the durable tenant fence", async () => { + const reasons = []; + const backing = store({ async finishRevoked(input) { reasons.push(input.reason); return "applied"; } }); + const inaccessible = await reverifySynSecHostedGitHubInstallation({ + principal: principal(), installationId: 9001, + transport: transport({ async getAccessibleInstallation() { return undefined; } }), store: backing, + }); + const suspended = await reverifySynSecHostedGitHubInstallation({ + principal: principal(), installationId: 9001, + transport: transport({ async getAccessibleInstallation() { return installation({ suspendedAt: "2026-08-25T00:00:00Z" }); } }), store: backing, + }); + assert.equal(inaccessible.status, "revoked"); + assert.equal(inaccessible.reason, "inaccessible"); + assert.equal(suspended.reason, "suspended"); + assert.deepEqual(reasons, ["inaccessible", "suspended"]); +}); + +test("account identity drift revokes and a superseded result cannot overwrite a newer observation", async () => { + const drift = await reverifySynSecHostedGitHubInstallation({ + principal: principal(), installationId: 9001, + transport: transport({ async getAccessibleInstallation() { return installation({ account: { id: 9999, login: "other", type: "Organization" } }); } }), + store: store(), + }); + assert.equal(drift.status, "revoked"); + assert.equal(drift.reason, "account-identity-changed"); + + const stale = await reverifySynSecHostedGitHubInstallation({ + principal: principal(), installationId: 9001, transport: transport(), + store: store({ async finishVerified() { return "stale"; } }), + }); + assert.equal(stale.status, "superseded"); +}); + +test("transport failure does not manufacture revocation and diagnostics are sanitized", async () => { + let revoked = false; + await assert.rejects( + reverifySynSecHostedGitHubInstallation({ + principal: principal(), installationId: 9001, + transport: transport({ async getAccessibleInstallation() { throw new Error("Authorization: Bearer gh-secret"); } }), + store: store({ async finishRevoked() { revoked = true; return "applied"; } }), + }), + (error) => error instanceof Error + && error.message === "GitHub installation re-verification failed." + && !error.message.includes("gh-secret"), + ); + assert.equal(revoked, false); +}); + +test("reverification requires the durable proof user and freshness checks fail closed on backend errors", async () => { + await assert.rejects( + reverifySynSecHostedGitHubInstallation({ + principal: principal(), installationId: 9001, transport: transport(), + store: store({ async beginReverification() { return undefined; } }), + }), + /proof does not match/, + ); + assert.equal(await isSynSecHostedInstallationFreshlyAuthorized({ + tenantId: "tenant-a", installationId: 9001, maxAgeMs: 60_000, store: store(), + }), true); + await assert.rejects( + isSynSecHostedInstallationFreshlyAuthorized({ + tenantId: "tenant-a", installationId: 9001, maxAgeMs: 60_000, + store: store({ async isFreshlyAuthorized() { throw new Error("postgresql://secret@db/tenant"); } }), + }), + (error) => error instanceof Error + && error.message === "Hosted installation authorization freshness check failed." + && !error.message.includes("secret"), + ); +}); diff --git a/tests/import-call-links.test.mjs b/tests/import-call-links.test.mjs new file mode 100644 index 00000000..163720fc --- /dev/null +++ b/tests/import-call-links.test.mjs @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { buildRepositoryIndex } from "../packages/repository/dist/analysis.js"; +import { buildCallGraph } from "../packages/repository/dist/call-graph.js"; +import { buildImportCallLinkGraph } from "../packages/repository/dist/import-call-links.js"; +import { buildModuleGraph } from "../packages/repository/dist/module-graph.js"; + +async function fixture(filesByPath) { + const root = await mkdtemp(join(tmpdir(), "synsec-import-call-links-")); + const files = []; + for (const [path, content] of Object.entries(filesByPath)) { + await mkdir(join(root, path, "..").replace(/[/\\][^/\\]+[/\\]\.\.$/, ""), { recursive: true }).catch(() => {}); + const absolute = join(root, path); + await mkdir(absolute.slice(0, Math.max(absolute.lastIndexOf("/"), absolute.lastIndexOf("\\"))), { recursive: true }); + await writeFile(absolute, content, "utf8"); + files.push({ path, size: Buffer.byteLength(content) }); + } + return { root, files, cleanup: () => rm(root, { recursive: true, force: true }) }; +} + +async function buildGraphs(root, files) { + const index = await buildRepositoryIndex(root, files); + const moduleGraph = buildModuleGraph(index, files); + const callGraph = await buildCallGraph(root, files); + const importCalls = await buildImportCallLinkGraph(root, files, moduleGraph, callGraph); + return { index, moduleGraph, callGraph, importCalls }; +} + +test("links explicit JS named and namespace imports to unique local functions", async () => { + const repo = await fixture({ + "src/handler.ts": [ + 'import { execute as runCommand } from "./exec.js";', + 'import * as database from "./db.js";', + "export function handler() {", + " runCommand();", + " database.queryUser();", + "}", + ].join("\n"), + "src/exec.ts": "export function execute() { return 1; }\n", + "src/db.ts": "export function queryUser() { return 2; }\n", + }); + try { + const { importCalls } = await buildGraphs(repo.root, repo.files); + assert.equal(importCalls.interpretation, "cross-module-import-call-evidence-only"); + assert.equal(importCalls.linkedCallCount, 2); + assert.deepEqual(importCalls.links.map(({ callee, targetPath, importedName, bindingKind, evidence }) => ({ + callee, targetPath, importedName, bindingKind, evidence, + })), [ + { + callee: "runCommand", + targetPath: "src/exec.ts", + importedName: "execute", + bindingKind: "javascript-named-import", + evidence: "explicit-import-binding-to-unique-local-function", + }, + { + callee: "database.queryUser", + targetPath: "src/db.ts", + importedName: "queryUser", + bindingKind: "javascript-namespace-import", + evidence: "explicit-import-binding-to-unique-local-function", + }, + ]); + } finally { + await repo.cleanup(); + } +}); + +test("links explicit Python from-import aliases through resolved top-level packages", async () => { + const repo = await fixture({ + "pkg/__init__.py": "", + "pkg/service.py": "def execute():\n return 1\n", + "app.py": "from pkg.service import execute as run\n\ndef handler():\n run()\n", + }); + try { + const { importCalls } = await buildGraphs(repo.root, repo.files); + assert.equal(importCalls.linkedCallCount, 1); + assert.deepEqual({ + callee: importCalls.links[0]?.callee, + targetPath: importCalls.links[0]?.targetPath, + importedName: importCalls.links[0]?.importedName, + bindingKind: importCalls.links[0]?.bindingKind, + }, { + callee: "run", + targetPath: "pkg/service.py", + importedName: "execute", + bindingKind: "python-from-import", + }); + } finally { + await repo.cleanup(); + } +}); + +test("refuses ambiguous imported bindings and ambiguous target functions", async () => { + const repo = await fixture({ + "src/handler.ts": [ + 'import { execute as run } from "./one.js";', + 'import { execute as run } from "./two.js";', + "export function handler() {", + " run();", + "}", + ].join("\n"), + "src/one.ts": [ + "export function execute() { return 1; }", + "export function execute() { return 2; }", + ].join("\n"), + "src/two.ts": "export function execute() { return 3; }\n", + }); + try { + const { importCalls } = await buildGraphs(repo.root, repo.files); + assert.equal(importCalls.linkedCallCount, 0); + assert.deepEqual(importCalls.links, []); + } finally { + await repo.cleanup(); + } +}); + +test("does not treat default imports or unresolved external modules as cross-module evidence", async () => { + const repo = await fixture({ + "src/handler.ts": [ + 'import execute from "./exec.js";', + 'import { request } from "external-package";', + "export function handler() {", + " execute();", + " request();", + "}", + ].join("\n"), + "src/exec.ts": "export default function execute() { return 1; }\n", + }); + try { + const { importCalls } = await buildGraphs(repo.root, repo.files); + assert.equal(importCalls.linkedCallCount, 0); + } finally { + await repo.cleanup(); + } +}); + +test("refuses a JS import binding shadowed inside the calling function", async () => { + const repo = await fixture({ + "src/handler.ts": [ + 'import { execute as run } from "./exec.js";', + "export function handler() {", + " const run = localFactory();", + " run();", + "}", + ].join("\n"), + "src/exec.ts": "export function execute() { return 1; }\n", + }); + try { + const { importCalls } = await buildGraphs(repo.root, repo.files); + assert.equal(importCalls.linkedCallCount, 0); + assert.deepEqual(importCalls.links, []); + } finally { + await repo.cleanup(); + } +}); + +test("refuses a Python import binding shadowed by a function parameter", async () => { + const repo = await fixture({ + "pkg/__init__.py": "", + "pkg/service.py": "def execute():\n return 1\n", + "app.py": [ + "from pkg.service import execute as run", + "", + "def handler(run):", + " run()", + ].join("\n"), + }); + try { + const { importCalls } = await buildGraphs(repo.root, repo.files); + assert.equal(importCalls.linkedCallCount, 0); + assert.deepEqual(importCalls.links, []); + } finally { + await repo.cleanup(); + } +}); diff --git a/tests/import-route-handlers.test.mjs b/tests/import-route-handlers.test.mjs new file mode 100644 index 00000000..917bcdad --- /dev/null +++ b/tests/import-route-handlers.test.mjs @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { buildRepositoryIndex } from "@synsec/repository/analysis"; +import { buildModuleGraph } from "@synsec/repository/module-graph"; +import { buildRepositoryRouteFlowAnalysis } from "@synsec/repository/route-flow-analysis"; + +async function makeRepository(filesByPath) { + const root = await mkdtemp(join(tmpdir(), "synsec-imported-route-handler-")); + const files = []; + for (const [path, content] of Object.entries(filesByPath)) { + const absolute = join(root, path); + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, content, "utf8"); + files.push({ path, size: Buffer.byteLength(content) }); + } + return { root, files, cleanup: () => rm(root, { recursive: true, force: true }) }; +} + +async function analyze(repo) { + const index = await buildRepositoryIndex(repo.root, repo.files); + const moduleGraph = buildModuleGraph(index, repo.files); + return await buildRepositoryRouteFlowAnalysis(repo.root, repo.files, index, moduleGraph); +} + +test("Node routes resolve an explicit repository-local named import handler", async () => { + const repo = await makeRepository({ + "server.ts": [ + 'import { listUsers as handleUsers } from "./handlers.js";', + 'router.get("/users", handleUsers);', + ].join("\n"), + "handlers.ts": [ + "export function listUsers() {", + " db.query(sqlText);", + "}", + ].join("\n"), + }); + + try { + const analysis = await analyze(repo); + assert.equal(analysis.entrypoints[0]?.resolution, "imported-named-function"); + assert.equal(analysis.entrypoints[0]?.handler?.path, "handlers.ts"); + assert.equal(analysis.entrypoints[0]?.handler?.name, "listUsers"); + assert.deepEqual(analysis.routeFlows[0]?.evidence.map(({ path, line, kind, depth }) => ({ path, line, kind, depth })), [ + { path: "handlers.ts", line: 2, kind: "database", depth: 0 }, + ]); + assert.equal(JSON.stringify(analysis.routeFlows).includes("sqlText"), false); + assert.equal(JSON.stringify(analysis.routeFlows).includes("db.query"), false); + } finally { + await repo.cleanup(); + } +}); + +test("Node routes resolve an explicitly exported destructured require handler", async () => { + const repo = await makeRepository({ + "server.cjs": [ + 'const { listUsers: handleUsers } = require("./handlers.cjs");', + 'router.get("/users", handleUsers);', + ].join("\n"), + "handlers.cjs": [ + "function listUsers() {", + " db.query(sqlText);", + "}", + "exports.listUsers = listUsers;", + ].join("\n"), + }); + + try { + const analysis = await analyze(repo); + assert.equal(analysis.entrypoints[0]?.resolution, "imported-named-function"); + assert.equal(analysis.entrypoints[0]?.handler?.path, "handlers.cjs"); + } finally { + await repo.cleanup(); + } +}); + +test("same-named local target functions without export evidence remain unresolved", async () => { + const repo = await makeRepository({ + "server.ts": [ + 'import { listUsers as handleUsers } from "./handlers.js";', + 'router.get("/users", handleUsers);', + ].join("\n"), + "handlers.ts": [ + "function listUsers() {", + " db.query(sqlText);", + "}", + ].join("\n"), + }); + + try { + const analysis = await analyze(repo); + assert.equal(analysis.entrypoints[0]?.resolution, "unresolved"); + assert.deepEqual(analysis.routeFlows, []); + } finally { + await repo.cleanup(); + } +}); + +test("shadowed imported route handlers remain unresolved", async () => { + const repo = await makeRepository({ + "server.ts": [ + 'import { listUsers as handleUsers } from "./handlers.js";', + "const handleUsers = localFactory();", + 'router.get("/users", handleUsers);', + ].join("\n"), + "handlers.ts": "export function listUsers() { db.query(sqlText); }\n", + }); + + try { + const analysis = await analyze(repo); + assert.equal(analysis.entrypoints[0]?.resolution, "unresolved"); + assert.deepEqual(analysis.routeFlows, []); + } finally { + await repo.cleanup(); + } +}); + +test("ambiguous imported handler targets remain unresolved", async () => { + const repo = await makeRepository({ + "server.ts": [ + 'import { listUsers as handleUsers } from "./handlers.js";', + 'import { listUsers as handleUsers } from "./other.js";', + 'router.get("/users", handleUsers);', + ].join("\n"), + "handlers.ts": "export function listUsers() { db.query(sqlText); }\n", + "other.ts": "export function listUsers() { db.query(otherSql); }\n", + }); + + try { + const analysis = await analyze(repo); + assert.equal(analysis.entrypoints[0]?.resolution, "unresolved"); + assert.deepEqual(analysis.routeFlows, []); + } finally { + await repo.cleanup(); + } +}); diff --git a/tests/incremental-execution-scope.test.mjs b/tests/incremental-execution-scope.test.mjs new file mode 100644 index 00000000..5dc09ef1 --- /dev/null +++ b/tests/incremental-execution-scope.test.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildReport } from "../packages/report/dist/index.js"; +import { scannerSupportsNativeChangedFiles } from "../packages/scanners/dist/index.js"; + +const interpretation = "scanner-execution-scope-not-coverage-proof"; + +test("built-in scanner changed-file execution classification is conservative", () => { + for (const scanner of ["opengrep", "betterleaks", "gitleaks", "checkov", "trivy", "osv-scanner"]) { + assert.equal(scannerSupportsNativeChangedFiles(scanner), true, scanner); + } + for (const scanner of ["grype", "syft", "scorecard", "unknown-scanner"]) { + assert.equal(scannerSupportsNativeChangedFiles(scanner), false, scanner); + } +}); + +test("report scanner summaries preserve machine-readable execution scope", () => { + const report = buildReport({ + target: { path: "/repo" }, + scope: { mode: "changed-files", baseRef: "base", changedFiles: ["src/a.ts"] }, + scans: [ + { + scanner: "opengrep", + startedAt: "2026-01-01T00:00:00.000Z", + completedAt: "2026-01-01T00:00:01.000Z", + target: { path: "/repo" }, + findings: [], + diagnostics: [], + executionScope: { + mode: "changed-files-native", + changedFileCount: 1, + interpretation, + }, + }, + { + scanner: "syft", + startedAt: "2026-01-01T00:00:00.000Z", + completedAt: "2026-01-01T00:00:01.000Z", + target: { path: "/repo" }, + findings: [], + diagnostics: [], + executionScope: { + mode: "repository-then-filtered", + changedFileCount: 1, + interpretation, + }, + }, + ], + }); + + assert.deepEqual(report.scanners.map((scanner) => [scanner.scanner, scanner.executionScope]), [ + ["opengrep", { mode: "changed-files-native", changedFileCount: 1, interpretation }], + ["syft", { mode: "repository-then-filtered", changedFileCount: 1, interpretation }], + ]); + assert.equal(report.scope.mode, "changed-files"); +}); + +test("execution scope remains optional for legacy and imported scan results", () => { + const report = buildReport({ + target: { path: "/repo" }, + scans: [{ + scanner: "legacy", + startedAt: "2026-01-01T00:00:00.000Z", + completedAt: "2026-01-01T00:00:01.000Z", + target: { path: "/repo" }, + findings: [], + diagnostics: [], + }], + }); + assert.equal(report.scanners[0].executionScope, undefined); +}); diff --git a/tests/incremental-plan.test.mjs b/tests/incremental-plan.test.mjs new file mode 100644 index 00000000..810f31cd --- /dev/null +++ b/tests/incremental-plan.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildIncrementalScanPlan } from "@synsec/repository/incremental-plan"; + +function graph() { + return { + schemaVersion: 1, + nodes: ["src/a.ts", "src/b.ts", "src/c.ts", "src/d.ts"], + edges: [ + { from: "src/b.ts", specifier: "./a", kind: "import", line: 1, target: "src/a.ts", resolution: "repository-file" }, + { from: "src/c.ts", specifier: "./b", kind: "import", line: 1, target: "src/b.ts", resolution: "repository-file" }, + { from: "src/d.ts", specifier: "pkg", kind: "import", line: 1, resolution: "external-or-unresolved" }, + ], + resolvedEdgeCount: 2, + unresolvedEdgeCount: 1, + }; +} + +test("incremental planner selects direct changes plus bounded local dependents", () => { + const plan = buildIncrementalScanPlan(graph(), ["src/a.ts"], { maxDependentDepth: 2, maxDependents: 10 }); + assert.equal(plan.mode, "targeted"); + assert.equal(plan.reason, "targeted-with-bounded-dependents"); + assert.deepEqual(plan.changedFiles, ["src/a.ts"]); + assert.deepEqual(plan.selectedFiles, ["src/a.ts", "src/b.ts", "src/c.ts"]); + assert.deepEqual(plan.dependentFiles, [ + { path: "src/b.ts", depth: 1, triggeredBy: "src/a.ts" }, + { path: "src/c.ts", depth: 2, triggeredBy: "src/a.ts" }, + ]); + assert.equal(plan.interpretation, "coverage-heuristic-not-proof-of-unaffected-code"); +}); + +test("incremental planner falls back to full scan for high-impact repository configuration", () => { + for (const changed of [ + ".github/workflows/ci.yml", + "package-lock.json", + "infra/main.tf", + "tsconfig.json", + "config/security.yaml", + "synsec.config.json", + ]) { + const plan = buildIncrementalScanPlan(graph(), [changed]); + assert.equal(plan.mode, "full-repository", changed); + assert.equal(plan.reason, "high-impact-file-changed", changed); + assert.deepEqual(plan.selectedFiles, []); + } +}); + +test("incremental planner fails closed when a changed analyzable source file is missing from the graph", () => { + const plan = buildIncrementalScanPlan(graph(), ["src/not-indexed.ts"]); + assert.equal(plan.mode, "full-repository"); + assert.equal(plan.reason, "changed-source-not-indexed"); +}); + +test("incremental planner fails closed when dependent expansion exceeds its bound", () => { + const plan = buildIncrementalScanPlan(graph(), ["src/a.ts"], { maxDependentDepth: 3, maxDependents: 1 }); + assert.equal(plan.mode, "full-repository"); + assert.equal(plan.reason, "dependent-expansion-exceeded-bound"); +}); + +test("incremental planner rejects unsafe paths and bounds configuration", () => { + assert.equal(buildIncrementalScanPlan(graph(), ["../outside.ts"]).reason, "invalid-changed-path"); + assert.equal(buildIncrementalScanPlan(graph(), ["/absolute.ts"]).reason, "invalid-changed-path"); + assert.throws(() => buildIncrementalScanPlan(graph(), ["src/a.ts"], { maxDependentDepth: 11 }), /maxDependentDepth/); + assert.throws(() => buildIncrementalScanPlan(graph(), ["src/a.ts"], { maxDependents: 0 }), /maxDependents/); +}); + +test("incremental planner handles no-op changes without manufacturing scope", () => { + const plan = buildIncrementalScanPlan(graph(), []); + assert.equal(plan.mode, "targeted"); + assert.equal(plan.reason, "no-changes"); + assert.deepEqual(plan.selectedFiles, []); +}); diff --git a/tests/integration.test.mjs b/tests/integration.test.mjs new file mode 100644 index 00000000..04335541 --- /dev/null +++ b/tests/integration.test.mjs @@ -0,0 +1,165 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { delimiter, join } from "node:path"; +import { tmpdir } from "node:os"; +import { defaultConfig } from "../packages/config/dist/index.js"; +import { runScanEngine } from "../packages/engine/dist/index.js"; + +test("scan engine runs an available adapter end-to-end and builds a correlated report", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-integration-repo-")); + const bin = await mkdtemp(join(tmpdir(), "synsec-integration-bin-")); + const originalPath = process.env.PATH ?? ""; + + try { + await mkdir(join(root, "src")); + await writeFile(join(root, "package.json"), JSON.stringify({ name: "fixture", version: "1.0.0" })); + await writeFile(join(root, "src", "index.js"), `import express from "express"; +const app = express(); +app.get("/health", (_req, res) => res.json({ ok: true })); +`); + + const trivy = join(bin, "trivy"); + await writeFile(trivy, `#!/bin/sh +if [ "$1" = "--version" ]; then + echo "Version: 99.0.0-fixture" + exit 0 +fi +cat <<'JSON' +{"Results":[{"Target":"package-lock.json","Vulnerabilities":[{"VulnerabilityID":"CVE-2026-4242","PkgName":"express","InstalledVersion":"1.0.0","FixedVersion":"1.0.1","Title":"Fixture dependency vulnerability","Severity":"HIGH"}]}]} +JSON +`); + await chmod(trivy, 0o755); + process.env.PATH = `${bin}${delimiter}${originalPath}`; + + const config = structuredClone(defaultConfig); + config.scanners = ["trivy"]; + config.parallelism = 1; + + const outcome = await runScanEngine({ rootPath: root, config, toolVersion: "test" }); + assert.equal(outcome.report.scanners.length, 1); + assert.equal(outcome.report.scanners[0].scanner, "trivy"); + assert.equal(outcome.report.rawFindingCount, 1); + assert.equal(outcome.report.findingCount, 1); + assert.equal(outcome.report.summary.high, 1); + assert.equal(outcome.report.scope.mode, "repository"); + assert.equal(outcome.failures.length, 0); + assert.equal(outcome.report.repository.languages.JavaScript, 1); + assert.equal(outcome.repositoryIndex.indexedFileCount, 1); + assert.ok(outcome.repositoryIndex.moduleEdges.some((edge) => edge.specifier === "express")); + assert.ok(outcome.repositoryIndex.routes.some((route) => route.route === "/health")); + const usage = outcome.report.findings[0].primary.metadata.dependencyUsage; + assert.equal(usage.status, "observed-import"); + assert.equal(usage.packageName, "express"); + assert.equal(usage.evidence[0].specifier, "express"); + } finally { + process.env.PATH = originalPath; + await rm(root, { recursive: true, force: true }); + await rm(bin, { recursive: true, force: true }); + } +}); + +test("scan engine adds bounded proximity signals to located non-secret findings", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-context-repo-")); + const bin = await mkdtemp(join(tmpdir(), "synsec-context-bin-")); + const originalPath = process.env.PATH ?? ""; + + try { + await mkdir(join(root, "src")); + await writeFile(join(root, "src", "app.js"), `import express from "express"; +const app = express(); +function requireAuth(req, res, next) { return next(); } +app.get("/users/:id", requireAuth, async (req, res) => { + const rows = await db.query("select * from users where id = $1", [req.params.id]); + res.json(rows); +}); +`); + + const opengrep = join(bin, "opengrep"); + await writeFile(opengrep, `#!/bin/sh +if [ "$1" = "--version" ]; then + echo "opengrep 99.0.0-fixture" + exit 0 +fi +cat <<'JSON' +{"results":[{"check_id":"fixture.sql","path":"src/app.js","start":{"line":5,"col":3},"end":{"line":5,"col":40},"extra":{"message":"Fixture query finding","severity":"ERROR","metadata":{"cwe":["CWE-89"]}}}]} +JSON +`); + await chmod(opengrep, 0o755); + process.env.PATH = `${bin}${delimiter}${originalPath}`; + + const config = structuredClone(defaultConfig); + config.scanners = ["opengrep"]; + config.parallelism = 1; + const outcome = await runScanEngine({ rootPath: root, config, toolVersion: "test" }); + const context = outcome.report.findings[0].primary.metadata.repositoryContext; + assert.equal(context.interpretation, "proximity-signals-only"); + assert.ok(context.nearbyRoutes.some((signal) => signal.route === "/users/:id")); + assert.ok(context.nearbyAuthSignals.some((signal) => signal.kind === "authentication")); + assert.ok(context.nearbySinks.some((signal) => signal.kind === "database")); + assert.equal("evidence" in context.nearbySinks[0], false); + } finally { + process.env.PATH = originalPath; + await rm(root, { recursive: true, force: true }); + await rm(bin, { recursive: true, force: true }); + } +}); + +test("scan engine enriches exact sink findings across an explicit local import", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-cross-module-engine-")); + const bin = await mkdtemp(join(tmpdir(), "synsec-cross-module-bin-")); + const originalPath = process.env.PATH ?? ""; + + try { + await writeFile(join(root, "server.ts"), [ + 'import { runQuery } from "./service.js";', + "export function listUsers() {", + " runQuery();", + "}", + 'router.get("/users", listUsers);', + ].join("\n")); + await writeFile(join(root, "service.ts"), [ + "export function runQuery() {", + " db.query(secretSql);", + "}", + ].join("\n")); + + const opengrep = join(bin, "opengrep"); + await writeFile(opengrep, `#!/bin/sh +if [ "$1" = "--version" ]; then + echo "opengrep 99.0.0-fixture" + exit 0 +fi +cat <<'JSON' +{"results":[{"check_id":"fixture.cross-module","path":"service.ts","start":{"line":2,"col":3},"end":{"line":2,"col":22},"extra":{"message":"Fixture imported sink","severity":"ERROR","metadata":{"cwe":["CWE-89"]}}}]} +JSON +`); + await chmod(opengrep, 0o755); + process.env.PATH = `${bin}${delimiter}${originalPath}`; + + const config = structuredClone(defaultConfig); + config.scanners = ["opengrep"]; + config.parallelism = 1; + const outcome = await runScanEngine({ rootPath: root, config, toolVersion: "test" }); + const primary = outcome.report.findings[0].primary; + const routeFlow = primary.metadata.routeFlow; + + assert.equal(routeFlow.length, 1); + assert.equal(routeFlow[0].method, "GET"); + assert.equal(routeFlow[0].route, "/users"); + assert.equal(routeFlow[0].resolution, "named-function"); + assert.equal(routeFlow[0].handler, "listUsers"); + assert.equal(routeFlow[0].sinkKind, "database"); + assert.equal(routeFlow[0].functionName, "runQuery"); + assert.equal(routeFlow[0].depth, 1); + assert.equal(routeFlow[0].callScope, "same-file-and-explicit-imports"); + assert.equal(routeFlow[0].interpretation, "structural-route-call-sink-evidence-only"); + assert.equal(JSON.stringify(routeFlow).includes("secretSql"), false); + assert.equal(JSON.stringify(routeFlow).includes("db.query"), false); + assert.equal(JSON.stringify(routeFlow).includes("service.ts"), false); + } finally { + process.env.PATH = originalPath; + await rm(root, { recursive: true, force: true }); + await rm(bin, { recursive: true, force: true }); + } +}); diff --git a/tests/koa-request-input-flow.test.mjs b/tests/koa-request-input-flow.test.mjs new file mode 100644 index 00000000..3c282093 --- /dev/null +++ b/tests/koa-request-input-flow.test.mjs @@ -0,0 +1,217 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { buildRepositoryIndex } from "@synsec/repository/analysis"; +import { buildCallGraph } from "@synsec/repository/call-graph"; +import { + buildKoaRouteRequestInputFlowContexts, + findingKoaRequestInputFlowEvidence, +} from "@synsec/repository/koa-request-input-flow"; +import { buildModuleGraph } from "@synsec/repository/module-graph"; +import { buildRepositoryRouteFlowAnalysis } from "@synsec/repository/route-flow-analysis"; + +async function makeRepository(files) { + const root = await mkdtemp(join(tmpdir(), "synsec-koa-request-flow-")); + const inputs = []; + for (const [path, content] of Object.entries(files)) { + await mkdir(dirname(join(root, path)), { recursive: true }); + await writeFile(join(root, path), content, "utf8"); + inputs.push({ path, size: Buffer.byteLength(content) }); + } + return { + root, + files: inputs, + cleanup: () => rm(root, { recursive: true, force: true }), + }; +} + +async function analyze(repo) { + const index = await buildRepositoryIndex(repo.root, repo.files); + return buildRepositoryRouteFlowAnalysis( + repo.root, + repo.files, + index, + buildModuleGraph(index, repo.files), + ); +} + +test("Koa direct context access on a call line produces bounded source-to-sink evidence", async () => { + const source = [ + 'import Router from "@koa/router";', + 'const router = new Router({ prefix: "/api" });', + "function runJob(ctx) {", + " execute(ctx.request.body.command);", + "}", + "function execute(command) {", + " child_process.exec(command);", + "}", + 'router.post("/jobs/run", runJob);', + ].join("\n"); + const repo = await makeRepository({ "routes.ts": source }); + try { + const analysis = await analyze(repo); + const entrypoint = analysis.entrypoints.find((item) => item.route.route === "/api/jobs/run" && item.route.frameworkHint === "Koa router"); + assert.equal(entrypoint?.resolution, "named-function"); + + const flow = analysis.koaRequestInputFlows.find((item) => item.route.route === "/api/jobs/run"); + assert.equal(flow?.interpretation, "structural-koa-context-source-direct-call-sink-evidence-only"); + assert.deepEqual(flow?.evidence.map((item) => ({ + sourceKind: item.source.kind, + sourceAccess: item.source.access, + sourceLine: item.source.line, + sinkKind: item.sink.kind, + sinkLine: item.sink.line, + callDistance: item.callDistance, + })), [{ + sourceKind: "body", + sourceAccess: "koa.Context.request.body", + sourceLine: 4, + sinkKind: "process", + sinkLine: 7, + callDistance: 1, + }]); + + assert.deepEqual(findingKoaRequestInputFlowEvidence(analysis.koaRequestInputFlows, "routes.ts", 7), [{ + method: "POST", + route: "/api/jobs/run", + frameworkHint: "Koa router", + handler: "runJob", + sourceKind: "body", + sourceFunction: "runJob", + sinkKind: "process", + sinkFunction: "execute", + callDistance: 1, + interpretation: "structural-koa-context-source-direct-call-sink-evidence-only", + }]); + } finally { + await repo.cleanup(); + } +}); + +test("Koa same-line context query and header access correlate only to the exact sink line", async () => { + const source = [ + 'import Router from "@koa/router";', + "const router = new Router();", + "function search(ctx) {", + " db.execute(ctx.query.term);", + " fetch(ctx.get(\"x-upstream\"));", + "}", + 'router.get("/search", search);', + ].join("\n"); + const repo = await makeRepository({ "routes.ts": source }); + try { + const analysis = await analyze(repo); + const flow = analysis.koaRequestInputFlows.find((item) => item.route.route === "/search"); + assert.deepEqual(flow?.evidence.map((item) => [item.source.kind, item.sink.kind, item.sink.line, item.callDistance]), [ + ["query", "database", 4, 0], + ["header", "network", 5, 0], + ]); + } finally { + await repo.cleanup(); + } +}); + +test("Koa imported handlers retain framework identity and direct request-flow evidence", async () => { + const routes = [ + 'import Router from "@koa/router";', + 'import { runJob } from "./handlers.js";', + "const router = new Router();", + 'router.post("/run", runJob);', + ].join("\n"); + const handlers = [ + "export function runJob(ctx) {", + " child_process.exec(ctx.params.command);", + "}", + ].join("\n"); + const repo = await makeRepository({ "routes.ts": routes, "handlers.ts": handlers }); + try { + const analysis = await analyze(repo); + const flow = analysis.koaRequestInputFlows.find((item) => item.route.route === "/run"); + assert.equal(flow?.resolution, "imported-named-function"); + assert.equal(flow?.route.frameworkHint, "Koa router"); + assert.deepEqual(flow?.evidence.map((item) => [item.source.kind, item.source.path, item.sink.path, item.callDistance]), [ + ["path", "handlers.ts", "handlers.ts", 0], + ]); + } finally { + await repo.cleanup(); + } +}); + +test("Koa request flow fails closed on locals and wider forwarding", async () => { + const source = [ + 'import Router from "@koa/router";', + "const router = new Router();", + "function runJob(ctx) {", + " const command = ctx.query.command;", + " execute(command);", + "}", + "function execute(command) {", + " child_process.exec(command);", + "}", + 'router.post("/run", runJob);', + ].join("\n"); + const repo = await makeRepository({ "routes.ts": source }); + try { + const analysis = await analyze(repo); + assert.deepEqual(analysis.koaRequestInputFlows, []); + } finally { + await repo.cleanup(); + } +}); + +test("Koa response body is not promoted into request-source evidence", async () => { + const source = [ + 'import Router from "@koa/router";', + "const router = new Router();", + "function render(ctx) {", + " child_process.exec(ctx.body.command);", + "}", + 'router.get("/render", render);', + ].join("\n"); + const repo = await makeRepository({ "routes.ts": source }); + try { + const analysis = await analyze(repo); + assert.equal(analysis.routeFlows.some((item) => item.route.route === "/render"), true); + assert.deepEqual(analysis.koaRequestInputFlows, []); + } finally { + await repo.cleanup(); + } +}); + +test("Koa request flow does not promote generic Node routes with ctx-looking parameters", async () => { + const source = [ + "function search(ctx) {", + " child_process.exec(ctx.query.command);", + "}", + 'router.get("/search", search);', + ].join("\n"); + const repo = await makeRepository({ "routes.ts": source }); + try { + const analysis = await analyze(repo); + assert.equal(analysis.entrypoints.some((item) => item.route.route === "/search"), true); + assert.deepEqual(analysis.koaRequestInputFlows, []); + } finally { + await repo.cleanup(); + } +}); + +test("Koa request flow validates its own evidence bounds", async () => { + const source = [ + 'import Router from "@koa/router";', + "const router = new Router();", + "function search(ctx) { child_process.exec(ctx.query.command); }", + 'router.get("/search", search);', + ].join("\n"); + const repo = await makeRepository({ "routes.ts": source }); + try { + const graph = await buildCallGraph(repo.root, repo.files); + await assert.rejects( + buildKoaRouteRequestInputFlowContexts(repo.root, [], graph, { maxEvidence: 0 }), + /Koa request-flow maxEvidence must be an integer between 1 and 50/, + ); + } finally { + await repo.cleanup(); + } +}); diff --git a/tests/koa-request-input-forwarding.test.mjs b/tests/koa-request-input-forwarding.test.mjs new file mode 100644 index 00000000..5a9dea6b --- /dev/null +++ b/tests/koa-request-input-forwarding.test.mjs @@ -0,0 +1,208 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { buildRepositoryIndex } from "@synsec/repository/analysis"; +import { buildCallGraph } from "@synsec/repository/call-graph"; +import { + buildKoaRouteRequestInputForwardingContexts, + findingKoaRequestInputForwardingEvidence, +} from "@synsec/repository/koa-request-input-forwarding"; +import { buildModuleGraph } from "@synsec/repository/module-graph"; +import { buildRepositoryRouteFlowAnalysis } from "@synsec/repository/route-flow-analysis"; + +async function makeRepository(files) { + const root = await mkdtemp(join(tmpdir(), "synsec-koa-request-forwarding-")); + const inputs = []; + for (const [path, content] of Object.entries(files)) { + await mkdir(dirname(join(root, path)), { recursive: true }); + await writeFile(join(root, path), content, "utf8"); + inputs.push({ path, size: Buffer.byteLength(content) }); + } + return { + root, + files: inputs, + cleanup: () => rm(root, { recursive: true, force: true }), + }; +} + +async function analyze(repo, options = {}) { + const index = await buildRepositoryIndex(repo.root, repo.files); + return buildRepositoryRouteFlowAnalysis( + repo.root, + repo.files, + index, + buildModuleGraph(index, repo.files), + options, + ); +} + +test("Koa single-use local request input forwards into one exact helper sink", async () => { + const source = [ + 'import Router from "@koa/router";', + "const router = new Router();", + "function runJob(ctx) {", + " const command = ctx.request.body.command;", + " execute(command);", + "}", + "function execute(command) {", + " child_process.exec(command);", + "}", + 'router.post("/run", runJob);', + ].join("\n"); + const repo = await makeRepository({ "routes.ts": source }); + try { + const analysis = await analyze(repo); + assert.deepEqual(analysis.koaRequestInputFlows, []); + const flow = analysis.koaRequestInputForwardingFlows.find((item) => item.route.route === "/run"); + assert.equal(flow?.interpretation, "structural-koa-context-source-single-use-local-call-sink-evidence-only"); + assert.deepEqual(flow?.evidence.map((item) => ({ + sourceKind: item.source.kind, + sourceLine: item.source.line, + useLine: item.binding.useLine, + sinkKind: item.sink.kind, + sinkLine: item.sink.line, + callDistance: item.callDistance, + })), [{ + sourceKind: "body", + sourceLine: 4, + useLine: 5, + sinkKind: "process", + sinkLine: 8, + callDistance: 1, + }]); + assert.deepEqual(findingKoaRequestInputForwardingEvidence(analysis.koaRequestInputForwardingFlows, "routes.ts", 8), [{ + method: "POST", + route: "/run", + frameworkHint: "Koa router", + handler: "runJob", + sourceKind: "body", + sourceFunction: "runJob", + sinkKind: "process", + sinkFunction: "execute", + callDistance: 1, + bindingHops: 1, + interpretation: "structural-koa-context-source-single-use-local-call-sink-evidence-only", + }]); + } finally { + await repo.cleanup(); + } +}); + +test("Koa single-use local supports direct member-qualified database sink", async () => { + const source = [ + 'import Router from "@koa/router";', + "const router = new Router();", + "async function search(ctx) {", + " const term = ctx.query.term;", + " await db.query(term);", + "}", + 'router.get("/search", search);', + ].join("\n"); + const repo = await makeRepository({ "routes.ts": source }); + try { + const analysis = await analyze(repo); + const flow = analysis.koaRequestInputForwardingFlows.find((item) => item.route.route === "/search"); + assert.deepEqual(flow?.evidence.map((item) => [item.source.kind, item.sink.kind, item.callDistance]), [ + ["query", "database", 0], + ]); + } finally { + await repo.cleanup(); + } +}); + +test("Koa forwarding fails closed on multiple use, transformation and mutable bindings", async () => { + const variants = [ + ["multiple", [ + "function run(ctx) {", + " const command = ctx.query.command;", + " audit(command);", + " execute(command);", + "}", + ]], + ["transform", [ + "function run(ctx) {", + " const command = ctx.query.command;", + " execute(command.trim());", + "}", + ]], + ["mutable", [ + "function run(ctx) {", + " let command = ctx.query.command;", + " execute(command);", + "}", + ]], + ]; + for (const [name, handler] of variants) { + const source = [ + 'import Router from "@koa/router";', + "const router = new Router();", + ...handler, + "function execute(command) { child_process.exec(command); }", + 'router.post("/run", run);', + ].join("\n"); + const repo = await makeRepository({ [`${name}.ts`]: source }); + try { + const analysis = await analyze(repo); + assert.deepEqual(analysis.koaRequestInputForwardingFlows, [], name); + } finally { + await repo.cleanup(); + } + } +}); + +test("Koa forwarding rejects response body and generic Node routes", async () => { + const koa = [ + 'import Router from "@koa/router";', + "const router = new Router();", + "function run(ctx) {", + " const command = ctx.body.command;", + " execute(command);", + "}", + "function execute(command) { child_process.exec(command); }", + 'router.post("/run", run);', + ].join("\n"); + const generic = [ + "function run(ctx) {", + " const command = ctx.query.command;", + " execute(command);", + "}", + "function execute(command) { child_process.exec(command); }", + 'router.post("/generic", run);', + ].join("\n"); + const repo = await makeRepository({ "koa.ts": koa, "generic.ts": generic }); + try { + const analysis = await analyze(repo); + assert.deepEqual(analysis.koaRequestInputForwardingFlows, []); + } finally { + await repo.cleanup(); + } +}); + +test("Koa forwarding honors forward-line and evidence bounds", async () => { + const source = [ + 'import Router from "@koa/router";', + "const router = new Router();", + "function run(ctx) {", + " const command = ctx.get(\"x-command\");", + "", + "", + " execute(command);", + "}", + "function execute(command) { child_process.exec(command); }", + 'router.post("/run", run);', + ].join("\n"); + const repo = await makeRepository({ "routes.ts": source }); + try { + const bounded = await analyze(repo, { maxKoaRequestInputForwardLines: 2 }); + assert.deepEqual(bounded.koaRequestInputForwardingFlows, []); + const graph = await buildCallGraph(repo.root, repo.files); + await assert.rejects( + buildKoaRouteRequestInputForwardingContexts(repo.root, [], graph, { maxEvidence: 0 }), + /Koa request-forwarding maxEvidence must be an integer between 1 and 50/, + ); + } finally { + await repo.cleanup(); + } +}); diff --git a/tests/koa-router-composition.test.mjs b/tests/koa-router-composition.test.mjs new file mode 100644 index 00000000..d6b8e2ed --- /dev/null +++ b/tests/koa-router-composition.test.mjs @@ -0,0 +1,176 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { buildRepositoryIndex } from "@synsec/repository/analysis"; +import { buildCallGraph } from "@synsec/repository/call-graph"; +import { composeKoaRouterEntrypoints } from "@synsec/repository/koa-router-composition"; +import { buildModuleGraph } from "@synsec/repository/module-graph"; +import { buildRepositoryRouteFlowAnalysis } from "@synsec/repository/route-flow-analysis"; + +async function makeRepository(files) { + const root = await mkdtemp(join(tmpdir(), "synsec-koa-router-")); + const inputs = []; + for (const [path, content] of Object.entries(files)) { + await mkdir(dirname(join(root, path)), { recursive: true }); + await writeFile(join(root, path), content, "utf8"); + inputs.push({ path, size: Buffer.byteLength(content) }); + } + return { + root, + files: inputs, + cleanup: () => rm(root, { recursive: true, force: true }), + }; +} + +test("Koa router prefixes, middleware, and same-file handlers participate in exact sink correlation", async () => { + const source = [ + 'import Router from "@koa/router";', + 'const router = new Router({ prefix: "/api" });', + "function requireUser(ctx, next) { return next(); }", + "function runJob(ctx) {", + " execute(command);", + "}", + "function execute(command) {", + " child_process.exec(command);", + "}", + 'router.post("/jobs/run", requireUser, runJob);', + ].join("\n"); + const repo = await makeRepository({ "routes.ts": source }); + try { + const index = await buildRepositoryIndex(repo.root, repo.files); + const analysis = await buildRepositoryRouteFlowAnalysis( + repo.root, + repo.files, + index, + buildModuleGraph(index, repo.files), + ); + const entrypoint = analysis.entrypoints.find((item) => item.route.route === "/api/jobs/run"); + assert.equal(entrypoint?.route.method, "POST"); + assert.equal(entrypoint?.handler?.name, "runJob"); + assert.equal(entrypoint?.resolution, "named-function"); + + const middleware = analysis.koaMiddlewareContexts.find((item) => item.route.route === "/api/jobs/run"); + assert.deepEqual(middleware?.middleware, [{ name: "requireUser", line: 10 }]); + assert.equal(middleware?.interpretation, "structural-koa-route-middleware-attachment-not-runtime-protection"); + + const flow = analysis.routeFlows.find((item) => item.route.route === "/api/jobs/run"); + assert.deepEqual(flow?.evidence + .filter((item) => item.kind === "process") + .map((item) => ({ path: item.path, line: item.line, kind: item.kind, depth: item.depth })), [ + { path: "routes.ts", line: 8, kind: "process", depth: 1 }, + ]); + assert.equal(flow?.interpretation, "structural-route-call-sink-evidence-only"); + } finally { + await repo.cleanup(); + } +}); + +test("Koa routes reuse the repository-local named import resolver for cross-module handlers", async () => { + const routes = [ + 'import Router from "@koa/router";', + 'import { runJob } from "./handlers.js";', + 'const router = new Router({ prefix: "/api" });', + 'router.post("/jobs/run", runJob);', + ].join("\n"); + const handlers = [ + "export function runJob(ctx) {", + " child_process.exec(command);", + "}", + ].join("\n"); + const repo = await makeRepository({ "routes.ts": routes, "handlers.ts": handlers }); + try { + const index = await buildRepositoryIndex(repo.root, repo.files); + const analysis = await buildRepositoryRouteFlowAnalysis( + repo.root, + repo.files, + index, + buildModuleGraph(index, repo.files), + ); + const entrypoint = analysis.entrypoints.find((item) => item.route.route === "/api/jobs/run"); + assert.equal(entrypoint?.resolution, "imported-named-function"); + assert.equal(entrypoint?.handler?.path, "handlers.ts"); + assert.equal(entrypoint?.handler?.name, "runJob"); + const flow = analysis.routeFlows.find((item) => item.route.route === "/api/jobs/run"); + assert.deepEqual(flow?.evidence.map((item) => ({ path: item.path, line: item.line, kind: item.kind })), [ + { path: "handlers.ts", line: 2, kind: "process" }, + ]); + } finally { + await repo.cleanup(); + } +}); + +test("Koa composition fails closed on dynamic prefixes", async () => { + const source = [ + 'import Router from "@koa/router";', + "const router = new Router({ prefix: API_PREFIX });", + "function status(ctx) { return true; }", + 'router.get("/status", status);', + ].join("\n"); + const repo = await makeRepository({ "routes.ts": source }); + try { + const graph = await buildCallGraph(repo.root, repo.files); + const result = await composeKoaRouterEntrypoints(repo.root, repo.files, graph, []); + assert.deepEqual(result.entrypoints, []); + assert.deepEqual(result.middlewareContexts, []); + } finally { + await repo.cleanup(); + } +}); + +test("Koa composition fails closed on inline or transformed callbacks", async () => { + const source = [ + 'import Router from "@koa/router";', + "const router = new Router();", + "function status(ctx) { return true; }", + 'router.get("/inline", async (ctx) => status(ctx));', + 'router.get("/wrapped", wrap(status));', + ].join("\n"); + const repo = await makeRepository({ "routes.ts": source }); + try { + const graph = await buildCallGraph(repo.root, repo.files); + const result = await composeKoaRouterEntrypoints(repo.root, repo.files, graph, []); + assert.deepEqual(result.entrypoints, []); + assert.deepEqual(result.middlewareContexts, []); + } finally { + await repo.cleanup(); + } +}); + +test("Koa composition rejects reassigned router bindings", async () => { + const source = [ + 'import Router from "@koa/router";', + "const router = new Router();", + "router = replacement;", + "function status(ctx) { return true; }", + 'router.get("/status", status);', + ].join("\n"); + const repo = await makeRepository({ "routes.ts": source }); + try { + const graph = await buildCallGraph(repo.root, repo.files); + const result = await composeKoaRouterEntrypoints(repo.root, repo.files, graph, []); + assert.deepEqual(result.entrypoints, []); + } finally { + await repo.cleanup(); + } +}); + +test("Koa composition validates output bounds", async () => { + const source = [ + 'import Router from "@koa/router";', + "const router = new Router();", + "function status(ctx) { return true; }", + 'router.get("/status", status);', + ].join("\n"); + const repo = await makeRepository({ "routes.ts": source }); + try { + const graph = await buildCallGraph(repo.root, repo.files); + await assert.rejects( + composeKoaRouterEntrypoints(repo.root, repo.files, graph, [], { maxRoutes: 0 }), + /maxRoutes must be an integer between 1 and 10000/, + ); + } finally { + await repo.cleanup(); + } +}); diff --git a/tests/lifecycle-review-comments.test.mjs b/tests/lifecycle-review-comments.test.mjs new file mode 100644 index 00000000..00eb1ac6 --- /dev/null +++ b/tests/lifecycle-review-comments.test.mjs @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + addFindingReviewComment, + commentsForFinding, + emptyFindingReviewCommentStore, + isFindingReviewCommentStore, + readFindingReviewCommentStore, + writeFindingReviewCommentStore, +} from "@synsec/lifecycle/review-comments"; + +const fingerprint = "sha256:finding-1"; +const timestamp = "2026-08-22T19:05:00.000Z"; + +test("review comments are append-only deterministic triage metadata", () => { + const empty = emptyFindingReviewCommentStore(); + const first = addFindingReviewComment(empty, fingerprint, "Reviewed with the service owner.", { + author: "security-team", + createdAt: timestamp, + }); + assert.equal(empty.comments[fingerprint], undefined); + assert.equal(first.comments[fingerprint].length, 1); + assert.equal(first.comments[fingerprint][0].body, "Reviewed with the service owner."); + assert.equal(first.comments[fingerprint][0].author, "security-team"); + + const duplicate = addFindingReviewComment(first, fingerprint, "Reviewed with the service owner.", { + author: "security-team", + createdAt: timestamp, + }); + assert.equal(duplicate, first); + + const second = addFindingReviewComment(first, fingerprint, "Follow-up review complete.", { + createdAt: "2026-08-22T19:06:00.000Z", + }); + assert.deepEqual(commentsForFinding(second, fingerprint).map((comment) => comment.body), [ + "Reviewed with the service owner.", + "Follow-up review complete.", + ]); +}); + +test("review comment validation rejects malformed or oversized metadata", () => { + const empty = emptyFindingReviewCommentStore(); + assert.throws(() => addFindingReviewComment(empty, "", "comment"), /fingerprint/); + assert.throws(() => addFindingReviewComment(empty, fingerprint, ""), /review comment/); + assert.throws(() => addFindingReviewComment(empty, fingerprint, "x".repeat(10_001)), /review comment/); + assert.throws(() => addFindingReviewComment(empty, fingerprint, "comment\0secret"), /review comment/); + assert.throws(() => addFindingReviewComment(empty, fingerprint, "comment", { author: "x".repeat(256) }), /author/); + assert.throws(() => addFindingReviewComment(empty, fingerprint, "comment", { createdAt: "not-a-time" }), /timestamp/); + + assert.equal(isFindingReviewCommentStore({ + schemaVersion: 1, + comments: { + [fingerprint]: [{ + id: "id", + fingerprint, + body: "comment", + createdAt: timestamp, + scannerEvidence: "must not be accepted", + }], + }, + }), false); +}); + +test("review comment persistence is restrictive, atomic-shaped, and corrupt stores fail closed", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-review-comments-")); + const path = join(root, "state", "comments.json"); + try { + const store = addFindingReviewComment(emptyFindingReviewCommentStore(), fingerprint, "Triage note only.", { + author: "maintainer", + createdAt: timestamp, + }); + await writeFindingReviewCommentStore(path, store); + assert.deepEqual(await readFindingReviewCommentStore(path), store); + assert.equal(JSON.parse(await readFile(path, "utf8")).schemaVersion, 1); + if (process.platform !== "win32") assert.equal((await stat(path)).mode & 0o777, 0o600); + + await writeFile(path, JSON.stringify({ + schemaVersion: 1, + comments: { + [fingerprint]: [{ id: "bad", fingerprint: "different", body: "comment", createdAt: timestamp }], + }, + }), "utf8"); + await assert.rejects(() => readFindingReviewCommentStore(path), /Not a supported SynSec finding review comment store/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/lifecycle-review-deadline.test.mjs b/tests/lifecycle-review-deadline.test.mjs new file mode 100644 index 00000000..c8fb93a5 --- /dev/null +++ b/tests/lifecycle-review-deadline.test.mjs @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + emptyLifecycleStore, + isLifecycleStore, + reconcileLifecycle, + setFindingReviewAt, + setFindingState, +} from "@synsec/lifecycle"; +import { buildFindingTriageView } from "@synsec/lifecycle/triage-view"; +import { renderFindingTriageHtml } from "@synsec/lifecycle/triage-html"; +import { emptyFindingReviewCommentStore } from "@synsec/lifecycle/review-comments"; +import { buildReport } from "@synsec/report"; + +function report() { + return buildReport({ + target: { path: "/repo" }, + scans: [{ + scanner: "fixture", + startedAt: "2026-08-22T20:00:00.000Z", + completedAt: "2026-08-22T20:00:01.000Z", + target: { path: "/repo" }, + diagnostics: [], + findings: [{ + id: "REVIEW-1", + title: "Accepted risk needs periodic review", + category: "sast", + severity: "medium", + confidence: 1, + scanner: { name: "fixture", ruleId: "REVIEW-1" }, + location: { path: "src/app.ts", startLine: 7 }, + }], + }], + scope: { mode: "repository" }, + }); +} + +test("review deadlines are bounded human triage metadata preserved across reconciliation", () => { + const current = report(); + const fingerprint = current.findings[0].fingerprint; + let store = reconcileLifecycle(current, emptyLifecycleStore(), "2026-08-22T20:01:00.000Z"); + store = setFindingState(store, fingerprint, "accepted-risk", { + note: "temporary vendor constraint", + reviewAt: "2026-11-01T12:00:00.000Z", + updatedAt: "2026-08-22T20:02:00.000Z", + }); + assert.equal(store.records[fingerprint].reviewAt, "2026-11-01T12:00:00.000Z"); + + const reconciled = reconcileLifecycle(current, store, "2026-08-23T20:00:00.000Z"); + assert.equal(reconciled.records[fingerprint].state, "accepted-risk"); + assert.equal(reconciled.records[fingerprint].reviewAt, "2026-11-01T12:00:00.000Z"); + assert.equal(reconciled.records[fingerprint].note, "temporary vendor constraint"); + + const view = buildFindingTriageView(current, reconciled, emptyFindingReviewCommentStore()); + assert.equal(view.items[0].reviewAt, "2026-11-01T12:00:00.000Z"); + assert.equal(view.interpretation, "triage-metadata-not-scanner-evidence"); + assert.match(renderFindingTriageHtml(view), /Review by/); + assert.match(renderFindingTriageHtml(view), /2026-11-01T12:00:00.000Z/); +}); + +test("review deadlines can be updated or cleared without changing finding state", () => { + const current = report(); + const fingerprint = current.findings[0].fingerprint; + let store = reconcileLifecycle(current, emptyLifecycleStore(), "2026-08-22T20:01:00.000Z"); + store = setFindingState(store, fingerprint, "accepted-risk", { updatedAt: "2026-08-22T20:02:00.000Z" }); + store = setFindingReviewAt(store, fingerprint, "2026-12-01T00:00:00.000Z", "2026-08-22T20:03:00.000Z"); + assert.equal(store.records[fingerprint].state, "accepted-risk"); + assert.equal(store.records[fingerprint].reviewAt, "2026-12-01T00:00:00.000Z"); + + store = setFindingReviewAt(store, fingerprint, null, "2026-08-22T20:04:00.000Z"); + assert.equal(store.records[fingerprint].state, "accepted-risk"); + assert.equal(store.records[fingerprint].reviewAt, undefined); +}); + +test("review deadline validation fails closed on malformed timestamps", () => { + const current = report(); + const fingerprint = current.findings[0].fingerprint; + const store = reconcileLifecycle(current, emptyLifecycleStore()); + assert.throws(() => setFindingReviewAt(store, fingerprint, "not-a-date"), /valid timestamp/); + assert.throws(() => setFindingState(store, fingerprint, "accepted-risk", { reviewAt: "not-a-date" }), /valid timestamp/); + assert.equal(isLifecycleStore({ + schemaVersion: 1, + records: { + [fingerprint]: { + fingerprint, + state: "accepted-risk", + updatedAt: "2026-08-22T20:00:00.000Z", + reviewAt: "not-a-date", + }, + }, + }), false); +}); diff --git a/tests/lifecycle-review-deadlines.test.mjs b/tests/lifecycle-review-deadlines.test.mjs new file mode 100644 index 00000000..a49b7807 --- /dev/null +++ b/tests/lifecycle-review-deadlines.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { assessLifecycleReviewDeadlines } from "@synsec/lifecycle/review-deadlines"; + +function store(records) { + return { schemaVersion: 1, records }; +} + +function record(fingerprint, state, reviewAt) { + return { + fingerprint, + state, + updatedAt: "2026-08-01T00:00:00.000Z", + ...(reviewAt ? { reviewAt } : {}), + }; +} + +test("review deadline assessment separates overdue, soon-due, scheduled, and unscheduled exceptions", () => { + const result = assessLifecycleReviewDeadlines(store({ + overdue: record("overdue", "accepted-risk", "2026-08-22T00:00:00.000Z"), + soon: record("soon", "false-positive", "2026-08-25T00:00:00.000Z"), + later: record("later", "accepted-risk", "2026-09-30T00:00:00.000Z"), + unscheduled: record("unscheduled", "false-positive"), + confirmed: record("confirmed", "confirmed", "2026-08-22T00:00:00.000Z"), + }), { + now: "2026-08-23T00:00:00.000Z", + dueSoonWindowMs: 7 * 24 * 60 * 60 * 1000, + }); + + assert.deepEqual(result.summary, { + reviewable: 4, + unscheduled: 1, + overdue: 1, + dueSoon: 1, + scheduled: 1, + }); + assert.deepEqual(result.items.map(({ fingerprint, status }) => ({ fingerprint, status })), [ + { fingerprint: "overdue", status: "overdue" }, + { fingerprint: "soon", status: "due-soon" }, + { fingerprint: "later", status: "scheduled" }, + ]); + assert.equal(result.generatedAt, "2026-08-23T00:00:00.000Z"); +}); + +test("review deadline assessment is deterministic for equal deadlines", () => { + const result = assessLifecycleReviewDeadlines(store({ + z: record("z", "accepted-risk", "2026-08-24T00:00:00.000Z"), + a: record("a", "false-positive", "2026-08-24T00:00:00.000Z"), + }), { now: "2026-08-23T00:00:00.000Z" }); + assert.deepEqual(result.items.map((item) => item.fingerprint), ["a", "z"]); +}); + +test("review deadline assessment excludes triage notes and ownership metadata", () => { + const input = store({ + fp: { + ...record("fp", "accepted-risk", "2026-08-24T00:00:00.000Z"), + note: "secret-bearing human note must not be copied", + owner: "security-team", + reportId: "report-1", + lastSeenPath: "private/internal.ts", + }, + }); + const result = assessLifecycleReviewDeadlines(input, { now: "2026-08-23T00:00:00.000Z" }); + const serialized = JSON.stringify(result); + assert.doesNotMatch(serialized, /secret-bearing|security-team|report-1|private\/internal/); +}); + +test("review deadline assessment rejects invalid clocks and windows", () => { + const input = store({}); + assert.throws(() => assessLifecycleReviewDeadlines(input, { now: "not-a-time" }), /valid timestamp/); + for (const dueSoonWindowMs of [-1, 1.5, 366 * 24 * 60 * 60 * 1000]) { + assert.throws(() => assessLifecycleReviewDeadlines(input, { dueSoonWindowMs }), /due-soon window/); + } +}); diff --git a/tests/lifecycle-review-due.test.mjs b/tests/lifecycle-review-due.test.mjs new file mode 100644 index 00000000..8f90cecb --- /dev/null +++ b/tests/lifecycle-review-due.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { emptyLifecycleStore, reconcileLifecycle, setFindingReviewAt, setFindingState } from "@synsec/lifecycle"; +import { emptyFindingReviewCommentStore } from "@synsec/lifecycle/review-comments"; +import { renderFindingTriageHtml } from "@synsec/lifecycle/triage-html"; +import { buildFindingTriageView } from "@synsec/lifecycle/triage-view"; +import { buildReport } from "@synsec/report"; + +function report() { + return buildReport({ + target: { path: "/repo" }, + scans: [{ + scanner: "fixture", + startedAt: "2026-08-22T20:00:00.000Z", + completedAt: "2026-08-22T20:00:01.000Z", + target: { path: "/repo" }, + diagnostics: [], + findings: [{ + id: "REVIEW-DUE-1", + title: "Accepted risk with review governance", + category: "sast", + severity: "medium", + confidence: 1, + scanner: { name: "fixture", ruleId: "REVIEW-DUE-1" }, + location: { path: "src/app.ts", startLine: 12 }, + }], + }], + scope: { mode: "repository" }, + }); +} + +test("triage derives due versus scheduled review status without changing lifecycle state", () => { + const current = report(); + const fingerprint = current.findings[0].fingerprint; + let store = reconcileLifecycle(current, emptyLifecycleStore(), "2026-08-22T20:01:00.000Z"); + store = setFindingState(store, fingerprint, "accepted-risk", { updatedAt: "2026-08-22T20:02:00.000Z" }); + store = setFindingReviewAt(store, fingerprint, "2026-09-01T00:00:00.000Z", "2026-08-22T20:03:00.000Z"); + + const scheduled = buildFindingTriageView(current, store, emptyFindingReviewCommentStore(), { + now: Date.parse("2026-08-31T23:59:59.000Z"), + }); + assert.equal(scheduled.items[0].state, "accepted-risk"); + assert.equal(scheduled.items[0].reviewStatus, "scheduled"); + assert.match(renderFindingTriageHtml(scheduled), /Review by/); + + const due = buildFindingTriageView(current, store, emptyFindingReviewCommentStore(), { + now: Date.parse("2026-09-01T00:00:00.000Z"), + }); + assert.equal(due.items[0].state, "accepted-risk"); + assert.equal(due.items[0].reviewStatus, "due"); + assert.match(renderFindingTriageHtml(due), /Review overdue/); + assert.equal(due.interpretation, "triage-metadata-not-scanner-evidence"); +}); diff --git a/tests/lifecycle-review-policy.test.mjs b/tests/lifecycle-review-policy.test.mjs new file mode 100644 index 00000000..511f5868 --- /dev/null +++ b/tests/lifecycle-review-policy.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { evaluateLifecycleReviewPolicy } from "@synsec/lifecycle/review-policy"; + +function assessment(summary, generatedAt = "2026-08-23T00:00:00.000Z") { + return { + schemaVersion: 1, + generatedAt, + dueSoonWindowMs: 7 * 24 * 60 * 60 * 1000, + items: [ + { + fingerprint: "sensitive-fingerprint", + state: "accepted-risk", + reviewAt: "2026-08-22T00:00:00.000Z", + status: "overdue", + }, + ], + summary, + }; +} + +test("review policy reports deterministic aggregate violations without finding identifiers", () => { + const result = evaluateLifecycleReviewPolicy(assessment({ + reviewable: 4, + overdue: 1, + dueSoon: 1, + scheduled: 1, + unscheduled: 1, + }), { + failOnOverdue: true, + failOnUnscheduled: true, + }); + + assert.deepEqual(result, { + schemaVersion: 1, + generatedAt: "2026-08-23T00:00:00.000Z", + ready: false, + violations: ["overdue", "unscheduled"], + summary: { + reviewable: 4, + overdue: 1, + dueSoon: 1, + scheduled: 1, + unscheduled: 1, + }, + }); + assert.doesNotMatch(JSON.stringify(result), /sensitive-fingerprint|2026-08-22/); +}); + +test("review policy passes when configured policy has no violations", () => { + const result = evaluateLifecycleReviewPolicy(assessment({ + reviewable: 2, + overdue: 0, + dueSoon: 1, + scheduled: 1, + unscheduled: 0, + }), { + failOnOverdue: true, + failOnUnscheduled: true, + }); + assert.equal(result.ready, true); + assert.deepEqual(result.violations, []); +}); + +test("review policy validates assessment summary consistency", () => { + assert.throws(() => evaluateLifecycleReviewPolicy(assessment({ + reviewable: 2, + overdue: 1, + dueSoon: 0, + scheduled: 0, + unscheduled: 0, + })), /internally inconsistent/); + + assert.throws(() => evaluateLifecycleReviewPolicy(assessment({ + reviewable: 1, + overdue: -1, + dueSoon: 0, + scheduled: 1, + unscheduled: 1, + })), /invalid overdue count/); +}); diff --git a/tests/lifecycle-triage-html.test.mjs b/tests/lifecycle-triage-html.test.mjs new file mode 100644 index 00000000..2236d666 --- /dev/null +++ b/tests/lifecycle-triage-html.test.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { renderFindingTriageHtml, writeFindingTriageHtml } from "@synsec/lifecycle/triage-html"; + +function view() { + return { + schemaVersion: 1, + reportId: "report-", + items: [{ + fingerprint: "fp<&>", + title: "Unsafe ", + severity: "high", + state: "confirmed", + updatedAt: "2026-08-22T19:10:00.000Z", + owner: "appsec ", + note: "Needs & follow-up", + comments: [{ + id: "comment-1", + fingerprint: "fp<&>", + body: "Do not render ", + author: "reviewer & owner", + createdAt: "2026-08-22T19:11:00.000Z", + }], + }], + summary: { current: 1, assigned: 1, unassigned: 0, commented: 1 }, + interpretation: "triage-metadata-not-scanner-evidence", + }; +} + +test("triage HTML escapes every scanner/human-controlled display field", () => { + const html = renderFindingTriageHtml(view()); + assert.match(html, /Unsafe <script>alert\(1\)<\/script>/); + assert.match(html, /report-<unsafe>/); + assert.match(html, /appsec <team>/); + assert.match(html, /Needs <review> & follow-up/); + assert.match(html, /<img src=x onerror=alert\(1\)>/); + assert.match(html, /reviewer & owner/); + assert.equal(html.includes(""), false); + assert.equal(html.includes(""), false); + assert.match(html, //); +}); + +test("triage HTML writer uses restrictive permissions where supported", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-triage-html-")); + const path = join(root, "triage", "index.html"); + try { + await writeFindingTriageHtml(path, view()); + const html = await readFile(path, "utf8"); + assert.match(html, /SynSec finding triage/); + if (process.platform !== "win32") assert.equal((await stat(path)).mode & 0o777, 0o600); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("empty triage view renders an explicit no-current-findings state", () => { + const html = renderFindingTriageHtml({ + schemaVersion: 1, + reportId: "empty", + items: [], + summary: { current: 0, assigned: 0, unassigned: 0, commented: 0 }, + interpretation: "triage-metadata-not-scanner-evidence", + }); + assert.match(html, /No current lifecycle findings/); +}); diff --git a/tests/lifecycle-triage-view.test.mjs b/tests/lifecycle-triage-view.test.mjs new file mode 100644 index 00000000..cf4f4975 --- /dev/null +++ b/tests/lifecycle-triage-view.test.mjs @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildReport } from "@synsec/report"; +import { + emptyLifecycleStore, + reconcileLifecycle, + setFindingOwner, + setFindingState, +} from "@synsec/lifecycle"; +import { + addFindingReviewComment, + emptyFindingReviewCommentStore, +} from "@synsec/lifecycle/review-comments"; +import { buildFindingTriageView } from "@synsec/lifecycle/triage-view"; + +function report() { + return buildReport({ + target: { path: "/repo" }, + scans: [{ + scanner: "fixture", + startedAt: "2026-08-22T19:00:00.000Z", + completedAt: "2026-08-22T19:00:01.000Z", + target: { path: "/repo" }, + diagnostics: ["scanner detail that must not enter triage view"], + findings: [{ + id: "A", + title: "Finding A", + description: "scanner evidence", + category: "sast", + severity: "high", + confidence: 1, + scanner: { name: "fixture", ruleId: "A" }, + location: { path: "src/a.ts", startLine: 10 }, + metadata: { sourceExcerpt: "sensitive source" }, + }, { + id: "B", + title: "Finding B", + category: "dependency", + severity: "medium", + confidence: 1, + scanner: { name: "fixture", ruleId: "B" }, + }], + }], + scope: { mode: "repository" }, + }); +} + +test("triage view composes current state ownership and comments without scanner/source evidence", () => { + const current = report(); + const [a, b] = current.findings.map((finding) => finding.fingerprint); + let lifecycle = reconcileLifecycle(current, emptyLifecycleStore(), "2026-08-22T19:01:00.000Z"); + lifecycle = setFindingState(lifecycle, a, "confirmed", { + note: "Needs remediation", + updatedAt: "2026-08-22T19:02:00.000Z", + }); + lifecycle = setFindingOwner(lifecycle, a, "appsec", "2026-08-22T19:03:00.000Z"); + + let comments = emptyFindingReviewCommentStore(); + comments = addFindingReviewComment(comments, a, "Reviewed with maintainers.", { + author: "appsec", + createdAt: "2026-08-22T19:04:00.000Z", + }); + comments = addFindingReviewComment(comments, "not-current", "Historical only.", { + createdAt: "2026-08-22T19:05:00.000Z", + }); + + const view = buildFindingTriageView(current, lifecycle, comments); + assert.equal(view.interpretation, "triage-metadata-not-scanner-evidence"); + assert.deepEqual(view.summary, { current: 2, assigned: 1, unassigned: 1, commented: 1 }); + assert.equal(view.items.length, 2); + + const itemA = view.items.find((item) => item.fingerprint === a); + const itemB = view.items.find((item) => item.fingerprint === b); + assert.equal(itemA.state, "confirmed"); + assert.equal(itemA.owner, "appsec"); + assert.equal(itemA.note, "Needs remediation"); + assert.equal(itemA.comments[0].body, "Reviewed with maintainers."); + assert.equal(itemB.state, "new"); + assert.equal(itemB.owner, undefined); + + const serialized = JSON.stringify(view); + assert.equal(serialized.includes("src/a.ts"), false); + assert.equal(serialized.includes("sensitive source"), false); + assert.equal(serialized.includes("scanner detail"), false); + assert.equal(serialized.includes("Historical only"), false); +}); + +test("triage view omits findings without lifecycle state rather than manufacturing review state", () => { + const current = report(); + const view = buildFindingTriageView(current, emptyLifecycleStore(), emptyFindingReviewCommentStore()); + assert.deepEqual(view.items, []); + assert.deepEqual(view.summary, { current: 0, assigned: 0, unassigned: 0, commented: 0 }); +}); diff --git a/tests/lifecycle.test.mjs b/tests/lifecycle.test.mjs new file mode 100644 index 00000000..ddb6a8f6 --- /dev/null +++ b/tests/lifecycle.test.mjs @@ -0,0 +1,218 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildReport } from "../packages/report/dist/index.js"; +import { + emptyLifecycleStore, + isLifecycleStore, + lifecycleSummary, + readLifecycleStore, + reconcileLifecycle, + setFindingOwner, + setFindingState, + verifyRemediation, + writeLifecycleStore, +} from "../packages/lifecycle/dist/index.js"; + +function reportWith(ruleIds, options = {}) { + return buildReport({ + target: { path: "/repo" }, + scans: [{ + scanner: options.scanner ?? "fixture", + startedAt: "2026-01-01T00:00:00.000Z", + completedAt: "2026-01-01T00:00:01.000Z", + target: { path: "/repo" }, + diagnostics: [], + findings: ruleIds.map((ruleId) => ({ + id: ruleId, + title: `Finding ${ruleId}`, + category: "sast", + severity: "high", + confidence: 1, + scanner: { name: options.scanner ?? "fixture", ruleId }, + location: { path: `src/${ruleId}.ts`, startLine: 1 }, + })), + }], + scope: options.scope ?? { mode: "repository" }, + }); +} + +test("lifecycle creates new findings and preserves explicit triage state", () => { + const report = reportWith(["A"]); + let store = reconcileLifecycle(report, emptyLifecycleStore(), "2026-01-01T00:00:00.000Z"); + const fingerprint = report.findings[0].fingerprint; + assert.equal(store.records[fingerprint].state, "new"); + assert.equal(store.records[fingerprint].lastSeenPath, "src/A.ts"); + + store = setFindingState(store, fingerprint, "confirmed", { + note: "Reviewed by maintainer", + reportId: report.reportId, + updatedAt: "2026-01-02T00:00:00.000Z", + }); + assert.equal(store.records[fingerprint].lastSeenPath, "src/A.ts"); + const next = reconcileLifecycle(report, store, "2026-01-03T00:00:00.000Z"); + assert.equal(next.records[fingerprint].state, "confirmed"); + assert.equal(next.records[fingerprint].note, "Reviewed by maintainer"); +}); + +test("finding ownership is bounded triage metadata and survives state/reconciliation changes", () => { + const report = reportWith(["A"]); + const fingerprint = report.findings[0].fingerprint; + let store = reconcileLifecycle(report, emptyLifecycleStore(), "2026-01-01T00:00:00.000Z"); + store = setFindingOwner(store, fingerprint, "security-team", "2026-01-01T12:00:00.000Z"); + assert.equal(store.records[fingerprint].owner, "security-team"); + assert.equal(store.records[fingerprint].updatedAt, "2026-01-01T12:00:00.000Z"); + + store = setFindingState(store, fingerprint, "confirmed", { updatedAt: "2026-01-02T00:00:00.000Z" }); + assert.equal(store.records[fingerprint].owner, "security-team"); + const next = reconcileLifecycle(report, store, "2026-01-03T00:00:00.000Z"); + assert.equal(next.records[fingerprint].owner, "security-team"); + + const cleared = setFindingOwner(next, fingerprint, "", "2026-01-04T00:00:00.000Z"); + assert.equal(cleared.records[fingerprint].owner, undefined); + assert.equal(cleared.records[fingerprint].state, "confirmed"); +}); + +test("finding ownership rejects unknown records, control characters, oversized values, and invalid timestamps", () => { + const report = reportWith(["A"]); + const fingerprint = report.findings[0].fingerprint; + const store = reconcileLifecycle(report, emptyLifecycleStore()); + assert.throws(() => setFindingOwner(store, "missing", "team"), /does not exist/); + assert.throws(() => setFindingOwner(store, fingerprint, "team\nother"), /control line breaks/); + assert.throws(() => setFindingOwner(store, fingerprint, "x".repeat(256)), /at most 255/); + assert.throws(() => setFindingOwner(store, fingerprint, "team", "not-a-time"), /valid timestamp/); +}); + +test("lifecycle store validation rejects malformed record shapes", () => { + assert.equal(isLifecycleStore({ schemaVersion: 1, records: {} }), true); + assert.equal(isLifecycleStore({ + schemaVersion: 1, + records: { + abc: { fingerprint: "different", state: "new", updatedAt: "2026-01-01T00:00:00.000Z" }, + }, + }), false); + assert.equal(isLifecycleStore({ + schemaVersion: 1, + records: { + abc: { fingerprint: "abc", state: "unknown", updatedAt: "2026-01-01T00:00:00.000Z" }, + }, + }), false); + assert.equal(isLifecycleStore({ + schemaVersion: 1, + records: { + abc: { fingerprint: "abc", state: "new", updatedAt: "not-a-date" }, + }, + }), false); + assert.equal(isLifecycleStore({ + schemaVersion: 1, + records: { + abc: { fingerprint: "abc", state: "new", updatedAt: "2026-01-01T00:00:00.000Z", owner: "bad\nowner" }, + }, + }), false); +}); + +test("lifecycle persistence is restrictive, round-trippable, and rejects corrupt stores", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-lifecycle-")); + const path = join(root, "state", "lifecycle.json"); + try { + const report = reportWith(["A"]); + const fingerprint = report.findings[0].fingerprint; + let store = reconcileLifecycle(report, emptyLifecycleStore(), "2026-01-01T00:00:00.000Z"); + store = setFindingOwner(store, fingerprint, "appsec"); + await writeLifecycleStore(path, store); + assert.deepEqual(await readLifecycleStore(path), store); + const serialized = await readFile(path, "utf8"); + assert.equal(JSON.parse(serialized).schemaVersion, 1); + assert.equal(JSON.parse(serialized).records[fingerprint].owner, "appsec"); + if (process.platform !== "win32") assert.equal((await stat(path)).mode & 0o777, 0o600); + + await writeFile(path, JSON.stringify({ + schemaVersion: 1, + records: { bad: { fingerprint: "mismatch", state: "new", updatedAt: "2026-01-01T00:00:00.000Z" } }, + })); + await assert.rejects(() => readLifecycleStore(path), /Not a supported SynSec lifecycle store/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("lifecycle marks disappeared confirmed findings fixed and returning findings regressed", () => { + const initial = reportWith(["A"]); + const fingerprint = initial.findings[0].fingerprint; + let store = reconcileLifecycle(initial, emptyLifecycleStore(), "2026-01-01T00:00:00.000Z"); + store = setFindingState(store, fingerprint, "confirmed", { updatedAt: "2026-01-02T00:00:00.000Z" }); + + const fixed = reconcileLifecycle(reportWith([]), store, "2026-01-03T00:00:00.000Z"); + assert.equal(fixed.records[fingerprint].state, "fixed"); + + const regressed = reconcileLifecycle(initial, fixed, "2026-01-04T00:00:00.000Z"); + assert.equal(regressed.records[fingerprint].state, "regressed"); + assert.equal(lifecycleSummary(regressed).regressed, 1); +}); + +test("changed-file scans do not mark out-of-scope findings fixed", () => { + const initial = reportWith(["A", "B"]); + const [a, b] = initial.findings.map((finding) => finding.fingerprint); + let store = reconcileLifecycle(initial, emptyLifecycleStore(), "2026-01-01T00:00:00.000Z"); + store = setFindingState(store, a, "confirmed", { updatedAt: "2026-01-02T00:00:00.000Z" }); + store = setFindingState(store, b, "confirmed", { updatedAt: "2026-01-02T00:00:00.000Z" }); + + const incremental = reportWith([], { + scope: { mode: "changed-files", baseRef: "main", changedFiles: ["src/A.ts"] }, + }); + const next = reconcileLifecycle(incremental, store, "2026-01-03T00:00:00.000Z"); + assert.equal(next.records[a].state, "fixed"); + assert.equal(next.records[b].state, "confirmed"); + assert.equal(next.records[b].reportId, store.records[b].reportId); +}); + +test("false-positive and accepted-risk decisions are not rewritten just because a later scan omits the finding", () => { + const report = reportWith(["A", "B"]); + const [a, b] = report.findings.map((finding) => finding.fingerprint); + let store = reconcileLifecycle(report, emptyLifecycleStore()); + store = setFindingState(store, a, "false-positive"); + store = setFindingState(store, b, "accepted-risk"); + + const next = reconcileLifecycle(reportWith([]), store); + assert.equal(next.records[a].state, "false-positive"); + assert.equal(next.records[b].state, "accepted-risk"); +}); + +test("remediation verification only calls a missing finding fixed when detecting coverage was repeated", () => { + const before = reportWith(["A"]); + const fingerprint = before.findings[0].fingerprint; + const after = reportWith([]); + const verification = verifyRemediation(before, after, [fingerprint], "2026-01-02T00:00:00.000Z"); + assert.equal(verification.items[0].status, "fixed"); + assert.equal(verification.summary.fixed, 1); +}); + +test("remediation verification is inconclusive when the detecting scanner did not rerun", () => { + const before = reportWith(["A"], { scanner: "fixture" }); + const after = reportWith([], { scanner: "different-scanner" }); + const verification = verifyRemediation(before, after); + assert.equal(verification.items[0].status, "inconclusive"); + assert.match(verification.items[0].reasons.join(" "), /None of the scanner/); +}); + +test("changed-file verification is inconclusive when the affected path was outside the rescan scope", () => { + const before = reportWith(["A"]); + const after = reportWith([], { + scope: { mode: "changed-files", baseRef: "main", changedFiles: ["src/B.ts"] }, + }); + const verification = verifyRemediation(before, after); + assert.equal(verification.items[0].status, "inconclusive"); + assert.match(verification.items[0].reasons.join(" "), /did not scan the finding path/); +}); + +test("remediation verification reports persisting and newly introduced findings", () => { + const before = reportWith(["A"]); + const after = reportWith(["A", "B"]); + const verification = verifyRemediation(before, after); + assert.equal(verification.items[0].status, "persisting"); + assert.equal(verification.summary.persisting, 1); + assert.equal(verification.summary.newFindings, 1); + assert.equal(verification.newFindings.length, 1); +}); diff --git a/tests/model-routing.test.mjs b/tests/model-routing.test.mjs new file mode 100644 index 00000000..581db9a4 --- /dev/null +++ b/tests/model-routing.test.mjs @@ -0,0 +1,159 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { routeModel, routeModelSet } from "../packages/workflows/dist/routing.js"; + +const candidates = [ + { + id: "local-small", + tasks: ["fast-classifier", "report-writer"], + costTier: 0, + latencyTier: 1, + privacy: "local", + supportsSourceContext: true, + }, + { + id: "remote-security", + tasks: ["security-reasoner", "code-reasoner", "verifier"], + costTier: 2, + latencyTier: 2, + privacy: "remote", + supportsSourceContext: true, + }, + { + id: "private-security", + tasks: ["security-reasoner", "verifier"], + costTier: 1, + latencyTier: 3, + privacy: "private-remote", + supportsSourceContext: false, + }, +]; + +test("routing chooses the lowest-cost eligible model by task", () => { + const decision = routeModel(candidates, { + task: "security-reasoner", + sourceContextRequested: false, + }); + assert.equal(decision.candidate.id, "private-security"); + assert.ok(decision.reason.some((reason) => /cost tier 1/.test(reason))); +}); + +test("source-context routing excludes models that cannot receive source", () => { + const decision = routeModel(candidates, { + task: "security-reasoner", + sourceContextRequested: true, + }); + assert.equal(decision.candidate.id, "remote-security"); + assert.ok(decision.reason.includes("permits source context")); +}); + +test("routing enforces cost and local-only constraints rather than silently widening policy", () => { + assert.throws( + () => routeModel(candidates, { + task: "security-reasoner", + sourceContextRequested: false, + maxCostTier: 0, + }), + /No model candidate satisfies routing constraints/, + ); + assert.throws( + () => routeModel(candidates, { + task: "verifier", + sourceContextRequested: false, + requireLocal: true, + }), + /privacy=local-only/, + ); +}); + +test("local preference is deterministic when multiple candidates remain eligible", () => { + const expanded = [ + ...candidates, + { + id: "remote-cheap-writer", + tasks: ["report-writer"], + costTier: 0, + latencyTier: 0, + privacy: "remote", + supportsSourceContext: false, + }, + ]; + const defaultDecision = routeModel(expanded, { + task: "report-writer", + sourceContextRequested: false, + }); + assert.equal(defaultDecision.candidate.id, "remote-cheap-writer"); + + const privateDecision = routeModel(expanded, { + task: "report-writer", + sourceContextRequested: false, + preferLocal: true, + }); + assert.equal(privateDecision.candidate.id, "local-small"); +}); + +test("routeModelSet selects distinct reviewers with the same eligibility policy", () => { + const expanded = [ + ...candidates, + { + id: "local-security", + tasks: ["security-reasoner", "verifier"], + costTier: 1, + latencyTier: 1, + privacy: "local", + supportsSourceContext: true, + }, + { + id: "remote-security-2", + tasks: ["security-reasoner"], + costTier: 2, + latencyTier: 1, + privacy: "remote", + supportsSourceContext: true, + }, + { + id: "local-security", + tasks: ["security-reasoner"], + costTier: 0, + latencyTier: 0, + privacy: "local", + supportsSourceContext: true, + }, + ]; + const decision = routeModelSet(expanded, { + task: "security-reasoner", + sourceContextRequested: false, + preferLocal: true, + }, 3); + assert.deepEqual(decision.candidates.map((candidate) => candidate.id), [ + "local-security", + "private-security", + "remote-security-2", + ]); + assert.equal(new Set(decision.candidates.map((candidate) => candidate.id)).size, 3); +}); + +test("routeModelSet fails closed when source/privacy constraints leave too few reviewers", () => { + assert.throws( + () => routeModelSet(candidates, { + task: "security-reasoner", + sourceContextRequested: true, + }, 2), + /Only 1 distinct model candidate\(s\).*2 required/, + ); + assert.throws( + () => routeModelSet(candidates, { + task: "verifier", + sourceContextRequested: false, + requireLocal: true, + }, 2), + /Only 0 distinct model candidate\(s\).*privacy=local-only/, + ); +}); + +test("routeModelSet validates the consensus reviewer count", () => { + assert.throws( + () => routeModelSet(candidates, { task: "security-reasoner", sourceContextRequested: false }, 1), + /between 2 and 10/, + ); +}); diff --git a/tests/module-graph.test.mjs b/tests/module-graph.test.mjs new file mode 100644 index 00000000..80c48911 --- /dev/null +++ b/tests/module-graph.test.mjs @@ -0,0 +1,127 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildRepositoryIndex } from "../packages/repository/dist/analysis.js"; +import { buildModuleGraph, findModuleNeighborhood } from "../packages/repository/dist/module-graph.js"; + +test("module graph resolves local JavaScript and TypeScript imports without confusing packages for repository files", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-module-graph-")); + try { + await mkdir(join(root, "src", "db"), { recursive: true }); + const app = `import express from "express";\nimport { requireAuth } from "./auth.js";\nimport { loadUser } from "./db/index.js";\nexport async function handler() { return loadUser(); }\n`; + const auth = `export function requireAuth() { return true; }\n`; + const db = `import { requireAuth } from "../auth.js";\nexport function loadUser() { requireAuth(); return {}; }\n`; + await writeFile(join(root, "src", "app.ts"), app); + await writeFile(join(root, "src", "auth.ts"), auth); + await writeFile(join(root, "src", "db", "index.ts"), db); + + const files = [ + { path: "src/app.ts", size: Buffer.byteLength(app) }, + { path: "src/auth.ts", size: Buffer.byteLength(auth) }, + { path: "src/db/index.ts", size: Buffer.byteLength(db) }, + ]; + const index = await buildRepositoryIndex(root, files); + const graph = buildModuleGraph(index, files); + + assert.equal(graph.schemaVersion, 1); + assert.deepEqual(graph.nodes, ["src/app.ts", "src/auth.ts", "src/db/index.ts"]); + assert.equal(graph.resolvedEdgeCount, 3); + assert.equal(graph.unresolvedEdgeCount, 1); + assert.ok(graph.edges.some((edge) => edge.specifier === "./auth.js" && edge.target === "src/auth.ts" && edge.resolutionEvidence === "relative-import")); + assert.ok(graph.edges.some((edge) => edge.specifier === "./db/index.js" && edge.target === "src/db/index.ts")); + assert.ok(graph.edges.some((edge) => edge.specifier === "express" && edge.resolution === "external-or-unresolved" && edge.target === undefined && edge.resolutionEvidence === undefined)); + + const neighborhood = findModuleNeighborhood(graph, "./src/app.ts", 3); + assert.equal(neighborhood.interpretation, "module-import-reachability-only"); + assert.deepEqual(neighborhood.dependencies, [ + { path: "src/auth.ts", depth: 1 }, + { path: "src/db/index.ts", depth: 1 }, + ]); + + const authNeighborhood = findModuleNeighborhood(graph, "src/auth.ts", 3); + assert.deepEqual(authNeighborhood.dependents, [ + { path: "src/app.ts", depth: 1 }, + { path: "src/db/index.ts", depth: 1 }, + ]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("module graph resolves explicit relative Python package imports and bounds traversal", () => { + const files = [ + { path: "service/__init__.py", size: 1 }, + { path: "service/api.py", size: 1 }, + { path: "service/auth.py", size: 1 }, + ]; + const index = { + schemaVersion: 1, + generatedAt: new Date(0).toISOString(), + indexedFileCount: files.length, + moduleEdges: [ + { from: "service/api.py", specifier: ".auth", kind: "python-import", line: 1 }, + { from: "service/auth.py", specifier: ".", kind: "python-import", line: 1 }, + ], + routes: [], + authSignals: [], + sinks: [], + }; + + const graph = buildModuleGraph(index, files); + assert.ok(graph.edges.some((edge) => edge.specifier === ".auth" && edge.target === "service/auth.py" && edge.resolutionEvidence === "relative-import")); + assert.ok(graph.edges.some((edge) => edge.specifier === "." && edge.target === "service/__init__.py")); + assert.deepEqual(findModuleNeighborhood(graph, "service/api.py", 0).dependencies, []); +}); + +test("module graph resolves absolute imports only through explicit top-level Python packages", () => { + const files = [ + { path: "service/__init__.py", size: 1 }, + { path: "service/api.py", size: 1 }, + { path: "service/db.py", size: 1 }, + { path: "requests.py", size: 1 }, + ]; + const index = { + schemaVersion: 1, + generatedAt: new Date(0).toISOString(), + indexedFileCount: files.length, + moduleEdges: [ + { from: "service/api.py", specifier: "service.db", kind: "python-import", line: 1 }, + { from: "service/api.py", specifier: "requests", kind: "python-import", line: 2 }, + ], + routes: [], + authSignals: [], + sinks: [], + }; + + const graph = buildModuleGraph(index, files); + assert.ok(graph.edges.some((edge) => edge.specifier === "service.db" && edge.target === "service/db.py" && edge.resolutionEvidence === "repository-root-python-package")); + assert.ok(graph.edges.some((edge) => edge.specifier === "requests" && edge.target === undefined && edge.resolution === "external-or-unresolved")); +}); + +test("module graph leaves ambiguous Python module/package shapes unresolved", () => { + const files = [ + { path: "service/__init__.py", size: 1 }, + { path: "service/api.py", size: 1 }, + { path: "service/auth.py", size: 1 }, + { path: "service/auth/__init__.py", size: 1 }, + ]; + const index = { + schemaVersion: 1, + generatedAt: new Date(0).toISOString(), + indexedFileCount: files.length, + moduleEdges: [ + { from: "service/api.py", specifier: ".auth", kind: "python-import", line: 1 }, + { from: "service/api.py", specifier: "service.auth", kind: "python-import", line: 2 }, + ], + routes: [], + authSignals: [], + sinks: [], + }; + + const graph = buildModuleGraph(index, files); + assert.equal(graph.resolvedEdgeCount, 0); + assert.equal(graph.unresolvedEdgeCount, 2); + assert.ok(graph.edges.every((edge) => edge.resolution === "external-or-unresolved" && edge.target === undefined)); +}); diff --git a/tests/nestjs-controller-composition.test.mjs b/tests/nestjs-controller-composition.test.mjs new file mode 100644 index 00000000..ac0dfef6 --- /dev/null +++ b/tests/nestjs-controller-composition.test.mjs @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { buildRepositoryIndex } from "@synsec/repository/analysis"; +import { buildModuleGraph } from "@synsec/repository/module-graph"; +import { composeNestJsControllerEntrypoints } from "@synsec/repository/nestjs-controller-composition"; +import { buildCallGraph } from "@synsec/repository/call-graph"; +import { buildRepositoryRouteFlowAnalysis } from "@synsec/repository/route-flow-analysis"; + +async function makeRepository(content) { + const root = await mkdtemp(join(tmpdir(), "synsec-nestjs-controller-")); + const path = "admin.controller.ts"; + await writeFile(join(root, path), content, "utf8"); + return { + root, + files: [{ path, size: Buffer.byteLength(content) }], + cleanup: () => rm(root, { recursive: true, force: true }), + }; +} + +test("NestJS controller routes compose literal prefixes and preserve exact sink evidence", async () => { + const source = [ + 'import { Controller, Post, UseGuards } from "@nestjs/common";', + "@Controller(\"admin\")", + "@UseGuards(SessionGuard)", + "export class AdminController {", + " @Post(\"run\")", + " @UseGuards(AdminGuard)", + " run() {", + " child_process.exec(command);", + " }", + "}", + ].join("\n"); + const repo = await makeRepository(source); + try { + const index = await buildRepositoryIndex(repo.root, repo.files); + const analysis = await buildRepositoryRouteFlowAnalysis(repo.root, repo.files, index, buildModuleGraph(index, repo.files)); + const entrypoint = analysis.entrypoints.find((item) => item.route.frameworkHint === "NestJS controller"); + assert.equal(entrypoint?.route.method, "POST"); + assert.equal(entrypoint?.route.route, "/admin/run"); + assert.equal(entrypoint?.handler?.name, "run"); + assert.equal(entrypoint?.resolution, "decorated-function"); + + const flow = analysis.routeFlows.find((item) => item.route.frameworkHint === "NestJS controller"); + assert.deepEqual(flow?.evidence.map((item) => ({ path: item.path, line: item.line, kind: item.kind, depth: item.depth })), [ + { path: "admin.controller.ts", line: 8, kind: "process", depth: 0 }, + ]); + assert.equal(flow?.interpretation, "structural-route-call-sink-evidence-only"); + + const guards = analysis.nestJsGuardContexts.find((item) => item.route.route === "/admin/run"); + assert.deepEqual(guards?.guards, [ + { name: "SessionGuard", line: 3, scope: "controller" }, + { name: "AdminGuard", line: 6, scope: "method" }, + ]); + assert.equal(guards?.interpretation, "structural-nestjs-guard-attachment-not-runtime-protection"); + } finally { + await repo.cleanup(); + } +}); + +test("NestJS composition accepts empty literal route decorators", async () => { + const source = [ + 'import { Controller, Get } from "@nestjs/common";', + "@Controller(\"health\")", + "export class HealthController {", + " @Get()", + " status() {", + " return true;", + " }", + "}", + ].join("\n"); + const repo = await makeRepository(source); + try { + const graph = await buildCallGraph(repo.root, repo.files); + const result = await composeNestJsControllerEntrypoints(repo.root, repo.files, graph, []); + assert.equal(result.entrypoints.length, 1); + assert.equal(result.entrypoints[0]?.route.route, "/health"); + assert.equal(result.entrypoints[0]?.route.method, "GET"); + } finally { + await repo.cleanup(); + } +}); + +test("NestJS composition fails closed on dynamic controller prefixes", async () => { + const source = [ + 'import { Controller, Get } from "@nestjs/common";', + "@Controller(API_PREFIX)", + "export class DynamicController {", + " @Get(\"status\")", + " status() { return true; }", + "}", + ].join("\n"); + const repo = await makeRepository(source); + try { + const graph = await buildCallGraph(repo.root, repo.files); + const result = await composeNestJsControllerEntrypoints(repo.root, repo.files, graph, []); + assert.deepEqual(result.entrypoints, []); + assert.deepEqual(result.guardContexts, []); + } finally { + await repo.cleanup(); + } +}); + +test("NestJS composition fails closed on aliased framework decorators", async () => { + const source = [ + 'import { Controller as NestController, Get } from "@nestjs/common";', + "@NestController(\"api\")", + "export class AliasController {", + " @Get(\"status\")", + " status() { return true; }", + "}", + ].join("\n"); + const repo = await makeRepository(source); + try { + const graph = await buildCallGraph(repo.root, repo.files); + const result = await composeNestJsControllerEntrypoints(repo.root, repo.files, graph, []); + assert.deepEqual(result.entrypoints, []); + } finally { + await repo.cleanup(); + } +}); + +test("NestJS composition does not treat guard factories as structural guard evidence", async () => { + const source = [ + 'import { Controller, Get, UseGuards } from "@nestjs/common";', + "@Controller(\"api\")", + "export class GuardedController {", + " @Get(\"status\")", + " @UseGuards(AuthGuard(\"jwt\"))", + " status() {", + " return true;", + " }", + "}", + ].join("\n"); + const repo = await makeRepository(source); + try { + const graph = await buildCallGraph(repo.root, repo.files); + const result = await composeNestJsControllerEntrypoints(repo.root, repo.files, graph, []); + assert.deepEqual(result.guardContexts, []); + assert.deepEqual(result.entrypoints, []); + } finally { + await repo.cleanup(); + } +}); + +test("NestJS composition validates output bounds", async () => { + const source = [ + 'import { Controller, Get } from "@nestjs/common";', + "@Controller(\"api\")", + "export class ApiController {", + " @Get(\"status\")", + " status() { return true; }", + "}", + ].join("\n"); + const repo = await makeRepository(source); + try { + const graph = await buildCallGraph(repo.root, repo.files); + await assert.rejects( + composeNestJsControllerEntrypoints(repo.root, repo.files, graph, [], { maxRoutes: 0 }), + /maxRoutes must be an integer between 1 and 10000/, + ); + } finally { + await repo.cleanup(); + } +}); diff --git a/tests/node-route-handler-index.test.mjs b/tests/node-route-handler-index.test.mjs new file mode 100644 index 00000000..27291cb6 --- /dev/null +++ b/tests/node-route-handler-index.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { buildRepositoryIndex } from "@synsec/repository/analysis"; + +test("repository index records only simple named Node route handlers", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-node-routes-")); + try { + const source = [ + "function listUsers(req, res) { res.json([]); }", + "router.get('/users', requireAuth, listUsers);", + "router.post('/users', (req, res) => res.sendStatus(204));", + "router.use('/admin', adminRouter);", + "router.patch('/users/:id', requireAuth(), updateUser);", + ].join("\n"); + await writeFile(join(root, "server.ts"), source, "utf8"); + const index = await buildRepositoryIndex(root, [{ path: "server.ts", size: Buffer.byteLength(source) }]); + + assert.equal(index.routes.length, 4); + const getUsers = index.routes.find((route) => route.method === "GET" && route.route === "/users"); + const postUsers = index.routes.find((route) => route.method === "POST" && route.route === "/users"); + const admin = index.routes.find((route) => route.method === "USE" && route.route === "/admin"); + const patchUser = index.routes.find((route) => route.method === "PATCH" && route.route === "/users/:id"); + + assert.equal(getUsers?.handler, "listUsers"); + assert.equal(postUsers?.handler, undefined); + assert.equal(admin?.handler, undefined); + assert.equal(patchUser?.handler, undefined); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/oci-scanner-sandbox.test.mjs b/tests/oci-scanner-sandbox.test.mjs new file mode 100644 index 00000000..fd9472d4 --- /dev/null +++ b/tests/oci-scanner-sandbox.test.mjs @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + buildOciScannerSandboxPlan, + runOciSandboxedScanner, +} from "@synsec/scanner-sdk/oci-sandbox"; + +const digestImage = process.env.SYNSEC_TEST_OCI_IMAGE?.trim(); +const integration = digestImage ? test : test.skip; +const fixtureImage = `registry.example.invalid/synsec/scanner@sha256:${"a".repeat(64)}`; + +test("OCI scanner plan enforces immutable offline non-root resource and filesystem isolation", () => { + const plan = buildOciScannerSandboxPlan("scanner", ["--json", "/workspace"], { + image: fixtureImage, + repositoryRoot: "/srv/synsec/workspaces/job-1", + cpuLimit: 1.5, + memoryBytes: 512 * 1024 * 1024, + pidsLimit: 128, + scratchBytes: 64 * 1024 * 1024, + }); + + assert.equal(plan.runtimeCommand, "docker"); + assert.equal(plan.enforcedControls.networkPolicy, "none"); + assert.equal(plan.enforcedControls.repositoryReadOnly, true); + assert.equal(plan.enforcedControls.rootFilesystemReadOnly, true); + assert.equal(plan.enforcedControls.scratchSeparated, true); + assert.equal(plan.enforcedControls.runAsNonRoot, true); + assert.equal(plan.enforcedControls.capabilitiesDropped, true); + assert.equal(plan.enforcedControls.allowPrivilegeEscalation, false); + assert.equal(plan.enforcedControls.hostSocketMounts, false); + assert.ok(plan.runtimeArgs.includes("--pull=never")); + assert.ok(plan.runtimeArgs.includes("--network=none")); + assert.ok(plan.runtimeArgs.includes("--ipc=none")); + assert.ok(plan.runtimeArgs.includes("--read-only")); + assert.ok(plan.runtimeArgs.includes("--cap-drop=ALL")); + assert.ok(plan.runtimeArgs.includes("--security-opt=no-new-privileges=true")); + assert.ok(plan.runtimeArgs.includes("--pids-limit=128")); + assert.ok(plan.runtimeArgs.includes("--memory=536870912")); + assert.ok(plan.runtimeArgs.includes("--memory-swap=536870912")); + assert.ok(plan.runtimeArgs.includes("--cpus=1.5")); + assert.ok(plan.runtimeArgs.includes("--user=65532:65532")); + assert.ok(plan.runtimeArgs.includes("type=bind,src=/srv/synsec/workspaces/job-1,dst=/workspace,readonly")); + assert.ok(plan.runtimeArgs.includes("--tmpfs")); + assert.ok(plan.runtimeArgs.includes("/scratch:rw,noexec,nosuid,nodev,size=67108864,uid=65532,gid=65532,mode=0700")); + assert.ok(plan.runtimeArgs.includes("/tmp:rw,noexec,nosuid,nodev,size=67108864,uid=65532,gid=65532,mode=0700")); + assert.equal(plan.runtimeArgs.includes("--privileged"), false); + assert.equal(plan.runtimeArgs.some((value) => value.includes("docker.sock")), false); +}); + +test("OCI scanner plan rejects mutable images, root users, unsafe mounts, and unbounded resources", () => { + assert.throws( + () => buildOciScannerSandboxPlan("scanner", [], { image: "scanner:latest", repositoryRoot: "/repo" }), + /pinned by sha256 digest/, + ); + assert.throws( + () => buildOciScannerSandboxPlan("scanner", [], { image: fixtureImage, repositoryRoot: "repo" }), + /absolute mount-safe path/, + ); + assert.throws( + () => buildOciScannerSandboxPlan("scanner", [], { image: fixtureImage, repositoryRoot: "/repo,escape" }), + /absolute mount-safe path/, + ); + assert.throws( + () => buildOciScannerSandboxPlan("scanner", [], { image: fixtureImage, repositoryRoot: "/repo", runAsUser: "0:0" }), + /non-root/, + ); + assert.throws( + () => buildOciScannerSandboxPlan("scanner", [], { image: fixtureImage, repositoryRoot: "/repo", pidsLimit: 1 }), + /PID limit/, + ); + assert.throws( + () => buildOciScannerSandboxPlan("scanner", [], { image: fixtureImage, repositoryRoot: "/repo", memoryBytes: 1 }), + /memory limit/, + ); +}); + +integration("OCI scanner execution actually observes read-only source, writable scratch, no credentials, and no network interface", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-oci-sandbox-")); + const previousToken = process.env.GITHUB_TOKEN; + try { + await chmod(root, 0o755); + await writeFile(join(root, "fixture.txt"), "repository evidence\n", { mode: 0o644 }); + process.env.GITHUB_TOKEN = "ghp_abcdefghijklmnopqrstuvwxyz1234567890"; + const output = await runOciSandboxedScanner( + "/bin/sh", + [ + "-c", + [ + "test -r /workspace/fixture.txt", + "! touch /workspace/should-not-write", + "touch /scratch/write-ok", + "touch /tmp/write-ok", + "test \"$(id -u)\" -ne 0", + "test -z \"${GITHUB_TOKEN:-}\"", + "test ! -e /var/run/docker.sock", + "! grep -q 'eth0:' /proc/net/dev", + ].join(" && "), + ], + { + image: digestImage, + repositoryRoot: root, + cpuLimit: 0.5, + memoryBytes: 128 * 1024 * 1024, + pidsLimit: 32, + scratchBytes: 32 * 1024 * 1024, + timeoutMs: 30_000, + maxOutputBytes: 1024 * 1024, + }, + ); + assert.equal(output.exitCode, 0, output.stderr); + assert.doesNotMatch(`${output.stdout}\n${output.stderr}`, /ghp_abcdefghijklmnopqrstuvwxyz1234567890/); + } finally { + if (previousToken === undefined) delete process.env.GITHUB_TOKEN; + else process.env.GITHUB_TOKEN = previousToken; + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/osv-incremental.test.mjs b/tests/osv-incremental.test.mjs new file mode 100644 index 00000000..2a720fa9 --- /dev/null +++ b/tests/osv-incremental.test.mjs @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { buildOsvArguments, scannerSupportsNativeChangedFiles } from "../packages/scanners/dist/index.js"; + +async function repository() { + const root = await mkdtemp(join(tmpdir(), "synsec-osv-incremental-")); + await mkdir(join(root, "apps", "web"), { recursive: true }); + await mkdir(join(root, "services", "api"), { recursive: true }); + await writeFile(join(root, "apps", "web", "package-lock.json"), "{}\n"); + await writeFile(join(root, "services", "api", "requirements-dev.txt"), "flask==3.0.0\n"); + return root; +} + +test("OSV-Scanner is advertised as a native changed-file scanner", () => { + assert.equal(scannerSupportsNativeChangedFiles("osv-scanner"), true); +}); + +test("OSV-Scanner defaults to recursive repository scanning", async () => { + const root = await repository(); + assert.deepEqual(buildOsvArguments({ target: { path: root } }), [ + "scan", "--format", "json", "source", "-r", root, + ]); +}); + +test("OSV-Scanner narrows a dependency-only changed scope to explicit lockfiles", async () => { + const root = await repository(); + assert.deepEqual(buildOsvArguments({ + target: { path: root }, + changedFiles: [ + "apps/web/package-lock.json", + "services/api/requirements-dev.txt", + "apps/web/package-lock.json", + ], + }), [ + "scan", + "--format", + "json", + "source", + `--lockfile=${join(root, "apps", "web", "package-lock.json")}`, + `--lockfile=${join(root, "services", "api", "requirements-dev.txt")}`, + ]); +}); + +test("OSV-Scanner falls back to full repository coverage for mixed or ambiguous changes", async () => { + const root = await repository(); + for (const changedFiles of [ + ["apps/web/package-lock.json", "apps/web/src/index.ts"], + ["apps/web/package.json"], + ["osv-scanner.toml"], + ["missing/package-lock.json"], + ["../outside/package-lock.json"], + ["/tmp/package-lock.json"], + ]) { + assert.deepEqual(buildOsvArguments({ target: { path: root }, changedFiles }), [ + "scan", "--format", "json", "source", "-r", root, + ]); + } +}); + +test("OSV-Scanner does not use a changed lockfile symlink as a native scan target", async () => { + const root = await repository(); + const outside = join(await mkdtemp(join(tmpdir(), "synsec-osv-outside-")), "package-lock.json"); + await writeFile(outside, "{}\n"); + const linkedDir = join(root, "linked"); + await mkdir(linkedDir, { recursive: true }); + await symlink(outside, join(linkedDir, "package-lock.json")); + + assert.deepEqual(buildOsvArguments({ + target: { path: root }, + changedFiles: ["linked/package-lock.json"], + }), ["scan", "--format", "json", "source", "-r", root]); +}); + +test("OSV-Scanner bounds native dependency scope", async () => { + const root = await repository(); + const changedFiles = []; + for (let index = 0; index < 101; index += 1) { + const rel = `deps/${index}/package-lock.json`; + const dir = join(root, "deps", String(index)); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "package-lock.json"), "{}\n"); + changedFiles.push(rel); + } + assert.deepEqual(buildOsvArguments({ target: { path: root }, changedFiles }), [ + "scan", "--format", "json", "source", "-r", root, + ]); +}); diff --git a/tests/postgres-hosted-installation-ownership.test.mjs b/tests/postgres-hosted-installation-ownership.test.mjs new file mode 100644 index 00000000..787be0cd --- /dev/null +++ b/tests/postgres-hosted-installation-ownership.test.mjs @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import pg from "pg"; +import { + migrateSynSecGitHubPostgresHostedInstallationOwnership, + PostgresSynSecHostedInstallationOwnershipStore, +} from "@synsec/github/postgres-hosted-installation-ownership"; + +const connectionString = process.env.SYNSEC_TEST_POSTGRES_URL?.trim(); +const integration = connectionString ? test : test.skip; + +function claim(tenantId = "tenant-a", overrides = {}) { + return { + tenantId, + installationId: 9001, + githubUserId: 101, + accountId: 5001, + accountLogin: "synsec-org", + accountType: "Organization", + ...overrides, + }; +} + +integration("PostgreSQL hosted installation ownership atomically fences competing tenant claims across pools", async () => { + const admin = new pg.Pool({ connectionString, max: 4, application_name: "synsec-hosted-owner-admin" }); + const replicaA = new pg.Pool({ connectionString, max: 4, application_name: "synsec-hosted-owner-a" }); + const replicaB = new pg.Pool({ connectionString, max: 4, application_name: "synsec-hosted-owner-b" }); + try { + await migrateSynSecGitHubPostgresHostedInstallationOwnership(admin); + await admin.query("TRUNCATE synsec_github_hosted_installation_ownership"); + const first = new PostgresSynSecHostedInstallationOwnershipStore(replicaA); + const second = new PostgresSynSecHostedInstallationOwnershipStore(replicaB); + + const results = await Promise.all([ + first.claim(claim("tenant-a")), + second.claim(claim("tenant-b", { githubUserId: 202 })), + ]); + assert.equal(results.filter((value) => value === "claimed").length, 1); + assert.equal(results.filter((value) => value === "conflict").length, 1); + + const row = await admin.query( + "SELECT installation_id, tenant_id, account_id, account_type FROM synsec_github_hosted_installation_ownership WHERE installation_id = 9001", + ); + assert.equal(row.rows.length, 1); + const durableTenant = row.rows[0].tenant_id; + assert.ok(durableTenant === "tenant-a" || durableTenant === "tenant-b"); + + const ownerStore = durableTenant === "tenant-a" ? first : second; + const ownerUser = durableTenant === "tenant-a" ? 303 : 404; + assert.equal(await ownerStore.claim(claim(durableTenant, { githubUserId: ownerUser, accountLogin: "renamed-org" })), "already-owned-by-tenant"); + + const current = await admin.query( + "SELECT github_user_id, account_login, access_status FROM synsec_github_hosted_installation_ownership WHERE installation_id = 9001", + ); + assert.equal(Number(current.rows[0].github_user_id), ownerUser); + assert.equal(current.rows[0].account_login, "renamed-org"); + assert.equal(current.rows[0].access_status, "active"); + + const otherStore = durableTenant === "tenant-a" ? second : first; + const otherTenant = durableTenant === "tenant-a" ? "tenant-b" : "tenant-a"; + assert.equal(await otherStore.release(otherTenant, 9001), false); + assert.equal((await admin.query("SELECT count(*)::integer AS count FROM synsec_github_hosted_installation_ownership")).rows[0].count, 1); + assert.equal(await ownerStore.release(durableTenant, 9001), true); + assert.equal((await admin.query("SELECT count(*)::integer AS count FROM synsec_github_hosted_installation_ownership")).rows[0].count, 0); + } finally { + await Promise.allSettled([admin.end(), replicaA.end(), replicaB.end()]); + } +}); + +integration("PostgreSQL hosted ownership refuses same-tenant account identity drift", async () => { + const pool = new pg.Pool({ connectionString, max: 4 }); + try { + await migrateSynSecGitHubPostgresHostedInstallationOwnership(pool); + await pool.query("TRUNCATE synsec_github_hosted_installation_ownership"); + const store = new PostgresSynSecHostedInstallationOwnershipStore(pool); + assert.equal(await store.claim(claim()), "claimed"); + assert.equal(await store.claim(claim("tenant-a", { accountId: 9999 })), "conflict"); + assert.equal(await store.claim(claim("tenant-a", { accountType: "User" })), "conflict"); + } finally { + await pool.end(); + } +}); + +integration("PostgreSQL reverification epochs reject stale cross-replica revocation results", async () => { + const admin = new pg.Pool({ connectionString, max: 4, application_name: "synsec-hosted-reverify-admin" }); + const replicaA = new pg.Pool({ connectionString, max: 4, application_name: "synsec-hosted-reverify-a" }); + const replicaB = new pg.Pool({ connectionString, max: 4, application_name: "synsec-hosted-reverify-b" }); + try { + await migrateSynSecGitHubPostgresHostedInstallationOwnership(admin); + await admin.query("TRUNCATE synsec_github_hosted_installation_ownership"); + const first = new PostgresSynSecHostedInstallationOwnershipStore(replicaA); + const second = new PostgresSynSecHostedInstallationOwnershipStore(replicaB); + assert.equal(await first.claim(claim()), "claimed"); + + const older = await first.beginReverification("tenant-a", 9001, 101); + const newer = await second.beginReverification("tenant-a", 9001, 101); + assert.ok(older && newer); + assert.ok(newer.epoch > older.epoch); + + assert.equal(await second.finishVerified({ ...newer, accountLogin: "synsec-org-renamed" }), "applied"); + assert.equal(await first.finishRevoked({ ...older, reason: "inaccessible" }), "stale"); + assert.equal(await first.isFreshlyAuthorized("tenant-a", 9001, 60_000), true); + + const row = await admin.query( + `SELECT access_status, revocation_reason, account_login, verification_epoch + FROM synsec_github_hosted_installation_ownership WHERE installation_id = 9001`, + ); + assert.equal(row.rows[0].access_status, "active"); + assert.equal(row.rows[0].revocation_reason, null); + assert.equal(row.rows[0].account_login, "synsec-org-renamed"); + assert.equal(Number(row.rows[0].verification_epoch), newer.epoch); + } finally { + await Promise.allSettled([admin.end(), replicaA.end(), replicaB.end()]); + } +}); + +integration("PostgreSQL revocation blocks authorization without releasing the tenant fence and later verified access can reactivate it", async () => { + const pool = new pg.Pool({ connectionString, max: 6 }); + try { + await migrateSynSecGitHubPostgresHostedInstallationOwnership(pool); + await pool.query("TRUNCATE synsec_github_hosted_installation_ownership"); + const store = new PostgresSynSecHostedInstallationOwnershipStore(pool); + assert.equal(await store.claim(claim()), "claimed"); + + const revokedFence = await store.beginReverification("tenant-a", 9001, 101); + assert.ok(revokedFence); + assert.equal(await store.finishRevoked({ ...revokedFence, reason: "suspended" }), "applied"); + assert.equal(await store.isFreshlyAuthorized("tenant-a", 9001, 60_000), false); + assert.equal(await store.claim(claim("tenant-b", { githubUserId: 202 })), "conflict"); + + const verifiedFence = await store.beginReverification("tenant-a", 9001, 101); + assert.ok(verifiedFence); + assert.equal(await store.finishVerified({ ...verifiedFence, accountLogin: "synsec-org" }), "applied"); + assert.equal(await store.isFreshlyAuthorized("tenant-a", 9001, 60_000), true); + + await pool.query( + "UPDATE synsec_github_hosted_installation_ownership SET verified_at = clock_timestamp() - interval '2 minutes' WHERE installation_id = 9001", + ); + assert.equal(await store.isFreshlyAuthorized("tenant-a", 9001, 60_000), false); + } finally { + await pool.end(); + } +}); + +integration("PostgreSQL reverification cannot be begun by another tenant or a stale proof user", async () => { + const pool = new pg.Pool({ connectionString, max: 4 }); + try { + await migrateSynSecGitHubPostgresHostedInstallationOwnership(pool); + await pool.query("TRUNCATE synsec_github_hosted_installation_ownership"); + const store = new PostgresSynSecHostedInstallationOwnershipStore(pool); + assert.equal(await store.claim(claim()), "claimed"); + assert.equal(await store.beginReverification("tenant-b", 9001, 101), undefined); + assert.equal(await store.beginReverification("tenant-a", 9001, 202), undefined); + } finally { + await pool.end(); + } +}); diff --git a/tests/postgres-lease-observer.test.mjs b/tests/postgres-lease-observer.test.mjs new file mode 100644 index 00000000..3d5f8339 --- /dev/null +++ b/tests/postgres-lease-observer.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import pg from "pg"; +import { countSynSecGitHubPostgresActiveLeases } from "@synsec/github/postgres-lease-observer"; +import { migrateSynSecGitHubPostgresBackend } from "@synsec/github/postgres-shared-backend"; +import { PostgresGitHubScanQueue } from "@synsec/github/postgres-shared-state"; + +const connectionString = process.env.SYNSEC_TEST_POSTGRES_URL?.trim(); +const integration = connectionString ? test : test.skip; + +function job(deliveryId, byte) { + return { + deliveryId, + installationId: 77, + repository: "synsec/maintenance", + headSha: byte.repeat(40), + event: "push", + createdAt: "2026-08-25T00:00:00.000Z", + }; +} + +test("PostgreSQL durable lease observer rejects malformed backend result shapes", async () => { + await assert.rejects( + countSynSecGitHubPostgresActiveLeases({ + async query() { return { rows: [] }; }, + async connect() { throw new Error("unused"); }, + }), + /invalid result shape/i, + ); + await assert.rejects( + countSynSecGitHubPostgresActiveLeases({ + async query() { return { rows: [{ count: -1 }] }; }, + async connect() { throw new Error("unused"); }, + }), + /invalid count/i, + ); +}); + +integration("PostgreSQL durable lease observer counts only currently valid fenced leases", async () => { + const pool = new pg.Pool({ connectionString, max: 8, application_name: "synsec-lease-observer-test" }); + try { + await migrateSynSecGitHubPostgresBackend(pool); + await pool.query("TRUNCATE synsec_github_scan_jobs, synsec_github_replay, synsec_github_installations"); + const queue = new PostgresGitHubScanQueue(pool, { leaseMs: 10_000 }); + + await queue.enqueue(job("maintenance-lease-1", "a")); + await queue.enqueue(job("maintenance-lease-2", "b")); + const first = await queue.claimNext(); + const second = await queue.claimNext(); + assert.ok(first?.leaseId); + assert.ok(second?.leaseId); + assert.equal(await countSynSecGitHubPostgresActiveLeases(pool), 2); + + await pool.query( + "UPDATE synsec_github_scan_jobs SET lease_until = clock_timestamp() - interval '1 second' WHERE job_id = $1", + [first.jobId], + ); + assert.equal(await countSynSecGitHubPostgresActiveLeases(pool), 1); + + assert.equal(await queue.complete(second.jobId, second.leaseId), true); + assert.equal(await countSynSecGitHubPostgresActiveLeases(pool), 0); + } finally { + await pool.end(); + } +}); diff --git a/tests/postgres-shared-backend.test.mjs b/tests/postgres-shared-backend.test.mjs new file mode 100644 index 00000000..ea4e057c --- /dev/null +++ b/tests/postgres-shared-backend.test.mjs @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import pg from "pg"; +import { + buildSynSecGitHubPostgresBackendContract, + createGitHubAppPostgresSharedRuntime, + createSynSecGitHubPostgresSharedStores, + migrateSynSecGitHubPostgresBackend, + SYNSEC_GITHUB_POSTGRES_BACKEND_ID, + SYNSEC_GITHUB_POSTGRES_IMPLEMENTATION_VERSION, +} from "@synsec/github/postgres-shared-backend"; +import { synchronizeGitHubInstallationState } from "@synsec/github/installation-sync"; +import { assessGitHubAppSharedStateBackendContract } from "@synsec/github/shared-state-contract"; + +const connectionString = process.env.SYNSEC_TEST_POSTGRES_URL?.trim(); +const integration = connectionString ? test : test.skip; + +function pushJob(deliveryId) { + return { + deliveryId, + installationId: 9001, + repository: "synsec/example", + headSha: "b".repeat(40), + event: "push", + createdAt: "2026-08-24T12:00:00.000Z", + }; +} + +function installationEvent(overrides = {}) { + return { + event: "installation", + action: "created", + installationId: 9001, + accountLogin: "synsec-org", + accountType: "Organization", + repositorySelection: "selected", + repositories: ["synsec/example"], + repositoriesAdded: [], + repositoriesRemoved: [], + ...overrides, + }; +} + +test("built-in PostgreSQL backend contract declares every required capability without connection details", () => { + const contract = buildSynSecGitHubPostgresBackendContract(); + const assessment = assessGitHubAppSharedStateBackendContract(contract); + assert.equal(assessment.ready, true); + assert.deepEqual(assessment.missingEvidence, []); + assert.equal(contract.backendId, SYNSEC_GITHUB_POSTGRES_BACKEND_ID); + assert.equal(contract.implementationVersion, SYNSEC_GITHUB_POSTGRES_IMPLEMENTATION_VERSION); + const serialized = JSON.stringify(contract); + assert.doesNotMatch(serialized, /postgresql:\/\//i); + assert.doesNotMatch(serialized, /password|connectionString|databaseUrl/i); +}); + +test("PostgreSQL runtime composition still fails closed without matching conformance evidence", () => { + const pool = { + async query() { throw new Error("database should not be queried during composition"); }, + async connect() { throw new Error("database should not be connected during composition"); }, + }; + assert.throws( + () => createGitHubAppPostgresSharedRuntime({ + pool, + conformanceReport: {}, + webhookSecret: "0123456789abcdef0123456789abcdef", + worker: {}, + }), + /conformance evidence is not ready/, + ); +}); + +integration("composed PostgreSQL migration serializes concurrent deployment invocations", async () => { + const pool = new pg.Pool({ connectionString, max: 8 }); + try { + await Promise.all([ + migrateSynSecGitHubPostgresBackend(pool), + migrateSynSecGitHubPostgresBackend(pool), + migrateSynSecGitHubPostgresBackend(pool), + migrateSynSecGitHubPostgresBackend(pool), + ]); + const schema = await pool.query( + "SELECT version FROM synsec_github_schema WHERE component = 'shared-state'", + ); + assert.deepEqual(schema.rows, [{ version: 1 }]); + const stores = createSynSecGitHubPostgresSharedStores(pool, { leaseMs: 10_000 }); + assert.equal(stores.queue.leaseMs, 10_000); + assert.equal(typeof stores.replayStore.claim, "function"); + assert.equal(typeof stores.installationStore.isRepositoryAllowed, "function"); + } finally { + await pool.end(); + } +}); + +integration("PostgreSQL replay, queue fencing, and authorization survive pool teardown and reconnect", async () => { + let firstPool = new pg.Pool({ connectionString, max: 4, application_name: "synsec-restart-before" }); + let secondPool; + try { + await migrateSynSecGitHubPostgresBackend(firstPool); + await firstPool.query("TRUNCATE synsec_github_scan_jobs, synsec_github_replay, synsec_github_installations"); + + const before = createSynSecGitHubPostgresSharedStores(firstPool, { leaseMs: 10_000 }); + const replayClaim = await before.replayStore.claim("restart-replay-1"); + assert.equal(replayClaim.accepted, true); + + await synchronizeGitHubInstallationState( + installationEvent(), + before.installationStore, + Date.parse("2026-08-24T12:00:00.000Z"), + ); + assert.equal(await before.installationStore.isRepositoryAllowed(9001, "synsec/example"), true); + + await before.queue.enqueue(pushJob("restart-job-1")); + const leasedBeforeRestart = await before.queue.claimNext(); + assert.ok(leasedBeforeRestart?.leaseId); + + await firstPool.end(); + firstPool = undefined; + + secondPool = new pg.Pool({ connectionString, max: 4, application_name: "synsec-restart-after" }); + const after = createSynSecGitHubPostgresSharedStores(secondPool, { leaseMs: 10_000 }); + + const duplicate = await after.replayStore.claim("restart-replay-1"); + assert.equal(duplicate.accepted, false); + assert.equal(duplicate.receivedAt, replayClaim.receivedAt); + + assert.equal(await after.installationStore.isRepositoryAllowed(9001, "synsec/example"), true); + await synchronizeGitHubInstallationState( + installationEvent({ action: "suspend", suspendedAt: "2026-08-24T12:00:01.000Z", repositories: [] }), + after.installationStore, + Date.parse("2026-08-24T12:00:01.000Z"), + ); + assert.equal(await after.installationStore.isRepositoryAllowed(9001, "synsec/example"), false); + + const durableLease = await after.queue.assertLease(leasedBeforeRestart.jobId, leasedBeforeRestart.leaseId); + assert.equal(durableLease.jobId, leasedBeforeRestart.jobId); + const renewed = await after.queue.renew(leasedBeforeRestart.jobId, leasedBeforeRestart.leaseId); + assert.equal(renewed.leaseId, leasedBeforeRestart.leaseId); + assert.equal(await after.queue.complete(leasedBeforeRestart.jobId, leasedBeforeRestart.leaseId), true); + assert.deepEqual(await after.queue.list(), []); + } finally { + if (firstPool) await firstPool.end(); + if (secondPool) await secondPool.end(); + } +}); diff --git a/tests/postgres-shared-state-conformance.test.mjs b/tests/postgres-shared-state-conformance.test.mjs new file mode 100644 index 00000000..33b0d5b1 --- /dev/null +++ b/tests/postgres-shared-state-conformance.test.mjs @@ -0,0 +1,211 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import pg from "pg"; +import { + migrateSynSecGitHubPostgresState, + PostgresGitHubScanQueue, + PostgresGitHubWebhookReplayStore, +} from "@synsec/github/postgres-shared-state"; +import { + migrateSynSecGitHubPostgresInstallationState, + PostgresGitHubInstallationStore, +} from "@synsec/github/postgres-installation-store"; +import { synchronizeGitHubInstallationState } from "@synsec/github/installation-sync"; +import { runGitHubAppSharedStateConformance } from "@synsec/github/shared-state-conformance-runner"; + +const connectionString = process.env.SYNSEC_TEST_POSTGRES_URL?.trim(); +const integration = connectionString ? test : test.skip; + +function pushJob(deliveryId = "delivery-1") { + return { + deliveryId, + installationId: 9001, + repository: "synsec/example", + headSha: "a".repeat(40), + event: "push", + createdAt: "2026-08-24T00:00:00.000Z", + }; +} + +function installationEvent(overrides = {}) { + return { + event: "installation", + action: "created", + installationId: 9001, + accountLogin: "synsec-org", + accountType: "Organization", + repositorySelection: "selected", + repositories: ["synsec/base"], + repositoriesAdded: [], + repositoriesRemoved: [], + ...overrides, + }; +} + +function repositoryDelta(repository) { + return installationEvent({ + event: "installation_repositories", + action: "added", + repositories: [], + repositoriesAdded: [repository], + }); +} + +integration("PostgreSQL passes the canonical seven-scenario shared-state conformance matrix across independent replica pools", async () => { + const adminPool = new pg.Pool({ connectionString, max: 4 }); + const poolA = new pg.Pool({ connectionString, max: 8, application_name: "synsec-conformance-replica-a" }); + const poolB = new pg.Pool({ connectionString, max: 8, application_name: "synsec-conformance-replica-b" }); + try { + await migrateSynSecGitHubPostgresState(adminPool); + await migrateSynSecGitHubPostgresInstallationState(adminPool); + + const queueA = () => new PostgresGitHubScanQueue(poolA, { leaseMs: 10_000 }); + const queueB = () => new PostgresGitHubScanQueue(poolB, { leaseMs: 10_000 }); + const replayA = () => new PostgresGitHubWebhookReplayStore(poolA); + const replayB = () => new PostgresGitHubWebhookReplayStore(poolB); + const installationA = () => new PostgresGitHubInstallationStore(poolA); + const installationB = () => new PostgresGitHubInstallationStore(poolB); + + const adapter = { + backendId: "postgres-v1", + implementationVersion: "0.2.0-postgres-v1", + async reset() { + await adminPool.query("TRUNCATE synsec_github_scan_jobs, synsec_github_replay, synsec_github_installations"); + }, + scenarios: { + async "replay.concurrent-duplicate-claim"() { + const claims = await Promise.all([ + replayA().claim("conformance-replay-1"), + replayB().claim("conformance-replay-1"), + ]); + assert.equal(claims.filter((claim) => claim.accepted).length, 1); + assert.equal(claims.filter((claim) => !claim.accepted).length, 1); + assert.equal(claims[0].receivedAt, claims[1].receivedAt); + }, + + async "queue.concurrent-idempotent-insert"() { + const first = queueA(); + const second = queueB(); + const [insertedA, insertedB] = await Promise.all([ + first.enqueue(pushJob("conformance-insert-1")), + second.enqueue(pushJob("conformance-insert-1")), + ]); + assert.equal(insertedA.jobId, insertedB.jobId); + assert.equal(insertedA.deliveryId, insertedB.deliveryId); + assert.equal((await first.list()).length, 1); + }, + + async "queue.concurrent-claim-fence"() { + const first = queueA(); + const second = queueB(); + await first.enqueue(pushJob("conformance-claim-1")); + const claims = await Promise.all([first.claimNext(), second.claimNext()]); + assert.equal(claims.filter(Boolean).length, 1); + const claimed = claims.find(Boolean); + assert.ok(claimed?.leaseId); + await first.assertLease(claimed.jobId, claimed.leaseId); + }, + + async "queue.stale-fence-renewal"() { + const first = queueA(); + const second = queueB(); + await first.enqueue(pushJob("conformance-renew-1")); + const oldLease = await first.claimNext(); + assert.ok(oldLease?.leaseId); + await adminPool.query( + "UPDATE synsec_github_scan_jobs SET lease_until = clock_timestamp() - interval '1 second' WHERE job_id = $1", + [oldLease.jobId], + ); + const newLease = await second.claimNext(); + assert.ok(newLease?.leaseId); + assert.notEqual(newLease.leaseId, oldLease.leaseId); + await assert.rejects(first.renew(oldLease.jobId, oldLease.leaseId)); + await second.assertLease(newLease.jobId, newLease.leaseId); + }, + + async "queue.stale-fence-terminal-transitions"() { + const first = queueA(); + const second = queueB(); + await first.enqueue(pushJob("conformance-terminal-1")); + const oldLease = await first.claimNext(); + assert.ok(oldLease?.leaseId); + await adminPool.query( + "UPDATE synsec_github_scan_jobs SET lease_until = clock_timestamp() - interval '1 second' WHERE job_id = $1", + [oldLease.jobId], + ); + const newLease = await second.claimNext(); + assert.ok(newLease?.leaseId); + await assert.rejects(first.release(oldLease.jobId, oldLease.leaseId)); + await assert.rejects(first.fail(oldLease.jobId, oldLease.leaseId)); + await assert.rejects(first.complete(oldLease.jobId, oldLease.leaseId)); + await second.assertLease(newLease.jobId, newLease.leaseId); + }, + + async "installation.concurrent-selection-mutation"() { + const first = installationA(); + const second = installationB(); + await synchronizeGitHubInstallationState( + installationEvent(), + first, + Date.parse("2026-08-24T00:00:00.000Z"), + ); + await Promise.all([ + synchronizeGitHubInstallationState( + repositoryDelta("synsec/alpha"), + first, + Date.parse("2026-08-24T00:00:01.000Z"), + ), + synchronizeGitHubInstallationState( + repositoryDelta("synsec/beta"), + second, + Date.parse("2026-08-24T00:00:02.000Z"), + ), + ]); + assert.deepEqual((await first.get(9001))?.repositories, [ + "synsec/alpha", + "synsec/base", + "synsec/beta", + ]); + }, + + async "authorization.cross-replica-revocation"() { + const first = installationA(); + const second = installationB(); + await synchronizeGitHubInstallationState( + installationEvent(), + first, + Date.parse("2026-08-24T00:00:00.000Z"), + ); + assert.equal(await second.isRepositoryAllowed(9001, "synsec/base"), true); + await synchronizeGitHubInstallationState( + installationEvent({ + action: "suspend", + suspendedAt: "2026-08-24T00:00:03.000Z", + repositories: [], + }), + first, + Date.parse("2026-08-24T00:00:03.000Z"), + ); + assert.equal(await second.isRepositoryAllowed(9001, "synsec/base"), false); + }, + }, + }; + + const report = await runGitHubAppSharedStateConformance(adapter, { scenarioTimeoutMs: 10_000 }); + assert.equal(report.backendId, "postgres-v1"); + assert.equal(report.implementationVersion, "0.2.0-postgres-v1"); + assert.equal(report.complete, true); + assert.deepEqual(report.coverage.missingCapabilities, []); + assert.deepEqual(report.results.map(({ id, status }) => [id, status]), [ + ["replay.concurrent-duplicate-claim", "passed"], + ["queue.concurrent-idempotent-insert", "passed"], + ["queue.concurrent-claim-fence", "passed"], + ["queue.stale-fence-renewal", "passed"], + ["queue.stale-fence-terminal-transitions", "passed"], + ["installation.concurrent-selection-mutation", "passed"], + ["authorization.cross-replica-revocation", "passed"], + ]); + } finally { + await Promise.allSettled([adminPool.end(), poolA.end(), poolB.end()]); + } +}); diff --git a/tests/postgres-shared-state.test.mjs b/tests/postgres-shared-state.test.mjs new file mode 100644 index 00000000..d1d1b801 --- /dev/null +++ b/tests/postgres-shared-state.test.mjs @@ -0,0 +1,224 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import pg from "pg"; +import { + migrateSynSecGitHubPostgresState, + PostgresGitHubScanQueue, + PostgresGitHubWebhookReplayStore, +} from "@synsec/github/postgres-shared-state"; +import { + migrateSynSecGitHubPostgresInstallationState, + PostgresGitHubInstallationStore, +} from "@synsec/github/postgres-installation-store"; +import { synchronizeGitHubInstallationState } from "@synsec/github/installation-sync"; + +const connectionString = process.env.SYNSEC_TEST_POSTGRES_URL?.trim(); +const integration = connectionString ? test : test.skip; + +function job(deliveryId, headByte, createdAt) { + return { + deliveryId, + installationId: 42, + repository: "synsec/example", + headSha: headByte.repeat(40), + event: "push", + createdAt, + }; +} + +function installationEvent(overrides = {}) { + return { + event: "installation", + action: "created", + installationId: 4242, + accountLogin: "synsec-org", + accountType: "Organization", + repositorySelection: "selected", + repositories: ["synsec/base"], + repositoriesAdded: [], + repositoriesRemoved: [], + ...overrides, + }; +} + +function repositoryDelta(repository) { + return installationEvent({ + event: "installation_repositories", + action: "added", + repositories: [], + repositoriesAdded: [repository], + }); +} + +integration("PostgreSQL migrations are idempotent and record the exact shared-state schema version", async () => { + const pool = new pg.Pool({ connectionString, max: 4 }); + try { + await migrateSynSecGitHubPostgresState(pool); + await migrateSynSecGitHubPostgresState(pool); + await migrateSynSecGitHubPostgresInstallationState(pool); + await migrateSynSecGitHubPostgresInstallationState(pool); + const result = await pool.query("SELECT version FROM synsec_github_schema WHERE component = 'shared-state'"); + assert.deepEqual(result.rows, [{ version: 1 }]); + } finally { + await pool.end(); + } +}); + +integration("PostgreSQL replay claims are atomic across independent store instances", async () => { + const pool = new pg.Pool({ connectionString, max: 8 }); + try { + await migrateSynSecGitHubPostgresState(pool); + await pool.query("DELETE FROM synsec_github_replay"); + const first = new PostgresGitHubWebhookReplayStore(pool); + const second = new PostgresGitHubWebhookReplayStore(pool); + const claims = await Promise.all([ + first.claim("delivery-concurrent-1"), + second.claim("delivery-concurrent-1"), + ]); + assert.equal(claims.filter((claim) => claim.accepted).length, 1); + assert.equal(claims.filter((claim) => !claim.accepted).length, 1); + assert.equal(claims[0].receivedAt, claims[1].receivedAt); + + const accepted = claims.find((claim) => claim.accepted); + assert.ok(accepted); + assert.equal(await second.release(accepted.deliveryId, "2026-01-01T00:00:00.000Z"), false); + assert.equal(await first.release(accepted.deliveryId, accepted.receivedAt), true); + assert.equal((await second.claim(accepted.deliveryId)).accepted, true); + } finally { + await pool.end(); + } +}); + +integration("PostgreSQL queue insertion is idempotent for exact work and rejects delivery provenance conflicts", async () => { + const pool = new pg.Pool({ connectionString, max: 12 }); + try { + await migrateSynSecGitHubPostgresState(pool); + await pool.query("DELETE FROM synsec_github_scan_jobs"); + const queueA = new PostgresGitHubScanQueue(pool, { leaseMs: 10_000 }); + const queueB = new PostgresGitHubScanQueue(pool, { leaseMs: 10_000 }); + + const duplicate = job("queue-duplicate-1", "a", "2026-08-24T00:00:00.000Z"); + const [insertedA, insertedB] = await Promise.all([queueA.enqueue(duplicate), queueB.enqueue(duplicate)]); + assert.equal(insertedA.jobId, insertedB.jobId); + assert.equal((await queueA.list()).length, 1); + + await assert.rejects( + queueB.enqueue(job("queue-duplicate-1", "f", "2026-08-24T00:00:09.000Z")), + /different scan provenance/, + ); + assert.equal((await queueA.list()).length, 1); + } finally { + await pool.end(); + } +}); + +integration("PostgreSQL queue uses competing fenced claims", async () => { + const pool = new pg.Pool({ connectionString, max: 12 }); + try { + await migrateSynSecGitHubPostgresState(pool); + await pool.query("DELETE FROM synsec_github_scan_jobs"); + const queueA = new PostgresGitHubScanQueue(pool, { leaseMs: 10_000 }); + const queueB = new PostgresGitHubScanQueue(pool, { leaseMs: 10_000 }); + + await queueA.enqueue(job("queue-claim-1", "b", "2026-08-24T00:00:01.000Z")); + await queueA.enqueue(job("queue-claim-2", "c", "2026-08-24T00:00:02.000Z")); + const [claimedA, claimedB] = await Promise.all([queueA.claimNext(), queueB.claimNext()]); + assert.ok(claimedA?.leaseId); + assert.ok(claimedB?.leaseId); + assert.notEqual(claimedA.jobId, claimedB.jobId); + assert.notEqual(claimedA.leaseId, claimedB.leaseId); + assert.equal(claimedA.attempts, 1); + assert.equal(claimedB.attempts, 1); + } finally { + await pool.end(); + } +}); + +integration("PostgreSQL queue rejects a stale fence after lease reclamation", async () => { + const pool = new pg.Pool({ connectionString, max: 8 }); + try { + await migrateSynSecGitHubPostgresState(pool); + await pool.query("DELETE FROM synsec_github_scan_jobs"); + const queueA = new PostgresGitHubScanQueue(pool, { leaseMs: 10_000 }); + const queueB = new PostgresGitHubScanQueue(pool, { leaseMs: 10_000 }); + await queueA.enqueue(job("queue-fence-1", "d", "2026-08-24T00:00:03.000Z")); + const oldLease = await queueA.claimNext(); + assert.ok(oldLease?.leaseId); + + await pool.query( + "UPDATE synsec_github_scan_jobs SET lease_until = clock_timestamp() - interval '1 second' WHERE job_id = $1", + [oldLease.jobId], + ); + const newLease = await queueB.claimNext(); + assert.equal(newLease?.jobId, oldLease.jobId); + assert.ok(newLease?.leaseId); + assert.notEqual(newLease.leaseId, oldLease.leaseId); + assert.equal(newLease.attempts, 2); + + await assert.rejects( + queueA.renew(oldLease.jobId, oldLease.leaseId), + /stale, expired, or no longer owned/, + ); + assert.equal(await queueB.complete(newLease.jobId, newLease.leaseId), true); + assert.deepEqual(await queueA.list(), []); + } finally { + await pool.end(); + } +}); + +integration("PostgreSQL installation deltas are serialized transactionally across independent stores", async () => { + const pool = new pg.Pool({ connectionString, max: 12 }); + try { + await migrateSynSecGitHubPostgresInstallationState(pool); + await pool.query("DELETE FROM synsec_github_installations"); + const storeA = new PostgresGitHubInstallationStore(pool); + const storeB = new PostgresGitHubInstallationStore(pool); + await synchronizeGitHubInstallationState(installationEvent(), storeA, Date.parse("2026-08-24T00:00:00.000Z")); + + await Promise.all([ + synchronizeGitHubInstallationState(repositoryDelta("synsec/alpha"), storeA, Date.parse("2026-08-24T00:00:01.000Z")), + synchronizeGitHubInstallationState(repositoryDelta("synsec/beta"), storeB, Date.parse("2026-08-24T00:00:02.000Z")), + ]); + + const record = await storeA.get(4242); + assert.deepEqual(record?.repositories, ["synsec/alpha", "synsec/base", "synsec/beta"]); + } finally { + await pool.end(); + } +}); + +integration("PostgreSQL authorization revocation is immediately shared across store instances", async () => { + const pool = new pg.Pool({ connectionString, max: 8 }); + try { + await migrateSynSecGitHubPostgresInstallationState(pool); + await pool.query("DELETE FROM synsec_github_installations"); + const storeA = new PostgresGitHubInstallationStore(pool); + const storeB = new PostgresGitHubInstallationStore(pool); + await synchronizeGitHubInstallationState(installationEvent(), storeA, Date.parse("2026-08-24T00:00:00.000Z")); + assert.equal(await storeB.isRepositoryAllowed(4242, "synsec/base"), true); + assert.equal(await storeB.isRepositoryAllowed(4242, "synsec/other"), false); + + await synchronizeGitHubInstallationState( + installationEvent({ action: "suspend", suspendedAt: "2026-08-24T00:00:03.000Z", repositories: [] }), + storeA, + Date.parse("2026-08-24T00:00:03.000Z"), + ); + assert.equal(await storeB.isRepositoryAllowed(4242, "synsec/base"), false); + + await synchronizeGitHubInstallationState( + installationEvent({ action: "unsuspend", repositories: [] }), + storeB, + Date.parse("2026-08-24T00:00:04.000Z"), + ); + assert.equal(await storeA.isRepositoryAllowed(4242, "synsec/base"), true); + + await synchronizeGitHubInstallationState( + installationEvent({ action: "deleted", accountLogin: undefined, accountType: undefined, repositorySelection: undefined, repositories: [] }), + storeA, + Date.parse("2026-08-24T00:00:05.000Z"), + ); + assert.equal(await storeB.isRepositoryAllowed(4242, "synsec/base"), false); + } finally { + await pool.end(); + } +}); diff --git a/tests/python-import-incremental-plan.test.mjs b/tests/python-import-incremental-plan.test.mjs new file mode 100644 index 00000000..0e9b74f0 --- /dev/null +++ b/tests/python-import-incremental-plan.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { buildRepositoryIndex } from "@synsec/repository/analysis"; +import { buildIncrementalScanPlan } from "@synsec/repository/incremental-plan"; +import { buildModuleGraph } from "@synsec/repository/module-graph"; + +test("incremental planning expands through conservative absolute Python package imports", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-python-incremental-")); + try { + await mkdir(join(root, "service"), { recursive: true }); + const packageInit = ""; + const db = "def load_user():\n return {}\n"; + const api = "from service.db import load_user\n\ndef handle():\n return load_user()\n"; + const app = "from service.api import handle\n\ndef main():\n return handle()\n"; + await writeFile(join(root, "service", "__init__.py"), packageInit); + await writeFile(join(root, "service", "db.py"), db); + await writeFile(join(root, "service", "api.py"), api); + await writeFile(join(root, "service", "app.py"), app); + + const files = [ + { path: "service/__init__.py", size: Buffer.byteLength(packageInit) }, + { path: "service/db.py", size: Buffer.byteLength(db) }, + { path: "service/api.py", size: Buffer.byteLength(api) }, + { path: "service/app.py", size: Buffer.byteLength(app) }, + ]; + const index = await buildRepositoryIndex(root, files); + const graph = buildModuleGraph(index, files); + const plan = buildIncrementalScanPlan(graph, ["service/db.py"], { + maxDependentDepth: 2, + maxDependents: 10, + }); + + assert.equal(plan.mode, "targeted"); + assert.equal(plan.reason, "targeted-with-bounded-dependents"); + assert.deepEqual(plan.selectedFiles, [ + "service/api.py", + "service/app.py", + "service/db.py", + ]); + assert.deepEqual(plan.dependentFiles, [ + { path: "service/api.py", depth: 1, triggeredBy: "service/db.py" }, + { path: "service/app.py", depth: 2, triggeredBy: "service/db.py" }, + ]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("incremental planning does not expand through ambiguous Python import shapes", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-python-ambiguous-")); + try { + await mkdir(join(root, "service", "db"), { recursive: true }); + const packageInit = ""; + const dbModule = "def load_user():\n return {}\n"; + const dbPackage = "def load_user():\n return {}\n"; + const api = "from service.db import load_user\n\ndef handle():\n return load_user()\n"; + await writeFile(join(root, "service", "__init__.py"), packageInit); + await writeFile(join(root, "service", "db.py"), dbModule); + await writeFile(join(root, "service", "db", "__init__.py"), dbPackage); + await writeFile(join(root, "service", "api.py"), api); + + const files = [ + { path: "service/__init__.py", size: Buffer.byteLength(packageInit) }, + { path: "service/db.py", size: Buffer.byteLength(dbModule) }, + { path: "service/db/__init__.py", size: Buffer.byteLength(dbPackage) }, + { path: "service/api.py", size: Buffer.byteLength(api) }, + ]; + const index = await buildRepositoryIndex(root, files); + const graph = buildModuleGraph(index, files); + const plan = buildIncrementalScanPlan(graph, ["service/db.py"], { + maxDependentDepth: 2, + maxDependents: 10, + }); + + assert.equal(graph.resolvedEdgeCount, 0); + assert.equal(plan.mode, "targeted"); + assert.deepEqual(plan.selectedFiles, ["service/db.py"]); + assert.deepEqual(plan.dependentFiles, []); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/release-readiness.test.mjs b/tests/release-readiness.test.mjs new file mode 100644 index 00000000..5cffceba --- /dev/null +++ b/tests/release-readiness.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { assessReleaseReadiness } from "../scripts/release-readiness.mjs"; + +const workflow = `name: CI +jobs: + build-and-test: + strategy: + matrix: + node: [20, 24] + steps: + - run: npm run build + - run: npm run typecheck + - run: npm test + postgres-shared-state: + services: + postgres: + image: postgres:16-alpine + steps: + - run: node --test tests/postgres-shared-state-conformance.test.mjs tests/postgres-hosted-installation-ownership.test.mjs + - run: node --test tests/oci-scanner-sandbox.test.mjs +`; + +async function fixture({ lockfile = false, packageOverrides = {}, workflowText = workflow } = {}) { + const root = await mkdtemp(join(tmpdir(), "synsec-release-readiness-")); + await mkdir(join(root, ".github/workflows"), { recursive: true }); + await mkdir(join(root, "docs"), { recursive: true }); + await writeFile(join(root, "package.json"), JSON.stringify({ + name: "synsec", + version: "0.2.0", + private: true, + engines: { node: ">=20" }, + ...packageOverrides, + })); + await writeFile(join(root, ".github/workflows/ci.yml"), workflowText); + for (const name of [ + "GITHUB_APP_UPGRADES.md", + "GITHUB_APP_SERVICE_MAINTENANCE.md", + "HOSTED_INSTALLATION_OWNERSHIP.md", + "HOSTED_INSTALLATION_REVERIFICATION_SWEEPS.md", + ]) await writeFile(join(root, "docs", name), "# fixture\n"); + if (lockfile) await writeFile(join(root, "package-lock.json"), "{}\n"); + return root; +} + +test("release readiness reports missing lockfile as an explicit blocker rather than fabricated readiness", async () => { + const root = await fixture(); + const report = await assessReleaseReadiness(root); + assert.equal(report.ready, false); + assert.deepEqual(report.errors, []); + assert.deepEqual(report.blockers.map(({ code }) => code), ["dependency-lockfile-missing"]); + assert.equal(report.evidence.dependencyLockfile, false); +}); + +test("release readiness treats malformed release invariants as hard errors", async () => { + const root = await fixture({ + packageOverrides: { version: "next", private: false, engines: { node: ">=18" } }, + }); + const report = await assessReleaseReadiness(root); + assert.equal(report.ready, false); + assert.deepEqual(new Set(report.errors.map(({ code }) => code)), new Set([ + "package-version-invalid", + "node-engine-policy-mismatch", + "root-package-publishable", + ])); +}); + +test("release readiness rejects CI that drops real backend or sandbox validation", async () => { + const root = await fixture({ workflowText: "jobs:\n build: npm test\n" }); + const report = await assessReleaseReadiness(root); + assert.equal(report.ready, false); + assert.ok(report.errors.some(({ code }) => code === "ci-coverage-incomplete")); + assert.equal(report.evidence.postgresConformance, false); + assert.equal(report.evidence.enforcedOciIsolation, false); +}); + +test("committed lockfile still blocks readiness until CI enforces npm ci", async () => { + const root = await fixture({ lockfile: true }); + const report = await assessReleaseReadiness(root); + assert.equal(report.ready, false); + assert.deepEqual(report.errors, []); + assert.deepEqual(report.blockers.map(({ code }) => code), ["ci-not-using-lockfile"]); +}); + +test("release readiness becomes ready only with lockfile and lockfile-enforced CI", async () => { + const root = await fixture({ lockfile: true, workflowText: `${workflow}\n# npm ci\n` }); + const report = await assessReleaseReadiness(root); + assert.equal(report.ready, true); + assert.deepEqual(report.errors, []); + assert.deepEqual(report.blockers, []); + assert.equal(report.evidence.nodeMatrix, true); + assert.equal(report.evidence.postgresConformance, true); + assert.equal(report.evidence.enforcedOciIsolation, true); + assert.equal(report.evidence.dependencyLockfile, true); +}); diff --git a/tests/remediation-verification-output.test.mjs b/tests/remediation-verification-output.test.mjs new file mode 100644 index 00000000..216755da --- /dev/null +++ b/tests/remediation-verification-output.test.mjs @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { chmod, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { writeRemediationVerification } from "@synsec/lifecycle"; + +const verification = { + schemaVersion: 1, + generatedAt: "2026-08-22T21:30:00.000Z", + beforeReportId: "before", + afterReportId: "after", + items: [{ + fingerprint: "finding-1", + title: "Sensitive finding title", + status: "inconclusive", + reasons: ["The detecting scanner did not rerun."], + }], + newFindings: [], + summary: { fixed: 0, persisting: 0, inconclusive: 1, missingBaseline: 0, newFindings: 0 }, +}; + +test("remediation verification output is private, atomic-shaped, and repairs permissive overwrite modes", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-verification-output-")); + const path = join(root, "verification.json"); + try { + await writeFile(path, "old\n", { encoding: "utf8", mode: 0o644 }); + if (process.platform !== "win32") await chmod(path, 0o644); + + await writeRemediationVerification(path, verification); + assert.deepEqual(JSON.parse(await readFile(path, "utf8")), verification); + if (process.platform !== "win32") assert.equal((await stat(path)).mode & 0o777, 0o600); + + const leftovers = (await readdir(root)).filter((name) => name.includes(".tmp")); + assert.deepEqual(leftovers, []); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/remediation-workflow.test.mjs b/tests/remediation-workflow.test.mjs new file mode 100644 index 00000000..04e877f9 --- /dev/null +++ b/tests/remediation-workflow.test.mjs @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + approveRemediationProposal, + authorizeRemediationExecution, + createRemediationProposal, +} from "@synsec/workflows/remediation"; +import { getWorkflow } from "@synsec/workflows"; + +const workflow = getWorkflow("repository-review"); +const head = "a".repeat(40); + +function proposal() { + return createRemediationProposal(workflow, { + targetCommitSha: head, + findingIds: ["finding-b", "finding-a"], + summary: "Harden input handling and add a regression guard.", + changes: [ + { + path: "src/handler.ts", + operation: "modify", + patch: "@@ -1 +1 @@\n-old\n+new\n", + }, + { + path: "tests/handler.test.ts", + operation: "create", + patch: "@@ -0,0 +1 @@\n+test('guard', () => {});\n", + }, + ], + }); +} + +test("remediation proposal is exact-commit, bounded, and approval-required", () => { + const value = proposal(); + assert.equal(value.targetCommitSha, head); + assert.equal(value.requiresApproval, true); + assert.equal(value.externalNetworkAssessment, "forbidden"); + assert.deepEqual(value.findingIds, ["finding-a", "finding-b"]); + assert.match(value.proposalId, /^[a-f0-9]{64}$/); + assert.equal(value.changes.length, 2); + assert.match(value.changes[0].patchSha256, /^[a-f0-9]{64}$/); +}); + +test("approval binds one exact patch set and current repository head", () => { + const value = proposal(); + const approval = approveRemediationProposal(value, { + proposalId: value.proposalId, + approvedBy: "security-reviewer", + approvedAt: "2026-08-22T20:30:00.000Z", + }); + const execution = authorizeRemediationExecution({ proposal: value, approval, currentHeadSha: head }); + assert.equal(execution.targetCommitSha, head); + assert.equal(execution.approval.approvedBy, "security-reviewer"); +}); + +test("remediation rejects path escape, git metadata, duplicates, and deletes", () => { + const base = { + targetCommitSha: head, + findingIds: ["finding-a"], + summary: "Safe fix", + }; + assert.throws(() => createRemediationProposal(workflow, { + ...base, + changes: [{ path: "../outside", operation: "modify", patch: "x" }], + }), /stay inside/); + assert.throws(() => createRemediationProposal(workflow, { + ...base, + changes: [{ path: ".git/config", operation: "modify", patch: "x" }], + }), /may not address .git/); + assert.throws(() => createRemediationProposal(workflow, { + ...base, + changes: [ + { path: "src/a.ts", operation: "modify", patch: "a" }, + { path: "src/./a.ts", operation: "create", patch: "b" }, + ], + }), /duplicate path/); + assert.throws(() => createRemediationProposal(workflow, { + ...base, + changes: [{ path: "src/a.ts", operation: "delete", patch: "x" }], + }), /only create and modify/); +}); + +test("remediation approval fails closed on proposal or patch tampering", () => { + const value = proposal(); + assert.throws(() => approveRemediationProposal(value, { + proposalId: "b".repeat(64), + approvedBy: "reviewer", + }), /does not match/); + + const tampered = structuredClone(value); + tampered.changes[0].patch = "@@ -1 +1 @@\n-old\n+attacker-change\n"; + assert.throws(() => approveRemediationProposal(tampered, { + proposalId: tampered.proposalId, + approvedBy: "reviewer", + }), /patch contents no longer match/); +}); + +test("remediation authorization rejects repository head movement", () => { + const value = proposal(); + const approval = approveRemediationProposal(value, { + proposalId: value.proposalId, + approvedBy: "reviewer", + approvedAt: "2026-08-22T20:30:00.000Z", + }); + assert.throws(() => authorizeRemediationExecution({ + proposal: value, + approval, + currentHeadSha: "b".repeat(40), + }), /head moved/); +}); + +test("workflows without remediation capability cannot create proposals", () => { + const reportWorkflow = getWorkflow("report-writing"); + assert.throws(() => createRemediationProposal(reportWorkflow, { + targetCommitSha: head, + findingIds: ["finding-a"], + summary: "not allowed", + changes: [{ path: "src/a.ts", operation: "modify", patch: "x" }], + }), /does not permit capabilities/); +}); diff --git a/tests/report-history-html.test.mjs b/tests/report-history-html.test.mjs new file mode 100644 index 00000000..3a6106b7 --- /dev/null +++ b/tests/report-history-html.test.mjs @@ -0,0 +1,130 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { renderHistoryHtml, writeHistoryHtml, writeHistoryHtmlFromStore } from "../packages/report/dist/history-html.js"; + +function history() { + return { + schemaVersion: 1, + scoreDelta: 8, + findingCountDelta: -1, + points: [ + { + reportId: "old", + generatedAt: "2026-08-20T12:00:00.000Z", + commitSha: "abcdef1234567890", + securityScore: 82, + findingCount: 2, + summary: { critical: 0, high: 1, medium: 1, low: 0, info: 0, unknown: 0 }, + newCount: 2, + fixedCount: 0, + persistingCount: 0, + }, + { + reportId: "new", + generatedAt: "2026-08-22T12:00:00.000Z", + commitSha: "1234567890abcdef", + securityScore: 90, + findingCount: 1, + summary: { critical: 0, high: 0, medium: 1, low: 0, info: 0, unknown: 0 }, + newCount: 0, + fixedCount: 1, + persistingCount: 1, + }, + ], + findings: [ + { + fingerprint: "fp-1", + title: "Unsafe ", + highestSeverity: "medium", + firstSeenAt: "2026-08-20T12:00:00.000Z", + lastSeenAt: "2026-08-22T12:00:00.000Z", + occurrenceCount: 2, + presentInLatest: true, + }, + { + fingerprint: "fp-fixed", + title: "Fixed finding", + highestSeverity: "high", + firstSeenAt: "2026-08-20T12:00:00.000Z", + lastSeenAt: "2026-08-20T12:00:00.000Z", + occurrenceCount: 1, + presentInLatest: false, + }, + ], + }; +} + +test("renderHistoryHtml creates a self-contained trend dashboard", () => { + const html = renderHistoryHtml(history(), { title: "Repository security" }); + assert.match(html, //); + assert.match(html, /Repository security/); + assert.match(html, /90\/100/); + assert.match(html, /\+8/); + assert.match(html, /-1/); + assert.match(html, /Security score trend/); + assert.match(html, /1234567890ab/); + assert.equal(html.includes("Fixed finding"), false); +}); + +test("renderHistoryHtml escapes finding and title content", () => { + const html = renderHistoryHtml(history(), { title: '' }); + assert.equal(html.includes(""), false); + assert.match(html, /Unsafe <script>alert\(1\)<\/script>/); + assert.equal(html.includes(''), false); + assert.match(html, /<img src=x onerror="x">/); +}); + +test("renderHistoryHtml handles empty history without malformed metrics", () => { + const html = renderHistoryHtml({ schemaVersion: 1, points: [], findings: [], scoreDelta: 0, findingCountDelta: 0 }); + assert.match(html, /No scan history is available yet/); + assert.match(html, /Active findings<\/span>0/); + assert.equal(html.includes("NaN"), false); +}); + +test("writeHistoryHtmlFromStore renders a trend-safe store to a restrictive local file", async () => { + const root = await mkdtemp(join(tmpdir(), "synsec-history-html-")); + try { + const storePath = join(root, "history.json"); + const outputPath = join(root, "dashboard", "index.html"); + await writeFile(storePath, JSON.stringify({ + schemaVersion: 1, + reports: [{ + reportId: "stored", + generatedAt: "2026-08-22T12:00:00.000Z", + target: { commitSha: "abcdef1234567890", branch: "main" }, + securityScore: 94, + findingCount: 1, + summary: { critical: 0, high: 0, medium: 1, low: 0, info: 0, unknown: 0 }, + findings: [{ fingerprint: "fp", primary: { title: "Stored finding", severity: "medium" } }], + }], + })); + + const built = await writeHistoryHtmlFromStore(storePath, outputPath, { title: "Stored history" }); + const html = await readFile(outputPath, "utf8"); + const info = await stat(outputPath); + assert.equal(built.points.length, 1); + assert.match(html, /Stored history/); + assert.match(html, /94\/100/); + if (process.platform !== "win32") assert.equal(info.mode & 0o777, 0o600); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("history dashboard writer repairs permissive existing file modes where supported", async () => { + if (process.platform === "win32") return; + const root = await mkdtemp(join(tmpdir(), "synsec-history-html-mode-")); + const outputPath = join(root, "history.html"); + try { + await writeFile(outputPath, "old\n", { encoding: "utf8", mode: 0o644 }); + await chmod(outputPath, 0o644); + await writeHistoryHtml(outputPath, history()); + assert.equal((await stat(outputPath)).mode & 0o777, 0o600); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/report-history-store.test.mjs b/tests/report-history-store.test.mjs new file mode 100644 index 00000000..3b0811a0 --- /dev/null +++ b/tests/report-history-store.test.mjs @@ -0,0 +1,94 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + appendHistoryReport, + buildHistoryFromStore, + readHistoryStore, + snapshotReport, +} from "../packages/report/dist/history-store.js"; + +function finding(fingerprint, title, severity = "medium") { + return { + fingerprint, + primary: { + id: fingerprint, + title, + category: "sast", + severity, + confidence: 0.9, + scanner: { name: "test" }, + description: "sensitive source context that must not be persisted in history", + location: { path: "src/app.ts", startLine: 12, snippet: "const secret = process.env.TOKEN" }, + }, + duplicates: [], + sources: [{ name: "test" }], + }; +} + +function report(id, generatedAt, findings) { + const summary = { critical: 0, high: 0, medium: 0, low: 0, info: 0, unknown: 0 }; + for (const item of findings) summary[item.primary.severity] += 1; + return { + schemaVersion: "1.0", + reportId: id, + generatedAt, + toolVersion: "0.2.0", + target: { path: ".", commitSha: id, branch: "main", repositoryUrl: "https://example.invalid/repo" }, + scanners: [], + rawFindingCount: findings.length, + findingCount: findings.length, + summary, + securityScore: 100 - findings.length * 10, + findings, + }; +} + +test("snapshotReport retains only trend-safe finding metadata", () => { + const snapshot = snapshotReport(report("r1", "2026-08-20T12:00:00.000Z", [finding("a", "Finding A", "high")])); + const serialized = JSON.stringify(snapshot); + assert.equal(serialized.includes("sensitive source context"), false); + assert.equal(serialized.includes("process.env.TOKEN"), false); + assert.equal(serialized.includes("example.invalid"), false); + assert.deepEqual(snapshot.findings[0], { + fingerprint: "a", + primary: { title: "Finding A", severity: "high" }, + }); +}); + +test("history store is bounded, ordered, idempotent, and trend-compatible", async () => { + const directory = await mkdtemp(join(tmpdir(), "synsec-history-")); + const path = join(directory, "history.json"); + const r1 = report("r1", "2026-08-20T12:00:00.000Z", [finding("a", "A")]); + const r2 = report("r2", "2026-08-21T12:00:00.000Z", [finding("a", "A"), finding("b", "B", "high")]); + const r3 = report("r3", "2026-08-22T12:00:00.000Z", [finding("b", "B", "high")]); + + await appendHistoryReport(path, r2, { maxReports: 2 }); + await appendHistoryReport(path, r1, { maxReports: 2 }); + await appendHistoryReport(path, r3, { maxReports: 2 }); + await appendHistoryReport(path, r3, { maxReports: 2 }); + + const store = await readHistoryStore(path); + assert.deepEqual(store.reports.map((item) => item.reportId), ["r2", "r3"]); + const history = await buildHistoryFromStore(path); + assert.deepEqual(history.points.map((item) => item.reportId), ["r2", "r3"]); + assert.equal(history.points[1].fixedCount, 1); + assert.equal(history.points[1].persistingCount, 1); + + const mode = (await import("node:fs/promises")).stat(path).then((stat) => stat.mode & 0o777); + assert.equal(await mode, 0o600); +}); + +test("history store rejects invalid retention and corrupt content", async () => { + const directory = await mkdtemp(join(tmpdir(), "synsec-history-invalid-")); + const path = join(directory, "history.json"); + const r1 = report("r1", "2026-08-20T12:00:00.000Z", []); + await assert.rejects(() => appendHistoryReport(path, r1, { maxReports: 0 }), /between 1 and/); + + await (await import("node:fs/promises")).writeFile(path, "{broken", "utf8"); + await assert.rejects(() => readHistoryStore(path), /not valid JSON/); + assert.equal((await readFile(path, "utf8")), "{broken"); +}); diff --git a/tests/report-history.test.mjs b/tests/report-history.test.mjs new file mode 100644 index 00000000..28938ed3 --- /dev/null +++ b/tests/report-history.test.mjs @@ -0,0 +1,97 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { buildReportHistory } from "../packages/report/dist/history.js"; + +function finding(fingerprint, title, severity = "medium") { + return { + fingerprint, + primary: { + id: fingerprint, + title, + category: "sast", + severity, + confidence: 0.9, + scanner: { name: "test" }, + }, + duplicates: [], + sources: [{ name: "test" }], + }; +} + +function report({ id, at, score, findings, sha = id }) { + const summary = { critical: 0, high: 0, medium: 0, low: 0, info: 0, unknown: 0 }; + for (const item of findings) summary[item.primary.severity] += 1; + return { + schemaVersion: "1.0", + reportId: id, + generatedAt: at, + toolVersion: "0.2.0", + target: { path: ".", commitSha: sha, branch: "main" }, + scanners: [], + rawFindingCount: findings.length, + findingCount: findings.length, + summary, + securityScore: score, + findings, + }; +} + +test("buildReportHistory sorts reports and derives finding churn", () => { + const first = report({ + id: "r1", + at: "2026-08-20T12:00:00.000Z", + score: 70, + findings: [finding("a", "Finding A", "high"), finding("b", "Finding B", "medium")], + }); + const second = report({ + id: "r2", + at: "2026-08-21T12:00:00.000Z", + score: 78, + findings: [finding("a", "Finding A", "high"), finding("c", "Finding C", "low")], + }); + const third = report({ + id: "r3", + at: "2026-08-22T12:00:00.000Z", + score: 90, + findings: [finding("c", "Finding C escalated", "medium")], + }); + + const history = buildReportHistory([third, first, second]); + assert.deepEqual(history.points.map((point) => point.reportId), ["r1", "r2", "r3"]); + assert.deepEqual( + history.points.map(({ newCount, fixedCount, persistingCount }) => ({ newCount, fixedCount, persistingCount })), + [ + { newCount: 2, fixedCount: 0, persistingCount: 0 }, + { newCount: 1, fixedCount: 1, persistingCount: 1 }, + { newCount: 0, fixedCount: 1, persistingCount: 1 }, + ], + ); + assert.equal(history.scoreDelta, 20); + assert.equal(history.findingCountDelta, -1); + + const a = history.findings.find((item) => item.fingerprint === "a"); + const c = history.findings.find((item) => item.fingerprint === "c"); + assert.equal(a.occurrenceCount, 2); + assert.equal(a.presentInLatest, false); + assert.equal(c.occurrenceCount, 2); + assert.equal(c.presentInLatest, true); + assert.equal(c.highestSeverity, "medium"); + assert.equal(c.title, "Finding C escalated"); +}); + +test("empty history is stable and machine-readable", () => { + assert.deepEqual(buildReportHistory([]), { + schemaVersion: 1, + points: [], + findings: [], + scoreDelta: 0, + findingCountDelta: 0, + }); +}); + +test("history rejects duplicate report ids and invalid timestamps", () => { + const base = report({ id: "same", at: "2026-08-22T12:00:00.000Z", score: 100, findings: [] }); + assert.throws(() => buildReportHistory([base, { ...base, generatedAt: "2026-08-23T12:00:00.000Z" }]), /Duplicate report id/); + assert.throws(() => buildReportHistory([{ ...base, reportId: "invalid-time", generatedAt: "not-a-date" }]), /invalid generatedAt/); +}); diff --git a/tests/report-sbom-html.test.mjs b/tests/report-sbom-html.test.mjs new file mode 100644 index 00000000..cf89d35c --- /dev/null +++ b/tests/report-sbom-html.test.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { buildReport } from "@synsec/report"; +import { buildSbomView, renderSbomHtml, writeSbomHtml } from "@synsec/report/sbom-html"; + +function report() { + return buildReport({ + target: { path: "/repo", commitSha: "0123456789abcdef0123456789abcdef01234567" }, + scans: [{ + scanner: "syft", + startedAt: "2026-08-22T19:00:00.000Z", + completedAt: "2026-08-22T19:00:01.000Z", + target: { path: "/repo" }, + findings: [], + diagnostics: ["must not enter the dependency dashboard"], + artifacts: [{ + type: "sbom", + format: "syft-json", + producer: "Syft ", + generatedAt: "2026-08-22T19:00:01.000Z", + packageCount: 2, + packages: [{ + name: "pkg