Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
name: CI

on:
# Pull requests only. A merge commit on main re-tests the exact tree the PR run already
# covered, so a `push: [main]` trigger doubled every change's CI for no new signal — a
# measured ~35 job-minutes per change, with both runs drawing on the same runner pool.
# Coverage for the paths a PR does not take:
# - direct pushes to main (release cuts) -> `workflow_dispatch`, plus the nightly suite
# (`scripts/nightly-tests.ps1`, 22:00 daily) which runs all four suites against main.
# main is gated unconditionally: every commit that lands on the default branch gets a full CI
# run, no matter how it got there. A merge commit does re-test the tree its PR run already
# covered (~35 job-minutes per change, both runs drawing on the same runner pool) — that
# duplication is accepted deliberately, because the alternative leaves direct pushes and
# release cuts resting on a `workflow_dispatch` nobody is forced to trigger. The local nightly
# suite is not a substitute: it runs against its currently checked-out tree and never pulls,
# so it is not an origin/main status check.
# - CodeQL keeps its own `push: [main]` trigger: only a run on the default branch
# updates the repository's code-scanning dashboard.
pull_request:
branches: [main, master]
push:
branches: [main]
workflow_dispatch:

jobs:
Expand Down Expand Up @@ -84,11 +87,20 @@ jobs:
# [ExcludeFromCodeCoverage]-attributed code from the denominator) — the same filter
# the local measurement uses. Previously CI measured without it, so the enforced
# number and the documented measurement procedure disagreed.
run: dotnet test --configuration Release --no-build --settings coverage.runsettings --logger "trx;LogFileName=test_results.trx" --collect:"XPlat Code Coverage" --results-directory TestResults
# The trx logger gets no LogFileName on purpose: every test project in the solution wrote
# the same test_results.trx into the shared results directory, so each one overwrote its
# predecessor and the uploaded artifact held a single project's results. VSTest's default
# name is per-run unique, which keeps all of them.
run: dotnet test --configuration Release --no-build --settings coverage.runsettings --logger trx --collect:"XPlat Code Coverage" --results-directory TestResults

- name: Generate coverage report
if: always()
run: dotnet reportgenerator -reports:"TestResults/**/coverage.cobertura.xml" -targetdir:coverage-report -reporttypes:"Html;Cobertura;TextSummary"
# -assemblyfilters mirrors the documented local measurement exactly. Without it the test
# scaffolding itself (NodePilot.TestCommons, NodePilot.LoadTests) counted as production
# code, so the gated number and the procedure in docs/claude-reference.md disagreed.
# The two +np / +nodepilot-mcp entries are not optional: the CLI assembly is named `np`
# and the MCP server `nodepilot-mcp`, so a plain NodePilot.* filter drops both.
run: dotnet reportgenerator -reports:"TestResults/**/coverage.cobertura.xml" -targetdir:coverage-report -reporttypes:"Html;Cobertura;TextSummary" "-assemblyfilters:+NodePilot.*;+np;+nodepilot-mcp;-NodePilot.*.Tests;-NodePilot.TestCommons;-NodePilot.LoadTests"

- name: Enforce minimum coverage
shell: pwsh
Expand Down Expand Up @@ -126,7 +138,7 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: backend-test-results
path: '**/test_results.trx'
path: '**/*.trx'

- name: Upload coverage report
if: always()
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,12 +355,12 @@ Initial-Admin: erster Login bei leerer DB (One-Shot-Token `admin-setup.token`).

## Security

- **Session:** absolute Lebensdauer **8h** (`Authentication:SessionAbsoluteLifetimeHours`, default 8; `AuthController.TokenLifetime`). Refresh verlängert die absolute Grenze **nicht**. `jti`-Revocation. Key aus `Jwt:Key` oder auto-generiertes `jwt-secret.key`.
- **Session:** absolute Lebensdauer **8h** (`Authentication:SessionAbsoluteLifetimeHours`, default 8; `AuthSessionIssuer`). Refresh verlängert die absolute Grenze **nicht**. `jti`-Revocation. Key aus `Jwt:Key` oder auto-generiertes `jwt-secret.key`.
- **Auth-Pfade:** Local-BCrypt (`Authentication:LocalLoginMode`, Produktionsdefault **`BreakGlassOnly`** — nur explizit markierte Notfallkonten; `Enabled`/`Disabled` möglich) + LDAP (`Authentication:Ldap:Enabled`) + Windows-Negotiate (`Authentication:Windows:Enabled`) + OIDC (`Authentication:Oidc:Enabled`, release-gated, + SCIM-Controller). Alle konvergieren auf JWT-Cookie + CSRF-Token. Siehe `docs/ldap-windows-sso.md`.
- **External Trigger:** `X-Api-Key` wird bevorzugt gegen SHA-256-Hashes unter `ExternalTrigger:Keys:<id>` geprüft; jeder Eintrag hat eine GUID-only `AllowedWorkflowIds`-Liste. Die komplette `Keys`-Map kommt atomar aus dem höchstprioren Provider, der sie deklariert (`Keys: {}` widerruft alle niedrigeren Keys); auch Scope-Arrays sind provider-atomar (`[]` = deny-all). Zusätzlich braucht der Workflow einen aktiven `manualTrigger`. Legacy-`ApiKey` ist ohne eigene `AllowedWorkflowIds`-Liste inert. Idempotency wird per kanonischer Integration-ID + Key-Fingerprint + Workflow domain-separiert; die DB speichert nur den Digest.
- **Rate-Limiting:** login 50/Min, refresh 20/Min, webhook 60/Min, trigger 30/Min, ai-generate 20/Min, audit 60/Min, backup 10/Min (per-IP, Sliding-Window).
- **Output-Redaction:** `OutputRedactor` maskiert Secrets. Immer aktiv. Custom-Patterns via `Logging:Redaction:Patterns`.
- **Localhost-Bypass:** ohne Credentials läuft in-process. **Produkt-Feature, kein Guard einziehen.**
- **Localhost-Bypass / Operator-Trust:** ohne Credentials läuft in-process unter der NodePilot-Service-Identität. `Operator` ist bewusst ein vertrauenswürdiger Automation-Author und darf solchen Workflow-Code publizieren/ausführen. Folder-RBAC ist keine Code-Sandbox. **Produkt-Feature, keinen Require-Target-Guard einziehen.**
- **Security-Headers (Non-Dev):** HSTS, CSP, X-Frame-Options=DENY, nosniff, Referrer-Policy.
- **SignalR-Auth:** httpOnly `np_auth`-Cookie wird beim WebSocket-Upgrade automatisch mitgeschickt (nur `/hubs/`); kein `?access_token=`-Querystring.
- **REST-API-Proxy:** `RestApi:Proxy:Enabled` (default `false`). Per-Step-Override via `proxyMode`.
Expand Down
19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -684,7 +684,7 @@ NodePilot ships **27 built-in activities** in two scopes — *Remote* (executed

| Type | Description | Key Config |
|---|---|---|
| `runScript` | Execute a PowerShell script locally when no target/localhost is selected, or through NodePilot's WinRM wrapper when a non-local target is selected. With no target the script runs on the API host and may open its own WinRM session (`Invoke-Command`/`New-PSSession`, SCOrch-style self-managed remoting) — at the cost of NodePilot's managed session pool, credential store and machine audit. Auto-captures script-scope variables as `param.*` outputs. Fails only on a terminating PowerShell error (`throw`/`Write-Error`) — an `exit N` does not fail the step unless `successExitCodes` is set; `isolated: true` runs it in its own Windows Job Object process. | `script`, `engine` (`auto`/`pwsh`/`powershell`), `timeoutSeconds`, `successExitCodes`, `isolated`, `memoryLimitMb`, `maxProcesses` |
| `runScript` | Execute a PowerShell script locally when no target/localhost is selected, or through NodePilot's WinRM wrapper when a non-local target is selected. With no target the script runs on the API host **as the NodePilot service identity** and may open its own WinRM session (`Invoke-Command`/`New-PSSession`, SCOrch-style self-managed remoting) — at the cost of NodePilot's managed session pool, credential store and machine audit. Operators are intentionally trusted automation authors and may publish/run this code. Auto-captures script-scope variables as `param.*` outputs. Fails only on a terminating PowerShell error (`throw`/`Write-Error`) — an `exit N` does not fail the step unless `successExitCodes` is set; `isolated: true` runs it in its own Windows Job Object process. | `script`, `engine` (`auto`/`pwsh`/`powershell`), `timeoutSeconds`, `successExitCodes`, `isolated`, `memoryLimitMb`, `maxProcesses` |
| `fileOperation` | Copy / move / delete / test-exists / rename — **files only** (asserts `-PathType Leaf`) | `operation`, `path`, `destination`, `newName` |
| `folderOperation` | Copy / move / delete / test-exists / list / create / rename — **folders only** (asserts `-PathType Container`) | `operation`, `path`, `destination`, `newName` |
| `textFileEdit` | Line-oriented text edit — append / prepend / insert / delete / replace / replaceLine — BOM-aware encoding, atomic write, optional backup, dry-run | `operation`, `path`, `content`, `matchPattern`, `replace`, `lineNumber` |
Expand Down Expand Up @@ -993,7 +993,7 @@ HTTPS is mandatory by default. `--allow-insecure` is an explicit development-onl
for HTTP loopback URLs and must be supplied on every command that uses such a profile;
it never permits plaintext connections to remote hosts.

**Tokens** are stored DPAPI-encrypted (`CurrentUser` scope) under `%APPDATA%\NodePilot\session-<profile>.dat`. A `TokenRefreshHandler` `DelegatingHandler` transparently refreshes on `401` and replays the original request.
**Tokens** are stored DPAPI-encrypted (`CurrentUser` scope) under `%APPDATA%\NodePilot\session-<profile>.dat`, including the server-issued absolute expiry. A `TokenRefreshHandler` rotates a still-valid token shortly before that deadline. CLI and MCP coordinate through the same origin-bound file lease, so concurrent processes perform one refresh and losers reload the winner's token; atomic same-directory replacement keeps each stored session generation complete. A transient proactive-refresh failure enters a token-bound 15-second cooldown while requests continue with the still-valid token. The rotated token is used by all future requests, and a new login is required after absolute expiry. Rotation never extends the server-side session lifetime.

---

Expand Down Expand Up @@ -1074,8 +1074,8 @@ During a runtime PostgreSQL or SQL Server outage, NodePilot stays up: APIs fail
- **Rate limiting** — per-IP **sliding window** (IPv4 partitioned by /32, IPv6 by /64): login **50/min**, refresh **20/min**, webhook **60/min**, external trigger **30/min**, AI generate **20/min**.
- **SSRF guard** — `restApi` blocks RFC 1918 / loopback / metadata IPs (opt-in for prod), re-validates on every redirect, strips auth headers cross-origin.
- **Localhost bypass** — `localhost` / `127.0.0.1` / `::1` without credentials runs in-process, skipping WinRM. Documented product feature.
- **Roles** — Admin (full), Operator (run + manage machines/credentials), Viewer (read-only).
- **SignalR auth** — JWT via `?access_token=` (only for `/hubs/` paths).
- **Roles** — Admin (full), Operator (trusted automation author: create/edit/publish/run workflows, manage machines/credentials, and intentionally execute local activities as the NodePilot service identity), Viewer (read-only). Folder RBAC governs workflow access; it is not a sandbox around Operator-authored code.
- **SignalR auth** — the browser's httpOnly `np_auth` cookie is sent automatically during the `/hubs/` WebSocket upgrade; no JWT query string is used.

### Hardening flags (shipped on — relaxed in Development)

Expand Down Expand Up @@ -1130,7 +1130,7 @@ What you get:
- **Direct Kestrel HTTPS** — cert from `LocalMachine\My` by thumbprint, **no IIS / reverse proxy**, SPA + API on one origin
- **Install-dir / data-dir split** — binaries in `C:\Program Files\NodePilot` (Read), mutable state in `C:\ProgramData\NodePilot` (Modify)
- **Cert private-key ACL grant** to the gMSA, firewall rule, health-check probe before unblocking
- **In-place upgrades with auto-rollback** via `deploy/Update-NodePilot.ps1`
- **In-place upgrades with auto-rollback** via `deploy/Update-NodePilot.ps1` (legacy workflow-history encryption is an explicit post-health-check cutover so rollback remains safe)

### Prerequisites (one-time)

Expand Down Expand Up @@ -1319,9 +1319,10 @@ dotnet test tests/NodePilot.Cli.Tests
- Backend DB tests use **SQLite in-memory** (`DataSource=:memory:`) — only as a test backend; the production app does not support SQLite.

**CI pipeline** ([`.github/workflows/ci.yml`](.github/workflows/ci.yml)) runs five jobs on every pull
request — and on demand via `workflow_dispatch`. There is deliberately no `push` trigger: a merge
commit re-tests the tree the PR run already covered. Direct pushes to `main` (release cuts) are
covered by the nightly suite (`scripts/nightly-tests.ps1`).
request — and on demand via `workflow_dispatch`. There is deliberately no `push` trigger: repository
owner-controlled release cuts use the manual workflow when another run is wanted. The local nightly
suite (`scripts/nightly-tests.ps1`) is a safety net for the **currently checked-out working tree**; it
does not run `git pull` and therefore does not claim to validate the latest `origin/main`.

1. **Backend** *(Windows)* — restore, build Release, `dotnet test --collect:"XPlat Code Coverage"`, ReportGenerator, **85 % line / 70 % branch coverage** gate
2. **Frontend** *(Ubuntu)* — `npm ci`, lint, type-check + build, vitest with coverage thresholds
Expand All @@ -1342,7 +1343,7 @@ The full OpenAPI spec is served at `GET /openapi/v1.json`; Swagger UI at `GET /s
| Workflows | `GET/POST/PUT/DELETE /api/workflows`, `/{id}/execute`, `/{id}/enable`, `/{id}/disable`, `/{id}/duplicate`, `/{id}/cancel-all` |
| Edit lock | `POST /{id}/lock`, `POST /{id}/unlock`, `POST /{id}/publish` *(atomic save+enable+unlock)*, `POST /{id}/force-unlock` *(Admin)* |
| Versions | `GET /{id}/versions`, `GET /{id}/versions/{v}`, `POST /{id}/rollback/{v}` |
| Import / Export | `GET /api/workflows/export`, `GET /{id}/export`, `POST /api/workflows/import?folderId={guid}`, `POST /api/workflows/import-scorch?folderId={guid}` *(SCOrch XML, ≤50 MiB; `folderId` optional → Root, RBAC = Edit on the target folder)* |
| Import / Export | `GET /api/workflows/export`, `GET /{id}/export`, `POST /api/workflows/import?folderId={guid}`, `POST /api/workflows/import-scorch?folderId={guid}` *(SCOrch XML, ≤50 MiB and ≤500 combined workflows/variables; `folderId` optional → Root, RBAC = Edit on the target folder; Admins and Operators both import workflows and their global variables — an existing variable of the same name is never overwritten)* |
| Executions | `GET /api/executions`, `GET /{id}/steps`, `POST /{id}/cancel`, `POST /{id}/retry`, `POST /{id}/resume` |
| Designer telemetry | `GET /api/workflows/{id}/step-health` (sparkline), `GET /api/workflows/{id}/step-stats?windowDays=30` |
| Machines | `GET/POST/PUT/DELETE /api/machines`, `POST /{id}/test` |
Expand Down
12 changes: 12 additions & 0 deletions docs/ai-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,10 @@ beim Startup eine Hardening-Warnung in den Logs aus.
sind nur für `Admin` und `Operator` zugänglich. Der Chat-Assistent (`POST /api/ai/chat`) ist für alle Rollen
lesbar (Erklären), aber das **Anwenden** von Vorschlägen bleibt Admin/Operator. Viewer sehen die Schreib-KI-Buttons im UI nicht
(der Script-Editor-KI-Button wird für Viewer zusätzlich zum LLM-Gating ausgeblendet).
- **Script-Kontext**: Das aktuelle PowerShell-Skript wird standardmäßig nicht an das LLM
gesendet. Der Dialog nennt den aktiven Zielhost und verlangt pro Aufruf eine standardmäßig
abgewählte Freigabe mit Secret-Warnung. Der Server wertet `CurrentScript` nur aus, wenn
`IncludeCurrentScript=true`; alte Clients bleiben dadurch fail-closed.
- **Rate-Limit**: 20 Anfragen/Min pro IP — schützt gegen Cost-Runaway bei Cloud-Modellen
und gegen versehentliche Spam-Loops im UI.
- **SSRF-Block**: Beim Startup wird die `BaseUrl` **jedes** Profils gegen Cloud-Metadata-IPs
Expand Down Expand Up @@ -487,8 +491,16 @@ Executor (nicht nur am HTTP-Controller), erlaubt als erstes Keyword nur `SELECT`
`EXPLAIN ANALYZE` ab. PostgreSQL setzt zusätzlich `SET TRANSACTION READ ONLY`; alle Provider rollen die
Transaktion zurück. **Secret-Schutz mehrlagig**: Schema-Tools verbergen `IsHidden`-Spalten; jede SQL-Referenz
auf eine geschützte Spalte wird bereits vor Ausführung abgelehnt (auch Alias-/Ausdrucksvarianten);
Whole-Row-Serialisierer über eine Tabelle mit geschützter Spalte (`to_json`/`row_to_json`/`::text`/
`FOR JSON`) ebenso, weil sie die namensbasierten Schichten umgehen würden. PostgreSQL-`U&"…"`-Identifier
und dynamische XML-Exporter (`query_to_xml` & Co.) sind im Read-Guard generell gesperrt.
Result-Spalten werden zusätzlich nach Namen maskiert und übrige Zellen durch den `IAuditDetailsRedactor`
geführt. Row-Cap 200. Übergroße Tool-Resultate bleiben valides JSON mit explizitem Truncation-Hinweis.

**Workflow-Definitionen sind hier bewusst nicht ausgenommen.** Der text2sql-Pfad ist Admin-only, und ein
globaler Admin sieht dieselben Zeilen ohnehin über die DbAdmin-Ansicht — eine Sperre auf `Workflows`,
`WorkflowVersions` oder den Custom-Activity-Tabellen hätte nur „welche Workflows gibt es" unbeantwortbar
gemacht, ohne eine Fähigkeit zu entziehen.
DB-Tools nutzen Strict Function Schemas; inkompatible lokale Endpoints erhalten automatisch einen
Best-Effort-Retry. SQL-Text wird nicht auditiert, stattdessen nur Anzahl und SHA-256-Kurzfingerprints.
Text2SQL ist nur als Capability sichtbar, wenn das aktive Profil `EnableToolCalling=true` hat.
Expand Down
Loading
Loading