From d9c8d8a9f53d3e91c07f576d67a7515e6218e273 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 15:38:12 +0000 Subject: [PATCH 1/3] fix(security): close GitHub code scanning alerts from gosec and trivy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remediate the findings uploaded to code scanning (93 gosec + 13 trivy): bound integer conversions, quote retention identifiers, detach background work with context.WithoutCancel, allowlist the backup-test script, and patch x/crypto plus workspace JS advisories. False-positive gosec hits on CLI/operator paths and env-var names are annotated so the next SARIF upload is empty. Docker Engine CVE-2026-33997 is ignored with justification — we only consume the frozen client module. Signed-off-by: Cursor Agent Co-authored-by: Santosh kumar --- .trivyignore | 8 ++ go.mod | 2 +- go.sum | 4 +- package.json | 7 +- pnpm-lock.yaml | 112 ++++++++++-------- services/cli/internal/dbcmd/db.go | 2 +- services/cli/internal/diag/test.go | 2 +- services/cli/internal/initcmd/init.go | 16 +-- services/cli/internal/initcmd/scaffold.go | 6 +- services/cli/internal/initcmd/template.go | 20 ++-- services/cli/internal/jobcmd/job.go | 4 +- services/cli/internal/modecmd/mode.go | 6 +- services/cli/internal/project/project.go | 7 +- services/cli/internal/starter/starter.go | 12 +- services/cli/internal/telemetry/telemetry.go | 2 +- services/cli/internal/upgradecmd/upgrade.go | 6 +- services/runtime/cmd/af-stack/backuptest.go | 8 +- services/runtime/cmd/migrationlint/main.go | 2 +- .../internal/adapters/remote/client.go | 2 +- services/runtime/internal/audit/audit.go | 6 +- .../runtime/internal/billing/lago_client.go | 2 +- .../runtime/internal/billing/stripe_client.go | 4 +- services/runtime/internal/config/config.go | 3 +- services/runtime/internal/config/features.go | 3 +- .../internal/connections/credential.go | 4 +- .../runtime/internal/connections/providers.go | 20 ++-- services/runtime/internal/db/db.go | 22 +++- .../internal/mcp/adapters/stdio/adapter.go | 36 +++--- services/runtime/internal/modules/manager.go | 2 +- services/runtime/internal/modules/migrate.go | 2 +- .../internal/oauth/adapters/github/github.go | 2 +- .../internal/oauth/adapters/google/google.go | 2 +- .../runtime/internal/prodcheck/prodcheck.go | 14 ++- .../runtime/internal/retention/retention.go | 11 +- .../sandbox/adapters/docker/adapter.go | 17 +-- .../internal/sandbox/adapters/e2b/adapter.go | 13 +- services/runtime/internal/secrets/kms.go | 3 +- .../runtime/internal/server/admin_brand.go | 2 +- .../runtime/internal/server/admin_demo.go | 43 ++++--- .../internal/server/admin_integrations.go | 2 +- services/runtime/internal/server/gdpr.go | 2 +- services/runtime/internal/server/llm.go | 2 +- services/runtime/internal/server/secrets.go | 16 +-- .../runtime/internal/server/shipwright.go | 2 +- services/runtime/internal/server/storage.go | 3 +- .../internal/server/tenant_resolver.go | 3 +- services/runtime/internal/skills/installer.go | 8 +- services/runtime/internal/webhooks/verify.go | 4 +- 48 files changed, 285 insertions(+), 196 deletions(-) create mode 100644 .trivyignore diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 00000000..4fbdad0f --- /dev/null +++ b/.trivyignore @@ -0,0 +1,8 @@ +# CVE-2026-33997 is a Docker Engine *daemon* flaw in `docker plugin install` +# privilege checks, fixed in Engine 29.3.1. This repo only imports +# github.com/docker/docker as a client of an already-running daemon (the +# sandbox adapter). That Go module is deprecated and frozen at +# v28.5.2+incompatible — there is no v29 tag consumers can `go get`. +# Migrating to github.com/moby/moby/{client,api} is a separate breaking +# SDK change and does not ship the daemon plugin-install path either. +CVE-2026-33997 diff --git a/go.mod b/go.mod index 56bf42aa..efc2a528 100644 --- a/go.mod +++ b/go.mod @@ -30,7 +30,7 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 go.opentelemetry.io/otel/sdk v1.46.0 go.opentelemetry.io/otel/trace v1.46.0 - golang.org/x/crypto v0.55.0 + golang.org/x/crypto v0.56.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 174d267b..37cc4564 100644 --- a/go.sum +++ b/go.sum @@ -311,8 +311,8 @@ go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= diff --git a/package.json b/package.json index ef9c4567..918fa1f9 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,12 @@ }, "pnpm": { "overrides": { - "hono@<4.12.25": ">=4.12.25", + "hono": "4.12.34", + "@hono/node-server": "1.19.15", + "prismjs": ">=1.30.0", + "qs": ">=6.16.0", + "body-parser": ">=2.3.0", + "postcss-selector-parser": ">=7.1.3", "next": "16.3.4", "brace-expansion@>=3": ">=5.0.9", "js-yaml@>=4 <4.3.1": "4.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9aa8e5c8..45281f24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,12 @@ settings: excludeLinksFromLockfile: false overrides: - hono@<4.12.25: '>=4.12.25' + hono: 4.12.34 + '@hono/node-server': 1.19.15 + prismjs: '>=1.30.0' + qs: '>=6.16.0' + body-parser: '>=2.3.0' + postcss-selector-parser: '>=7.1.3' next: 16.3.4 brace-expansion@>=3: '>=5.0.9' js-yaml@>=4 <4.3.1: 4.3.2 @@ -123,7 +128,7 @@ importers: version: 9.39.4(jiti@2.7.0) eslint-config-next: specifier: 16.3.4 - version: 16.3.4(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + version: 16.3.4(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) tailwindcss: specifier: ^4 version: 4.3.0 @@ -223,7 +228,7 @@ importers: version: 9.39.4(jiti@2.7.0) eslint-config-next: specifier: 16.3.4 - version: 16.3.4(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + version: 16.3.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) playwright: specifier: ^1.62.1 version: 1.62.1 @@ -630,11 +635,11 @@ packages: '@floating-ui/utils@0.2.12': resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} - '@hono/node-server@1.19.14': - resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + '@hono/node-server@1.19.15': + resolution: {integrity: sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg==} engines: {node: '>=18.14.1'} peerDependencies: - hono: '>=4.12.25' + hono: 4.12.34 '@hookform/resolvers@5.4.0': resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==} @@ -1982,8 +1987,8 @@ packages: zod: optional: true - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} brace-expansion@1.1.15: @@ -2852,8 +2857,8 @@ packages: highlightjs-vue@1.0.0: resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==} - hono@4.12.27: - resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} + hono@4.12.34: + resolution: {integrity: sha512-GqXJqY/xJkJmuloTrnV1ZEXG3fqte+VjkUqoRNZXcrUidiUOP4fMSIHHY4tsqZBK++kVyWmt/AAfSUuy57/eSA==} engines: {node: '>=16.9.0'} html-url-attributes@3.0.1: @@ -3751,8 +3756,8 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss-selector-parser@7.1.1: - resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + postcss-selector-parser@7.1.6: + resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==} engines: {node: '>=4'} postcss@8.5.23: @@ -3796,10 +3801,6 @@ packages: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} - prismjs@1.27.0: - resolution: {integrity: sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==} - engines: {node: '>=6'} - prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} @@ -3825,8 +3826,8 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.15.2: - resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} queue-microtask@1.2.3: @@ -4126,6 +4127,10 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -5141,9 +5146,9 @@ snapshots: '@floating-ui/utils@0.2.12': {} - '@hono/node-server@1.19.14(hono@4.12.27)': + '@hono/node-server@1.19.15(hono@4.12.34)': dependencies: - hono: 4.12.27 + hono: 4.12.34 '@hookform/resolvers@5.4.0(react-hook-form@7.77.0(react@19.2.4))': dependencies: @@ -5356,7 +5361,7 @@ snapshots: '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.14(hono@4.12.27) + '@hono/node-server': 1.19.15(hono@4.12.34) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -5366,7 +5371,7 @@ snapshots: eventsource-parser: 3.1.0 express: 5.2.1 express-rate-limit: 8.5.2(express@5.2.1) - hono: 4.12.27 + hono: 4.12.34 jose: 6.2.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -6406,15 +6411,15 @@ snapshots: optionalDependencies: zod: 4.4.3 - body-parser@2.2.2: + body-parser@2.3.0: dependencies: bytes: 3.1.2 - content-type: 1.0.5 + content-type: 2.0.0 debug: 4.4.3 http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 - qs: 6.15.2 + qs: 6.16.0 raw-body: 3.0.2 type-is: 2.1.0 transitivePeerDependencies: @@ -6871,27 +6876,27 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-next@16.3.4(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3): + eslint-config-next@16.3.4(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 16.3.4(eslint@9.39.4(jiti@2.7.0)) eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.7.0)) globals: 16.4.0 - typescript-eslint: 8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + typescript-eslint: 8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) optionalDependencies: - typescript: 6.0.3 + typescript: 5.9.3 transitivePeerDependencies: - '@typescript-eslint/parser' - eslint-import-resolver-webpack - eslint-plugin-import-x - supports-color - eslint-config-next@16.3.4(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): + eslint-config-next@16.3.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3): dependencies: '@next/eslint-plugin-next': 16.3.4(eslint@9.39.4(jiti@2.7.0)) eslint: 9.39.4(jiti@2.7.0) @@ -6902,9 +6907,9 @@ snapshots: eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.7.0)) globals: 16.4.0 - typescript-eslint: 8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + typescript-eslint: 8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - '@typescript-eslint/parser' - eslint-import-resolver-webpack @@ -6930,22 +6935,22 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): + eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -6956,7 +6961,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -6968,7 +6973,7 @@ snapshots: string.prototype.trimend: 1.0.10 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -6985,7 +6990,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -7178,7 +7183,7 @@ snapshots: express@5.2.1: dependencies: accepts: 2.0.0 - body-parser: 2.2.2 + body-parser: 2.3.0 content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 @@ -7197,7 +7202,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.15.2 + qs: 6.16.0 range-parser: 1.2.1 router: 2.2.0 send: 1.2.1 @@ -7478,7 +7483,7 @@ snapshots: highlightjs-vue@1.0.0: {} - hono@4.12.27: {} + hono@4.12.34: {} html-url-attributes@3.0.1: {} @@ -8537,7 +8542,7 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-selector-parser@7.1.1: + postcss-selector-parser@7.1.6: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 @@ -8574,8 +8579,6 @@ snapshots: dependencies: parse-ms: 4.0.0 - prismjs@1.27.0: {} - prismjs@1.30.0: {} prompts@2.4.2: @@ -8602,9 +8605,10 @@ snapshots: punycode@2.3.1: {} - qs@6.15.2: + qs@6.16.0: dependencies: - side-channel: 1.1.0 + es-define-property: 1.0.1 + side-channel: 1.1.1 queue-microtask@1.2.3: {} @@ -8762,7 +8766,7 @@ snapshots: dependencies: hastscript: 6.0.0 parse-entities: 2.0.0 - prismjs: 1.27.0 + prismjs: 1.30.0 regexp.prototype.flags@1.5.4: dependencies: @@ -8964,7 +8968,7 @@ snapshots: open: 11.0.0 ora: 8.2.0 postcss: 8.5.26 - postcss-selector-parser: 7.1.1 + postcss-selector-parser: 7.1.6 prompts: 2.4.2 recast: 0.23.11 stringify-object: 5.0.0 @@ -9004,7 +9008,7 @@ snapshots: open: 11.0.0 ora: 8.2.0 postcss: 8.5.26 - postcss-selector-parser: 7.1.1 + postcss-selector-parser: 7.1.6 prompts: 2.4.2 recast: 0.23.11 socks: 2.8.9 @@ -9090,6 +9094,14 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@3.0.7: {} diff --git a/services/cli/internal/dbcmd/db.go b/services/cli/internal/dbcmd/db.go index d902ad74..ebe145fa 100644 --- a/services/cli/internal/dbcmd/db.go +++ b/services/cli/internal/dbcmd/db.go @@ -35,7 +35,7 @@ const coreMigrationsRel = "services/runtime/internal/db/migrations" // goose binary. dir is passed as -dir; gooseArgs are the trailing command. var gooseRun = func(ctx context.Context, dir string, gooseArgs []string, stdout, stderr io.Writer) error { full := append([]string{"-dir", dir}, gooseArgs...) - cmd := exec.CommandContext(ctx, "goose", full...) + cmd := exec.CommandContext(ctx, "goose", full...) // #nosec G204 -- goose argv is built by this package from local migration dirs cmd.Stdout = stdout cmd.Stderr = stderr return cmd.Run() diff --git a/services/cli/internal/diag/test.go b/services/cli/internal/diag/test.go index 71298523..3c926d2b 100644 --- a/services/cli/internal/diag/test.go +++ b/services/cli/internal/diag/test.go @@ -35,7 +35,7 @@ type TestReport struct { // runTSC is indirected for testing. It typechecks a single standalone .ts // file with a bare tsc + DOM lib (no node_modules needed). var runTSC = func(ctx context.Context, file string) error { - cmd := exec.CommandContext(ctx, "tsc", + cmd := exec.CommandContext(ctx, "tsc", // #nosec G204 -- fixed tsc flags; file is a local path this CLI resolved "--noEmit", "--strict", "--skipLibCheck", "--target", "ES2020", "--module", "ESNext", "--moduleResolution", "bundler", "--lib", "ES2020,DOM", diff --git a/services/cli/internal/initcmd/init.go b/services/cli/internal/initcmd/init.go index 361ad85c..55acd17b 100644 --- a/services/cli/internal/initcmd/init.go +++ b/services/cli/internal/initcmd/init.go @@ -279,7 +279,7 @@ func readBrand(path string) (brandFile, error) { if !exists(path) { return brand, nil } - raw, err := os.ReadFile(path) + raw, err := os.ReadFile(filepath.Clean(path)) // #nosec G304 -- operator brand.yaml if err != nil { return brandFile{}, err } @@ -294,7 +294,7 @@ func writeBrand(path string, brand brandFile) error { if err != nil { return err } - return os.WriteFile(path, raw, 0o644) + return os.WriteFile(path, raw, 0o644) // #nosec G304,G306 -- brand.yaml is project source } func copyLogo(root, rawPath string) (string, error) { @@ -315,14 +315,14 @@ func copyLogo(root, rawPath string) (string, error) { } destRel := filepath.ToSlash(filepath.Join("brand", "logo"+ext)) dest := filepath.Join(root, filepath.FromSlash(destRel)) - if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(dest), 0o750); err != nil { return "", err } - raw, err := os.ReadFile(source) + raw, err := os.ReadFile(filepath.Clean(source)) // #nosec G304 -- operator-supplied logo path if err != nil { return "", err } - if err := os.WriteFile(dest, raw, 0o644); err != nil { + if err := os.WriteFile(dest, raw, 0o644); err != nil { // #nosec G304,G306,G703 -- copied logo under the project brand dir return "", err } return "./" + destRel, nil @@ -352,7 +352,7 @@ func updateDefaultAgentName(root, next string) error { if !exists(path) { continue } - raw, err := os.ReadFile(path) + raw, err := os.ReadFile(filepath.Clean(path)) // #nosec G304 -- in-repo agent rename targets if err != nil { return err } @@ -363,7 +363,7 @@ func updateDefaultAgentName(root, next string) error { if updated == string(raw) { continue } - if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { + if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { // #nosec G304,G306,G703 -- in-repo source rewrite return err } } @@ -371,7 +371,7 @@ func updateDefaultAgentName(root, next string) error { } func currentAgentName(root string) string { - raw, err := os.ReadFile(filepath.Join(root, "docker-compose.yml")) + raw, err := os.ReadFile(filepath.Clean(filepath.Join(root, "docker-compose.yml"))) // #nosec G304 -- project compose file if err == nil { if match := composeNodeIDRE.FindSubmatch(raw); len(match) == 2 { return string(match[1]) diff --git a/services/cli/internal/initcmd/scaffold.go b/services/cli/internal/initcmd/scaffold.go index bbcfa51f..769a1ea5 100644 --- a/services/cli/internal/initcmd/scaffold.go +++ b/services/cli/internal/initcmd/scaffold.go @@ -76,10 +76,10 @@ func runScaffold(args []string, stdout, stderr io.Writer) error { written := make([]string, 0, len(files)) for rel, contents := range files { path := filepath.Join(target, filepath.FromSlash(rel)) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return output.Fail("init: create %s: %v", rel, err) } - // #nosec G306 -- scaffolded project source files, not secrets. + // #nosec G304,G306,G703 -- scaffolded project source files, not secrets. if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { return output.Fail("init: write %s: %v", rel, err) } @@ -131,7 +131,7 @@ func ensureTargetDir(target string, force bool) error { case err == nil: return fmt.Errorf("init: %s already exists and is not a directory", target) case os.IsNotExist(err): - return os.MkdirAll(target, 0o755) + return os.MkdirAll(target, 0o750) default: return err } diff --git a/services/cli/internal/initcmd/template.go b/services/cli/internal/initcmd/template.go index 515b8139..0441240f 100644 --- a/services/cli/internal/initcmd/template.go +++ b/services/cli/internal/initcmd/template.go @@ -59,7 +59,7 @@ func scaffoldCodingAgent(root string) (created []string, skippedExisting bool, e "README.md": codingAgentReadme(codingAgentNodeID), } dir := filepath.Join(root, codingAgentDir) - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := os.MkdirAll(dir, 0o750); err != nil { return nil, false, fmt.Errorf("init: create %s: %w", codingAgentDir, err) } // Deterministic order so the CLI summary + tests are stable. @@ -70,7 +70,7 @@ func scaffoldCodingAgent(root string) (created []string, skippedExisting bool, e skippedExisting = true continue } - // #nosec G306 -- scaffolded project source files (main.py, Dockerfile, + // #nosec G304,G306,G703 -- scaffolded project source files (main.py, Dockerfile, // README) are meant to be world-readable like any checked-in source. if err := os.WriteFile(dest, []byte(files[name]), 0o644); err != nil { return nil, skippedExisting, fmt.Errorf("init: write %s: %w", rel, err) @@ -91,7 +91,7 @@ func scaffoldCodingAgent(root string) (created []string, skippedExisting bool, e skippedExisting = true continue } - // #nosec G306 -- world-readable project scaffolding, not secrets. + // #nosec G304,G306,G703 -- world-readable project scaffolding, not secrets. if err := os.WriteFile(dest, []byte(rootFiles[name]), 0o644); err != nil { return nil, skippedExisting, fmt.Errorf("init: write %s: %w", name, err) } @@ -344,7 +344,7 @@ var ( // the operator's comments, anchors, and formatting intact. func wireCodingAgentCompose(root string) (wired bool, err error) { path := filepath.Join(root, "docker-compose.yml") - raw, err := os.ReadFile(path) + raw, err := os.ReadFile(filepath.Clean(path)) // #nosec G304 -- project docker-compose.yml if err != nil { if os.IsNotExist(err) { return false, nil @@ -361,7 +361,7 @@ func wireCodingAgentCompose(root string) (wired bool, err error) { } insertAt := loc[1] updated := content[:insertAt] + codingAgentComposeService + content[insertAt:] - // #nosec G306 -- docker-compose.yml is world-readable project source. + // #nosec G304,G306,G703 -- docker-compose.yml is world-readable project source. if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { return false, fmt.Errorf("init: update docker-compose.yml: %w", err) } @@ -389,14 +389,14 @@ const heroEnvBlock = "\n# Hero flow (coding-agent template): multi-tenancy on so func ensureHeroEnv(root string) (string, error) { path := filepath.Join(root, ".env") if exists(path) { - raw, err := os.ReadFile(path) + raw, err := os.ReadFile(filepath.Clean(path)) // #nosec G304 -- project-local .env if err != nil { return "", fmt.Errorf("init: read .env: %w", err) } if mtEnvKeyRE.Match(raw) { return "present", nil // respect the operator's explicit choice } - f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) + f, err := os.OpenFile(filepath.Clean(path), os.O_APPEND|os.O_WRONLY, 0o600) // #nosec G304,G302 -- project-local .env if err != nil { return "", fmt.Errorf("init: open .env: %w", err) } @@ -411,7 +411,7 @@ func ensureHeroEnv(root string) (string, error) { var content string examplePath := filepath.Join(root, ".env.example") if exists(examplePath) { - b, err := os.ReadFile(examplePath) + b, err := os.ReadFile(filepath.Clean(examplePath)) // #nosec G304 -- project-local .env.example if err != nil { return "", fmt.Errorf("init: read .env.example: %w", err) } @@ -420,9 +420,9 @@ func ensureHeroEnv(root string) (string, error) { if !mtEnvKeyRE.MatchString(content) { content += heroEnvBlock } - // #nosec G306 -- .env is world-readable project scaffolding (no real + // #nosec G304,G306,G703 -- .env is project scaffolding (no real // secrets; the hero placeholders are filled in by the user afterwards). - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { return "", fmt.Errorf("init: write .env: %w", err) } return "created", nil diff --git a/services/cli/internal/jobcmd/job.go b/services/cli/internal/jobcmd/job.go index 7d85bd2a..1dfdbd5f 100644 --- a/services/cli/internal/jobcmd/job.go +++ b/services/cli/internal/jobcmd/job.go @@ -82,10 +82,10 @@ func runNew(baseDir string, args []string, stdout, stderr io.Writer) error { if _, statErr := os.Stat(abs); statErr == nil { return output.Invalid("job new: %s already exists (refusing to overwrite)", rel) } - if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(abs), 0o750); err != nil { return output.Fail("job new: create jobs dir: %v", err) } - // #nosec G306 -- scaffolded worker source, world-readable (not a secret). + // #nosec G304,G306,G703 -- scaffolded worker source, world-readable (not a secret). if err := os.WriteFile(abs, []byte(contents), 0o644); err != nil { return output.Fail("job new: write %s: %v", rel, err) } diff --git a/services/cli/internal/modecmd/mode.go b/services/cli/internal/modecmd/mode.go index 47e38ae4..c479c359 100644 --- a/services/cli/internal/modecmd/mode.go +++ b/services/cli/internal/modecmd/mode.go @@ -109,13 +109,13 @@ func setMode(root, envPath, mode string) error { out = ensureTrailingNewline(string(raw)) + "\n# Deployment mode: saas | personal (set via `af-stack mode`).\n" + line + "\n" } - // #nosec G306 -- .env is world-readable project config (no secrets here). + // #nosec G304,G306,G703 -- .env is world-readable project config (no secrets here). return os.WriteFile(envPath, []byte(out), 0o644) case errors.Is(err, os.ErrNotExist): // Seed from .env.example when available so the new .env keeps the // full documented variable set, then upsert the mode line. content := "" - if b, rerr := os.ReadFile(filepath.Join(root, ".env.example")); rerr == nil { + if b, rerr := os.ReadFile(filepath.Clean(filepath.Join(root, ".env.example"))); rerr == nil { // #nosec G304 -- project-local .env.example content = string(b) } if modeLineRE.MatchString(content) { @@ -124,7 +124,7 @@ func setMode(root, envPath, mode string) error { content = ensureTrailingNewline(content) + "\n# Deployment mode: saas | personal (set via `af-stack mode`).\n" + line + "\n" } - // #nosec G306 -- see above. + // #nosec G304,G306,G703 -- see above. return os.WriteFile(envPath, []byte(content), 0o644) default: return fmt.Errorf("mode: read .env: %w", err) diff --git a/services/cli/internal/project/project.go b/services/cli/internal/project/project.go index cca52d0a..2a93b55d 100644 --- a/services/cli/internal/project/project.go +++ b/services/cli/internal/project/project.go @@ -33,7 +33,7 @@ import ( type commandRunner func(ctx context.Context, dir string, name string, args []string, stdout, stderr io.Writer) error var runCommand commandRunner = func(ctx context.Context, dir string, name string, args []string, stdout, stderr io.Writer) error { - cmd := exec.CommandContext(ctx, name, args...) + cmd := exec.CommandContext(ctx, name, args...) // #nosec G204 -- CLI wraps docker/node/browser open with argv this package builds cmd.Dir = dir cmd.Stdout = stdout cmd.Stderr = stderr @@ -121,7 +121,7 @@ func readEnvValue(root, key, def string) string { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return v } - data, err := os.ReadFile(filepath.Join(root, ".env")) + data, err := os.ReadFile(filepath.Clean(filepath.Join(root, ".env"))) // #nosec G304 -- project-local .env if err != nil { return def } @@ -697,9 +697,10 @@ func openURL(ctx context.Context, url string, stderr io.Writer) { func writeFiles(root string, files map[string]string) error { for rel, contents := range files { path := filepath.Join(root, filepath.FromSlash(rel)) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return err } + // #nosec G304,G306,G703 -- scaffolded project source under the operator's target dir if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { return err } diff --git a/services/cli/internal/starter/starter.go b/services/cli/internal/starter/starter.go index 333b1d22..d9f022c0 100644 --- a/services/cli/internal/starter/starter.go +++ b/services/cli/internal/starter/starter.go @@ -77,14 +77,14 @@ func EnsureBackend(root, version string) ([]string, error) { if _, err := os.Stat(path); err == nil { continue } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return written, fmt.Errorf("create %s: %w", rel, err) } mode := os.FileMode(0o644) if strings.HasSuffix(rel, ".sh") { mode = 0o755 } - // #nosec G306 -- compose stack and mounted config, not secrets. + // #nosec G304,G306,G703 -- compose stack and mounted config, not secrets. if err := os.WriteFile(path, []byte(contents), mode); err != nil { return written, fmt.Errorf("write %s: %w", rel, err) } @@ -266,7 +266,7 @@ func nextFreePort(start int, claimed map[int]bool) (int, error) { // Values from the process environment win, as they do for docker compose. func ReadEnv(root string) (map[string]string, error) { values := map[string]string{} - f, err := os.Open(filepath.Join(root, ".env")) + f, err := os.Open(filepath.Clean(filepath.Join(root, ".env"))) // #nosec G304 -- project-local .env if err != nil { if os.IsNotExist(err) { return values, nil @@ -303,12 +303,12 @@ func ReadEnv(root string) (map[string]string, error) { func SetEnv(root string, values map[string]string, comment string) error { envPath := filepath.Join(root, ".env") var lines []string - data, err := os.ReadFile(envPath) + data, err := os.ReadFile(filepath.Clean(envPath)) // #nosec G304 -- project-local .env switch { case err == nil: lines = strings.Split(strings.TrimRight(string(data), "\n"), "\n") case os.IsNotExist(err): - if ex, exErr := os.ReadFile(filepath.Join(root, ".env.example")); exErr == nil { + if ex, exErr := os.ReadFile(filepath.Clean(filepath.Join(root, ".env.example"))); exErr == nil { // #nosec G304 -- project-local .env.example lines = strings.Split(strings.TrimRight(string(ex), "\n"), "\n") } default: @@ -348,7 +348,7 @@ func SetEnv(root string, values map[string]string, comment string) error { lines = append(lines, k+"="+remaining[k]) } } - // #nosec G306 G703 -- /.env of the app the user is running in; local dev config, not secrets. + // #nosec G304,G306,G703 -- /.env of the app the user is running in; local dev config, not secrets. return os.WriteFile(envPath, []byte(strings.Join(lines, "\n")+"\n"), 0o644) } diff --git a/services/cli/internal/telemetry/telemetry.go b/services/cli/internal/telemetry/telemetry.go index 61fed99d..e6953cc0 100644 --- a/services/cli/internal/telemetry/telemetry.go +++ b/services/cli/internal/telemetry/telemetry.go @@ -211,7 +211,7 @@ func (c *Client) loadOrCreateAnonID() string { return randomID() } path := filepath.Join(c.configDir, "anonymous_id") - if raw, err := os.ReadFile(path); err == nil { + if raw, err := os.ReadFile(filepath.Clean(path)); err == nil { // #nosec G304 -- per-user ~/.af-stack/anonymous_id if id := strings.TrimSpace(string(raw)); id != "" { return id } diff --git a/services/cli/internal/upgradecmd/upgrade.go b/services/cli/internal/upgradecmd/upgrade.go index 0a2bba6c..87c47153 100644 --- a/services/cli/internal/upgradecmd/upgrade.go +++ b/services/cli/internal/upgradecmd/upgrade.go @@ -224,7 +224,7 @@ func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) error { // ─── helpers ────────────────────────────────────────────────────────────── func gitOut(dir string, args ...string) (string, error) { - cmd := exec.Command("git", args...) + cmd := exec.Command("git", args...) // #nosec G204 -- git argv is built by this package, not untrusted input if dir != "" { cmd.Dir = dir } @@ -276,7 +276,7 @@ func incomingMigrations(root, target string) []string { // changes) and returns the conflicted paths split fork-owned / platform. func predictConflicts(root, target string) (fork, platform []string, err error) { // merge-tree exits 1 on conflicts, >1 on real errors. - cmd := exec.Command("git", "merge-tree", "--write-tree", "--name-only", "HEAD", target) + cmd := exec.Command("git", "merge-tree", "--write-tree", "--name-only", "HEAD", target) // #nosec G204 -- fixed git subcommand cmd.Dir = root var buf bytes.Buffer cmd.Stdout = &buf @@ -336,7 +336,7 @@ func tryBackup(root string, stdout, stderr io.Writer) string { out := filepath.Join(root, fmt.Sprintf("backup-pre-upgrade-%s.sql.gz", time.Now().UTC().Format("20060102-150405"))) fmt.Fprintf(stdout, "Backing up the database before migrations (%s)…\n", filepath.Base(out)) - cmd := exec.Command("bash", script, "--url", dbURL, "--out", out) + cmd := exec.Command("bash", script, "--url", dbURL, "--out", out) // #nosec G204,G702 -- fixed scripts/backup.sh under the project root cmd.Dir = root cmd.Stdout = stdout cmd.Stderr = stderr diff --git a/services/runtime/cmd/af-stack/backuptest.go b/services/runtime/cmd/af-stack/backuptest.go index ee5e766f..d9323da9 100644 --- a/services/runtime/cmd/af-stack/backuptest.go +++ b/services/runtime/cmd/af-stack/backuptest.go @@ -4,9 +4,11 @@ package main import ( "context" + "fmt" "log/slog" "os" "os/exec" + "path/filepath" "strconv" "strings" "sync/atomic" @@ -59,6 +61,10 @@ func runBackupRestoreTest(ctx context.Context, log *slog.Logger) error { if script == "" { script = "scripts/backup-restore-test.sh" } + if filepath.Base(script) != "backup-restore-test.sh" { + return fmt.Errorf("backup-test: refused unexpected script %q", script) + } + script = filepath.Clean(script) timeout := 10 * time.Minute if v := strings.TrimSpace(os.Getenv("BACKUP_TEST_TIMEOUT_SECONDS")); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { @@ -69,7 +75,7 @@ func runBackupRestoreTest(ctx context.Context, log *slog.Logger) error { runCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - cmd := exec.CommandContext(runCtx, "bash", script) + cmd := exec.CommandContext(runCtx, "bash", script) // #nosec G204,G702 -- allowlisted backup-restore-test.sh only cmd.Env = os.Environ() out, err := cmd.CombinedOutput() if err != nil { diff --git a/services/runtime/cmd/migrationlint/main.go b/services/runtime/cmd/migrationlint/main.go index c876cafa..dfcf4e4b 100644 --- a/services/runtime/cmd/migrationlint/main.go +++ b/services/runtime/cmd/migrationlint/main.go @@ -49,7 +49,7 @@ func lintDir(dir string) ([]Finding, error) { continue } path := filepath.Join(dir, e.Name()) - data, err := os.ReadFile(path) + data, err := os.ReadFile(filepath.Clean(path)) // #nosec G304,G703 -- local migration files from an operator-supplied dir if err != nil { return nil, fmt.Errorf("read %s: %w", path, err) } diff --git a/services/runtime/internal/adapters/remote/client.go b/services/runtime/internal/adapters/remote/client.go index 4ced7f32..5f8bdbe8 100644 --- a/services/runtime/internal/adapters/remote/client.go +++ b/services/runtime/internal/adapters/remote/client.go @@ -455,7 +455,7 @@ func backoff(attempt int, lastErr error) time.Duration { if base > 5*time.Second { base = 5 * time.Second } - jitter := time.Duration(rand.Int64N(int64(base) / 2)) + jitter := time.Duration(rand.Int64N(int64(base) / 2)) // #nosec G404 -- retry jitter, not a secret return base + jitter - (base / 4) } diff --git a/services/runtime/internal/audit/audit.go b/services/runtime/internal/audit/audit.go index 1734e44a..ce851a18 100644 --- a/services/runtime/internal/audit/audit.go +++ b/services/runtime/internal/audit/audit.go @@ -127,7 +127,11 @@ func (w *Writer) Write(ctx context.Context, r *http.Request, ev Event) { } go func() { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + parent := ctx + if r != nil { + parent = r.Context() + } + ctx, cancel := context.WithTimeout(context.WithoutCancel(parent), 2*time.Second) defer cancel() // Bind the write connection to the row's tenant so the audit-log RLS // WITH CHECK passes (the pool's PrepareConn hook reads this from ctx). diff --git a/services/runtime/internal/billing/lago_client.go b/services/runtime/internal/billing/lago_client.go index ecf982a4..89b2a9ca 100644 --- a/services/runtime/internal/billing/lago_client.go +++ b/services/runtime/internal/billing/lago_client.go @@ -24,7 +24,7 @@ const ( // http://lago-api:3000 or https://api.getlago.com. EnvLagoAPIURL = "LAGO_API_URL" // EnvLagoAPIKey is the bearer token used for Lago's API. - EnvLagoAPIKey = "LAGO_API_KEY" + EnvLagoAPIKey = "LAGO_API_KEY" // #nosec G101 -- env var name, not a credential ) // NewLagoClientFromEnv returns a Lago adapter. Missing URL or API key diff --git a/services/runtime/internal/billing/stripe_client.go b/services/runtime/internal/billing/stripe_client.go index 8ffe11dc..701c5f7d 100644 --- a/services/runtime/internal/billing/stripe_client.go +++ b/services/runtime/internal/billing/stripe_client.go @@ -77,11 +77,11 @@ type Client interface { const EnvBillingAdapter = "AF_STACK_BILLING_ADAPTER" // EnvSecretKey is the env var that activates the real Stripe client. -const EnvSecretKey = "STRIPE_SECRET_KEY" +const EnvSecretKey = "STRIPE_SECRET_KEY" // #nosec G101 -- env var name, not a credential // EnvWebhookSecret is the env var the webhook handler uses to validate // incoming Stripe-Signature headers. -const EnvWebhookSecret = "STRIPE_WEBHOOK_SECRET" +const EnvWebhookSecret = "STRIPE_WEBHOOK_SECRET" // #nosec G101 -- env var name, not a credential // NewClientFromEnv returns the configured Client. // diff --git a/services/runtime/internal/config/config.go b/services/runtime/internal/config/config.go index 30c6b0e8..25d59474 100644 --- a/services/runtime/internal/config/config.go +++ b/services/runtime/internal/config/config.go @@ -9,6 +9,7 @@ package config import ( "fmt" "os" + "path/filepath" "strings" "time" @@ -372,7 +373,7 @@ func Load(path string) (Config, error) { cfg := Default() if path != "" { - data, err := os.ReadFile(path) + data, err := os.ReadFile(filepath.Clean(path)) // #nosec G304 -- operator-supplied config path if err != nil && !os.IsNotExist(err) { return Config{}, fmt.Errorf("read config %s: %w", path, err) } diff --git a/services/runtime/internal/config/features.go b/services/runtime/internal/config/features.go index 5fc1f676..8d73c455 100644 --- a/services/runtime/internal/config/features.go +++ b/services/runtime/internal/config/features.go @@ -6,6 +6,7 @@ import ( "bytes" "fmt" "os" + "path/filepath" "gopkg.in/yaml.v3" ) @@ -129,7 +130,7 @@ func LoadFeatureConfig(path string) (FeatureConfig, []ValidationError, error) { if path == "" { path = DefaultFeatureConfigPath } - data, err := os.ReadFile(path) + data, err := os.ReadFile(filepath.Clean(path)) // #nosec G304 -- operator-supplied feature config path if err != nil { if os.IsNotExist(err) { raw := RawFeatureConfig{Preset: PresetLean} diff --git a/services/runtime/internal/connections/credential.go b/services/runtime/internal/connections/credential.go index 2cc26056..6fd61743 100644 --- a/services/runtime/internal/connections/credential.go +++ b/services/runtime/internal/connections/credential.go @@ -24,7 +24,7 @@ type Cipher interface { // the runtime — it is never returned to app code, logged, or placed on a // Connection. type credential struct { - APIKey string `json:"api_key,omitempty"` + APIKey string `json:"api_key,omitempty"` // #nosec G117 -- sealed with AES-256-GCM before persist; never logged AccessToken string `json:"access_token,omitempty"` RefreshToken string `json:"refresh_token,omitempty"` } @@ -45,7 +45,7 @@ func sealCredential(c Cipher, cred credential) ([]byte, error) { if c == nil { return nil, fmt.Errorf("connections: cipher not configured") } - plaintext, err := json.Marshal(cred) + plaintext, err := json.Marshal(cred) // #nosec G117 -- envelope is AES-256-GCM sealed before persist if err != nil { return nil, fmt.Errorf("connections: marshal credential: %w", err) } diff --git a/services/runtime/internal/connections/providers.go b/services/runtime/internal/connections/providers.go index f5eaae98..be6d6487 100644 --- a/services/runtime/internal/connections/providers.go +++ b/services/runtime/internal/connections/providers.go @@ -143,21 +143,21 @@ func (r *Registry) Names() []string { func defaultDescriptors() []Descriptor { return []Descriptor{ - { + { // #nosec G101 -- provider metadata (public URLs + env var names) Name: "github", Kind: KindOAuth, BaseURL: "https://api.github.com", AuthHeaderName: "Authorization", AuthValuePrefix: "Bearer ", AuthorizeEndpoint: "https://github.com/login/oauth/authorize", - TokenEndpoint: "https://github.com/login/oauth/access_token", + TokenEndpoint: "https://github.com/login/oauth/access_token", // #nosec G101 -- public OAuth endpoint DefaultScopes: []string{"repo", "read:user"}, ScopeSeparator: " ", WebhookScheme: WebhookGitHubHMAC, ClientIDEnv: "CONNECTIONS_GITHUB_CLIENT_ID", - ClientSecretEnv: "CONNECTIONS_GITHUB_CLIENT_SECRET", + ClientSecretEnv: "CONNECTIONS_GITHUB_CLIENT_SECRET", // #nosec G101 -- env var name }, - { + { // #nosec G101 -- provider metadata (public URLs) Name: "stripe", Kind: KindAPIKey, BaseURL: "https://api.stripe.com", @@ -167,33 +167,33 @@ func defaultDescriptors() []Descriptor { // Stripe is a secret-key provider — no OAuth consent flow in the // standard integration path, so no authorize/token endpoints. }, - { + { // #nosec G101 -- provider metadata (public URLs + env var names) Name: "google", Kind: KindOAuth, BaseURL: "https://www.googleapis.com", AuthHeaderName: "Authorization", AuthValuePrefix: "Bearer ", AuthorizeEndpoint: "https://accounts.google.com/o/oauth2/v2/auth", - TokenEndpoint: "https://oauth2.googleapis.com/token", + TokenEndpoint: "https://oauth2.googleapis.com/token", // #nosec G101 -- public OAuth endpoint DefaultScopes: []string{"https://www.googleapis.com/auth/userinfo.email"}, ScopeSeparator: " ", WebhookScheme: WebhookNone, ClientIDEnv: "CONNECTIONS_GOOGLE_CLIENT_ID", - ClientSecretEnv: "CONNECTIONS_GOOGLE_CLIENT_SECRET", + ClientSecretEnv: "CONNECTIONS_GOOGLE_CLIENT_SECRET", // #nosec G101 -- env var name }, - { + { // #nosec G101 -- provider metadata (public URLs + env var names) Name: "slack", Kind: KindOAuth, BaseURL: "https://slack.com/api", AuthHeaderName: "Authorization", AuthValuePrefix: "Bearer ", AuthorizeEndpoint: "https://slack.com/oauth/v2/authorize", - TokenEndpoint: "https://slack.com/api/oauth.v2.access", + TokenEndpoint: "https://slack.com/api/oauth.v2.access", // #nosec G101 -- public OAuth endpoint DefaultScopes: []string{"chat:write"}, ScopeSeparator: ",", WebhookScheme: WebhookNone, ClientIDEnv: "CONNECTIONS_SLACK_CLIENT_ID", - ClientSecretEnv: "CONNECTIONS_SLACK_CLIENT_SECRET", + ClientSecretEnv: "CONNECTIONS_SLACK_CLIENT_SECRET", // #nosec G101 -- env var name }, } } diff --git a/services/runtime/internal/db/db.go b/services/runtime/internal/db/db.go index bd89a418..24ef869c 100644 --- a/services/runtime/internal/db/db.go +++ b/services/runtime/internal/db/db.go @@ -10,6 +10,7 @@ import ( "context" "errors" "fmt" + "math" "time" "github.com/jackc/pgx/v5" @@ -43,10 +44,18 @@ func Open(ctx context.Context, cfg Config) (*DB, error) { return nil, fmt.Errorf("db: parse url: %w", err) } if cfg.MaxConnections > 0 { - pcfg.MaxConns = int32(cfg.MaxConnections) + n, err := int32n(cfg.MaxConnections, "MaxConnections") + if err != nil { + return nil, err + } + pcfg.MaxConns = n } if cfg.MaxIdleConns > 0 { - pcfg.MinConns = int32(cfg.MaxIdleConns) + n, err := int32n(cfg.MaxIdleConns, "MaxIdleConns") + if err != nil { + return nil, err + } + pcfg.MinConns = n } if cfg.ConnMaxLifetime > 0 { pcfg.MaxConnLifetime = cfg.ConnMaxLifetime @@ -158,6 +167,13 @@ func (d *DB) Close() { } } +func int32n(n int, name string) (int32, error) { + if n <= 0 || n > math.MaxInt32 { + return 0, fmt.Errorf("db: %s out of range: %d", name, n) + } + return int32(n), nil +} + // Stats is a minimal stats snapshot. type Stats struct { AcquireCount int64 @@ -167,4 +183,4 @@ type Stats struct { MaxConns int NewConnsCount int64 MaxLifetimeDest int64 -} \ No newline at end of file +} diff --git a/services/runtime/internal/mcp/adapters/stdio/adapter.go b/services/runtime/internal/mcp/adapters/stdio/adapter.go index ad543cde..458fe00c 100644 --- a/services/runtime/internal/mcp/adapters/stdio/adapter.go +++ b/services/runtime/internal/mcp/adapters/stdio/adapter.go @@ -6,15 +6,15 @@ // // Lifecycle: // -// Connect: spawn the process, pipe stdin/stdout, launch a stderr -// scraper goroutine (first chunk is surfaced as last_error -// on the next Connect failure), perform the "initialize" -// handshake, send the "notifications/initialized" notice. -// ListTools: tools/list request -> Tool array. -// Call: tools/call request -> CallResult (MCP's content array passed -// through unchanged). -// Close: SIGTERM the child, give it a short grace window, then SIGKILL -// if still alive. Cancels the reader goroutine. +// Connect: spawn the process, pipe stdin/stdout, launch a stderr +// scraper goroutine (first chunk is surfaced as last_error +// on the next Connect failure), perform the "initialize" +// handshake, send the "notifications/initialized" notice. +// ListTools: tools/list request -> Tool array. +// Call: tools/call request -> CallResult (MCP's content array passed +// through unchanged). +// Close: SIGTERM the child, give it a short grace window, then SIGKILL +// if still alive. Cancels the reader goroutine. // // Concurrency: Connect / Close are NOT safe for concurrent calls from // different goroutines (the Pool serialises them). ListTools / Call ARE @@ -61,13 +61,13 @@ type Adapter struct { args []string log *slog.Logger - mu sync.Mutex // serialises Connect/Close + the requestID counter - closed bool - cancel context.CancelFunc - stdin io.WriteCloser - stdout *bufio.Reader - stderr io.ReadCloser - doneCh chan struct{} + mu sync.Mutex // serialises Connect/Close + the requestID counter + closed bool + cancel context.CancelFunc + stdin io.WriteCloser + stdout *bufio.Reader + stderr io.ReadCloser + doneCh chan struct{} // Response routing — the reader goroutine fans out incoming JSON // frames to the goroutine that issued the matching request. @@ -121,7 +121,7 @@ func (a *Adapter) Connect(ctx context.Context) error { // Spawn under a cancellable context so Close() can SIGKILL via // cancel() if the child ignores SIGTERM. cmdCtx, cancel := context.WithCancel(context.Background()) - cmd := exec.CommandContext(cmdCtx, a.args[0], a.args[1:]...) + cmd := exec.CommandContext(cmdCtx, a.args[0], a.args[1:]...) // #nosec G204 -- operator-configured MCP server argv // Env: merge the row's env on TOP of a minimal passthrough (PATH + // HOME) so utilities like `uvx` can find their interpreter. Tests @@ -581,4 +581,4 @@ func mergeEnv(extra map[string]string) []string { var lookupEnv = func(key string) (string, bool) { v, ok := osLookup(key) return v, ok -} \ No newline at end of file +} diff --git a/services/runtime/internal/modules/manager.go b/services/runtime/internal/modules/manager.go index cd480b90..5fab3f35 100644 --- a/services/runtime/internal/modules/manager.go +++ b/services/runtime/internal/modules/manager.go @@ -114,7 +114,7 @@ func Load(root string, enabled []string, log *slog.Logger) *Manager { func (m *Manager) loadOne(dir, manifestPath string, enabledSet map[string]struct{}, seen map[string]string) *Loaded { l := &Loaded{Dir: dir, Migration: MigrationSkipped} - data, err := os.ReadFile(manifestPath) + data, err := os.ReadFile(filepath.Clean(manifestPath)) // #nosec G304 -- module manifest under the configured modules root if err != nil { l.LoadErr = fmt.Errorf("read manifest: %w", err) l.Migration = MigrationError diff --git a/services/runtime/internal/modules/migrate.go b/services/runtime/internal/modules/migrate.go index e529d4ec..bbf50548 100644 --- a/services/runtime/internal/modules/migrate.go +++ b/services/runtime/internal/modules/migrate.go @@ -73,7 +73,7 @@ func readMigrationFiles(dir string) ([]migrationFile, error) { return nil, fmt.Errorf("migration version %d declared twice: %s and %s", version, prev, name) } seen[version] = name - data, err := os.ReadFile(filepath.Join(dir, name)) + data, err := os.ReadFile(filepath.Clean(filepath.Join(dir, name))) // #nosec G304 -- module migration listed by ReadDir if err != nil { return nil, fmt.Errorf("read migration %s: %w", name, err) } diff --git a/services/runtime/internal/oauth/adapters/github/github.go b/services/runtime/internal/oauth/adapters/github/github.go index d3efdaa1..dd06de08 100644 --- a/services/runtime/internal/oauth/adapters/github/github.go +++ b/services/runtime/internal/oauth/adapters/github/github.go @@ -38,7 +38,7 @@ var DefaultScopes = []string{"repo", "read:user"} // httptest server URL via package-level reassignment + Cleanup. var ( authorizeURL = "https://github.com/login/oauth/authorize" - tokenURL = "https://github.com/login/oauth/access_token" + tokenURL = "https://github.com/login/oauth/access_token" // #nosec G101 -- public OAuth endpoint, not a credential revokeBase = "https://api.github.com/applications" ) diff --git a/services/runtime/internal/oauth/adapters/google/google.go b/services/runtime/internal/oauth/adapters/google/google.go index 85428975..bc16b6d2 100644 --- a/services/runtime/internal/oauth/adapters/google/google.go +++ b/services/runtime/internal/oauth/adapters/google/google.go @@ -41,7 +41,7 @@ var DefaultScopes = []string{ // them with an httptest server URL via reassignment + Cleanup. var ( authorizeURL = "https://accounts.google.com/o/oauth2/v2/auth" - tokenURL = "https://oauth2.googleapis.com/token" + tokenURL = "https://oauth2.googleapis.com/token" // #nosec G101 -- public OAuth endpoint, not a credential revokeURL = "https://oauth2.googleapis.com/revoke" ) diff --git a/services/runtime/internal/prodcheck/prodcheck.go b/services/runtime/internal/prodcheck/prodcheck.go index af4213d8..642663c9 100644 --- a/services/runtime/internal/prodcheck/prodcheck.go +++ b/services/runtime/internal/prodcheck/prodcheck.go @@ -46,13 +46,15 @@ const ( // boot logs and in the /ready envelope, so operators and tooling can branch on // them. Do not rename without a deprecation. const ( - CodeDBRoleBypassesRLS = "PRODCHECK_DB_ROLE_BYPASSES_RLS" //nolint:gosec // stable status code, not a credential + // #nosec G101 -- stable status code, not a credential + CodeDBRoleBypassesRLS = "PRODCHECK_DB_ROLE_BYPASSES_RLS" //nolint:gosec CodeTenantTableRLSMissing = "PRODCHECK_TENANT_TABLE_RLS_MISSING" - CodeCORSWildcardCreds = "PRODCHECK_CORS_WILDCARD_CREDENTIALED" //nolint:gosec // stable status code, not a credential - CodeSecretsDevKey = "PRODCHECK_SECRETS_DEV_KEY" - CodeStorageNotIsolated = "PRODCHECK_STORAGE_NOT_ISOLATED" - CodeSandboxNetworkOpen = "PRODCHECK_SANDBOX_NETWORK_OPEN" - CodeCatalogUnavailable = "PRODCHECK_CATALOG_UNAVAILABLE" + // #nosec G101 -- stable status code, not a credential + CodeCORSWildcardCreds = "PRODCHECK_CORS_WILDCARD_CREDENTIALED" //nolint:gosec + CodeSecretsDevKey = "PRODCHECK_SECRETS_DEV_KEY" + CodeStorageNotIsolated = "PRODCHECK_STORAGE_NOT_ISOLATED" + CodeSandboxNetworkOpen = "PRODCHECK_SANDBOX_NETWORK_OPEN" + CodeCatalogUnavailable = "PRODCHECK_CATALOG_UNAVAILABLE" ) // Result is one check outcome. diff --git a/services/runtime/internal/retention/retention.go b/services/runtime/internal/retention/retention.go index 4a68c86f..9915c97d 100644 --- a/services/runtime/internal/retention/retention.go +++ b/services/runtime/internal/retention/retention.go @@ -7,6 +7,7 @@ import ( "database/sql" "fmt" "regexp" + "strings" "time" ) @@ -88,6 +89,10 @@ func runPolicy(ctx context.Context, db *sql.DB, p Policy) (Report, error) { if err := ctx.Err(); err != nil { return report, err } + table := quoteIdent(p.Table) + col := quoteIdent(p.OrderColumn) + // Identifiers are regex-validated and quoted; batch is an int we + // control. Sprintf is only used to splice those safe tokens. q := fmt.Sprintf(` delete from %s where ctid in ( @@ -96,7 +101,7 @@ func runPolicy(ctx context.Context, db *sql.DB, p Policy) (Report, error) { order by %s limit %d ) - `, p.Table, p.Table, p.OrderColumn, p.OrderColumn, batch) + `, table, table, col, col, batch) // #nosec G201 -- identifiers validated + quoted res, err := db.ExecContext(ctx, q, cutoff) if err != nil { return report, err @@ -111,6 +116,10 @@ func runPolicy(ctx context.Context, db *sql.DB, p Policy) (Report, error) { var identRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) +func quoteIdent(name string) string { + return `"` + strings.ReplaceAll(name, `"`, `""`) + `"` +} + func validatePolicy(p Policy) error { if !identRE.MatchString(p.Table) { return fmt.Errorf("retention: invalid table %q", p.Table) diff --git a/services/runtime/internal/sandbox/adapters/docker/adapter.go b/services/runtime/internal/sandbox/adapters/docker/adapter.go index ae27126b..e62263ed 100644 --- a/services/runtime/internal/sandbox/adapters/docker/adapter.go +++ b/services/runtime/internal/sandbox/adapters/docker/adapter.go @@ -45,6 +45,7 @@ import ( "fmt" "io" "log/slog" + "math" "path" "strings" "sync" @@ -200,7 +201,7 @@ func (a *Adapter) Run(ctx context.Context, spec sandbox.RunSpec) (*sandbox.RunRe if err != nil { return nil, fmt.Errorf("container create: %w", err) } - defer a.cleanup(containerID) + defer a.cleanup(runCtx, containerID) a.trackRun(spec.ID, containerID) defer a.untrackRun(spec.ID) @@ -507,7 +508,7 @@ func (a *Adapter) observeStats( if mem == 0 { mem = stat.MemoryStats.Usage } - mbi := int(mem / (1024 * 1024)) + mbi := int(min(mem/(1024*1024), uint64(math.MaxInt))) if mbi > memPeakMB { memPeakMB = mbi } @@ -579,11 +580,11 @@ func (a *Adapter) persistLogs(ctx context.Context, spec sandbox.RunSpec, stdout, // cleanup removes the container regardless of exit status. Uses a // short background context so an already-cancelled run still gets its // container reaped. -func (a *Adapter) cleanup(containerID string) { +func (a *Adapter) cleanup(parent context.Context, containerID string) { if containerID == "" { return } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(context.WithoutCancel(parent), 10*time.Second) defer cancel() if err := a.cli.ContainerRemove(ctx, containerID, container.RemoveOptions{ Force: true, @@ -659,7 +660,7 @@ func (a *Adapter) Stream(ctx context.Context, spec sandbox.RunSpec) (<-chan sand cancel() close(linesCh) a.untrackRun(spec.ID) - a.cleanup(containerID) + a.cleanup(runCtx, containerID) return nil, nil, err } @@ -667,7 +668,7 @@ func (a *Adapter) Stream(ctx context.Context, spec sandbox.RunSpec) (<-chan sand cancel() close(linesCh) a.untrackRun(spec.ID) - a.cleanup(containerID) + a.cleanup(runCtx, containerID) return nil, nil, fmt.Errorf("container start: %w", err) } @@ -677,7 +678,7 @@ func (a *Adapter) Stream(ctx context.Context, spec sandbox.RunSpec) (<-chan sand defer close(linesCh) defer close(resultCh) defer a.untrackRun(spec.ID) - defer a.cleanup(containerID) + defer a.cleanup(runCtx, containerID) logsReader, err := a.cli.ContainerLogs(runCtx, containerID, container.LogsOptions{ ShowStdout: true, ShowStderr: true, Follow: true, @@ -735,7 +736,7 @@ func (a *Adapter) Stream(ctx context.Context, spec sandbox.RunSpec) (<-chan sand case e := <-waitErr: if errors.Is(e, context.DeadlineExceeded) || errors.Is(runCtx.Err(), context.DeadlineExceeded) { status = sandbox.StatusTimeout - _ = a.cli.ContainerStop(context.Background(), containerID, container.StopOptions{}) + _ = a.cli.ContainerStop(context.WithoutCancel(runCtx), containerID, container.StopOptions{}) } else { runErr = e status = sandbox.StatusFailed diff --git a/services/runtime/internal/sandbox/adapters/e2b/adapter.go b/services/runtime/internal/sandbox/adapters/e2b/adapter.go index 2414935f..cc6ca4b3 100644 --- a/services/runtime/internal/sandbox/adapters/e2b/adapter.go +++ b/services/runtime/internal/sandbox/adapters/e2b/adapter.go @@ -413,7 +413,11 @@ func (a *Adapter) execStream(ctx context.Context, s createSandboxResponse, spec return "", "", 0, fmt.Errorf("start message too large: %d bytes", len(payload)) } frame := make([]byte, 5+len(payload)) - binary.BigEndian.PutUint32(frame[1:5], uint32(len(payload))) //nolint:gosec // length bounded by the MaxUint32 check above + payloadLen, err := uint32FromLen(len(payload)) + if err != nil { + return "", "", 0, err + } + binary.BigEndian.PutUint32(frame[1:5], payloadLen) copy(frame[5:], payload) url := a.envdBase + "/process.Process/Start" @@ -630,3 +634,10 @@ func urlQueryEscape(s string) string { } return b.String() } + +func uint32FromLen(n int) (uint32, error) { + if n < 0 || n > math.MaxUint32 { + return 0, fmt.Errorf("length out of uint32 range: %d", n) + } + return uint32(n), nil // #nosec G115 -- range-checked above +} diff --git a/services/runtime/internal/secrets/kms.go b/services/runtime/internal/secrets/kms.go index da567913..e814ac4c 100644 --- a/services/runtime/internal/secrets/kms.go +++ b/services/runtime/internal/secrets/kms.go @@ -9,6 +9,7 @@ import ( "fmt" "log/slog" "os" + "path/filepath" "strings" cloudkms "cloud.google.com/go/kms/apiv1" @@ -173,7 +174,7 @@ func loadAzureKMSCipher(ctx context.Context) (*Cipher, error) { func encryptedDataKeyFromEnv() ([]byte, error) { if file := strings.TrimSpace(os.Getenv("AF_STACK_KMS_ENCRYPTED_DATA_KEY_FILE")); file != "" { - raw, err := os.ReadFile(file) + raw, err := os.ReadFile(filepath.Clean(file)) // #nosec G304,G703 -- operator-supplied KMS key file if err != nil { return nil, fmt.Errorf("secrets: read AF_STACK_KMS_ENCRYPTED_DATA_KEY_FILE: %w", err) } diff --git a/services/runtime/internal/server/admin_brand.go b/services/runtime/internal/server/admin_brand.go index e3ecbee0..b5657b9e 100644 --- a/services/runtime/internal/server/admin_brand.go +++ b/services/runtime/internal/server/admin_brand.go @@ -161,7 +161,7 @@ func (s *Server) handleAdminDeleteBrand(w http.ResponseWriter, r *http.Request) func readBrandYAML() (map[string]any, string, error) { for _, p := range []string{"brand.yaml", "../../brand.yaml", "/app/brand.yaml"} { abs, _ := filepath.Abs(p) - data, err := os.ReadFile(p) + data, err := os.ReadFile(filepath.Clean(p)) // #nosec G304 -- fixed brand.yaml search paths if err == nil { var out map[string]any if err := yaml.Unmarshal(data, &out); err != nil { diff --git a/services/runtime/internal/server/admin_demo.go b/services/runtime/internal/server/admin_demo.go index c1926bfe..8b278b4a 100644 --- a/services/runtime/internal/server/admin_demo.go +++ b/services/runtime/internal/server/admin_demo.go @@ -58,11 +58,11 @@ type demoSeedRemoved struct { // fills 20+ rows, sparklines have shape, but the seed completes in well // under a second. const ( - demoGatewayRows = 200 - demoCostRows = 20 - demoWebhookRows = 6 + demoGatewayRows = 200 + demoCostRows = 20 + demoWebhookRows = 6 demoActivityRows = 30 - demoTenantID = "00000000-0000-0000-0000-000000000000" + demoTenantID = "00000000-0000-0000-0000-000000000000" ) func (s *Server) registerAdminDemoRoutes() { @@ -171,14 +171,14 @@ func seedDemoRows(ctx context.Context, pool *pgxpool.Pool) (demoSeedInserted, er agents := []string{"supportdesk.reply_plan", "supportdesk.classify_issue", "demo.echo", "demo.summarise"} for i := 0; i < demoGatewayRows; i++ { // Spread across 24h, denser near "now" so the rpm tile is non-zero. - ageSecs := int(rand.Float64() * 48 * 3600) + ageSecs := int(demoFrac() * 48 * 3600) created := now.Add(-time.Duration(ageSecs) * time.Second) - agent := agents[rand.IntN(len(agents))] + agent := agents[demoIntN(len(agents))] endpoint := "/api/v1/execute/" + agent // 8% failure overall. statusCode := 200 - if rand.Float64() < 0.08 { - switch rand.IntN(4) { + if demoFrac() < 0.08 { + switch demoIntN(4) { case 0: statusCode = 400 case 1: @@ -189,7 +189,7 @@ func seedDemoRows(ctx context.Context, pool *pgxpool.Pool) (demoSeedInserted, er statusCode = 500 } } - durationMs := 80 + rand.IntN(1200) + durationMs := 80 + demoIntN(1200) _, err := pool.Exec(ctx, ` insert into suite_gateway_requests (tenant_id, endpoint, method, status_code, duration_ms, created_at) @@ -209,13 +209,13 @@ func seedDemoRows(ctx context.Context, pool *pgxpool.Pool) (demoSeedInserted, er "openrouter/google/gemini-flash-1.5", } for i := 0; i < demoCostRows; i++ { - ageSecs := int(rand.Float64() * 48 * 3600) + ageSecs := int(demoFrac() * 48 * 3600) occurred := now.Add(-time.Duration(ageSecs) * time.Second) - model := models[rand.IntN(len(models))] + model := models[demoIntN(len(models))] // Cost values: realistic per-call spend (~$0.0001..$0.01). - costUSD := 0.0001 + rand.Float64()*0.0099 - promptTokens := 40 + rand.IntN(2000) - completionTokens := 20 + rand.IntN(800) + costUSD := 0.0001 + demoFrac()*0.0099 + promptTokens := 40 + demoIntN(2000) + completionTokens := 20 + demoIntN(800) // agent = 'demo-seed' is the sentinel wipeDemoRows uses. _, err := pool.Exec(ctx, ` insert into suite_cost_events @@ -233,7 +233,7 @@ func seedDemoRows(ctx context.Context, pool *pgxpool.Pool) (demoSeedInserted, er eventTypes := []string{"run.completed", "tenant.created", "budget.threshold_crossed", "support.reply", "demo.ping", "audit.recorded"} dirs := []string{"outbound", "outbound", "inbound", "outbound", "inbound", "outbound"} for i := 0; i < demoWebhookRows; i++ { - ageSecs := int(rand.Float64() * 48 * 3600) + ageSecs := int(demoFrac() * 48 * 3600) created := now.Add(-time.Duration(ageSecs) * time.Second) _, err := pool.Exec(ctx, ` insert into suite_webhook_deliveries @@ -255,9 +255,9 @@ func seedDemoRows(ctx context.Context, pool *pgxpool.Pool) (demoSeedInserted, er actions := []string{"tenant.created", "user.signed_in", "api_key.issued", "budget.set", "config.updated", "feature.toggled", "audit.exported"} resourceTypes := []string{"tenant", "user", "api_key", "budget", "config", "feature", "audit"} for i := 0; i < demoActivityRows; i++ { - ageSecs := int(rand.Float64() * 48 * 3600) + ageSecs := int(demoFrac() * 48 * 3600) occurred := now.Add(-time.Duration(ageSecs) * time.Second) - idx := rand.IntN(len(actions)) + idx := demoIntN(len(actions)) _, err := pool.Exec(ctx, ` insert into suite_user_activity (tenant_id, actor_type, action, resource_type, resource_id, metadata, occurred_at) @@ -274,3 +274,12 @@ func seedDemoRows(ctx context.Context, pool *pgxpool.Pool) (demoSeedInserted, er return out, nil } + +// demoFrac / demoIntN wrap math/rand for non-secret demo fixture jitter. +func demoFrac() float64 { + return rand.Float64() // #nosec G404 -- demo seed data, not a secret +} + +func demoIntN(n int) int { + return rand.IntN(n) // #nosec G404 -- demo seed data, not a secret +} diff --git a/services/runtime/internal/server/admin_integrations.go b/services/runtime/internal/server/admin_integrations.go index 35af9ae0..847741e2 100644 --- a/services/runtime/internal/server/admin_integrations.go +++ b/services/runtime/internal/server/admin_integrations.go @@ -186,7 +186,7 @@ var integrationFieldDefaults = map[string]string{ // that are optional without a literal default value. Fields with neither // a default nor a note render as required. Every field's classification // must match what the consuming adapter actually enforces. -var integrationFieldNotes = map[string]string{ +var integrationFieldNotes = map[string]string{ // #nosec G101 -- UI help text, not a credential // Browserbase infers the project from a single-project API key. "browserbase_project_id": "Optional — inferred from the API key when left blank.", // The remote-adapter sidecar protocol enforces bearer auth only when diff --git a/services/runtime/internal/server/gdpr.go b/services/runtime/internal/server/gdpr.go index 0cfdc10f..a83bafea 100644 --- a/services/runtime/internal/server/gdpr.go +++ b/services/runtime/internal/server/gdpr.go @@ -146,7 +146,7 @@ func (s *Server) lookupSuiteUserEmail(ctx context.Context, userID string) (strin } func (s *Server) exportSuiteUser(ctx context.Context, userID, email string) (map[string]any, error) { - queries := map[string]string{ + queries := map[string]string{ // #nosec G101 -- export query names, not credentials "suite_user": `select coalesce(jsonb_agg(to_jsonb(row)), '[]'::jsonb) from (select id::text, email, name, avatar_url, created_at, deleted_at from suite_users where id = $1::uuid) row`, diff --git a/services/runtime/internal/server/llm.go b/services/runtime/internal/server/llm.go index 5cdd2bec..3117f73a 100644 --- a/services/runtime/internal/server/llm.go +++ b/services/runtime/internal/server/llm.go @@ -592,7 +592,7 @@ func (s *Server) handleLLMChatCompletions(w http.ResponseWriter, r *http.Request // Best-effort store on a detached context so a slow write (or a // client disconnect) never delays or cancels the response. go func(payload []byte, pt, ct int) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 2*time.Second) defer cancel() if err := s.llmCache.Put(ctx, tenantID, req.Model, cacheHash, payload, pt, ct, llmCacheTTL); err != nil { diff --git a/services/runtime/internal/server/secrets.go b/services/runtime/internal/server/secrets.go index 804d3483..2ed15059 100644 --- a/services/runtime/internal/server/secrets.go +++ b/services/runtime/internal/server/secrets.go @@ -6,12 +6,12 @@ // Every response shape must match the zod schemas in // apps/dashboard/src/lib/api.ts exactly: // -// GET /api/v1/secrets -> SecretListSchema -// GET /api/v1/secrets/{key} -> SecretMetadataSchema -// POST /api/v1/secrets/{key}/reveal -> SecretValueSchema -// PUT /api/v1/secrets/{key} -> SecretMetadataSchema (body: PutSecretInput) -// DELETE /api/v1/secrets/{key} -> {"deleted": true} -// POST /api/v1/secrets/{key}/rotate -> SecretMetadataSchema (body: {"value": string}) +// GET /api/v1/secrets -> SecretListSchema +// GET /api/v1/secrets/{key} -> SecretMetadataSchema +// POST /api/v1/secrets/{key}/reveal -> SecretValueSchema +// PUT /api/v1/secrets/{key} -> SecretMetadataSchema (body: PutSecretInput) +// DELETE /api/v1/secrets/{key} -> {"deleted": true} +// POST /api/v1/secrets/{key}/rotate -> SecretMetadataSchema (body: {"value": string}) // // Plaintext only ever leaves the runtime via /reveal. List and Get // responses MUST NOT include the value field. @@ -220,7 +220,7 @@ func (s *Server) recordSecretReveal(r *http.Request, tenantID, key string) { resourceID := key go func() { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 2*time.Second) defer cancel() // Bind the write connection to the row's tenant so the audit-log RLS // WITH CHECK passes (the pool's PrepareConn hook reads ctx). Secrets @@ -420,4 +420,4 @@ func (s *Server) handleRotateSecret(w http.ResponseWriter, r *http.Request) { return } writeJSON(w, http.StatusOK, meta) -} \ No newline at end of file +} diff --git a/services/runtime/internal/server/shipwright.go b/services/runtime/internal/server/shipwright.go index 2c8aa9ee..254d30f5 100644 --- a/services/runtime/internal/server/shipwright.go +++ b/services/runtime/internal/server/shipwright.go @@ -23,7 +23,7 @@ import ( const ( defaultShipwrightAgentCall = "shipwright.build" - envShipwrightAgentCall = "AF_STACK_SHIPWRIGHT_AGENT_CALL" + envShipwrightAgentCall = "AF_STACK_SHIPWRIGHT_AGENT_CALL" // #nosec G101 -- env var name, not a credential ) type ShipwrightStore interface { diff --git a/services/runtime/internal/server/storage.go b/services/runtime/internal/server/storage.go index 650b5924..b82a9a57 100644 --- a/services/runtime/internal/server/storage.go +++ b/services/runtime/internal/server/storage.go @@ -420,6 +420,7 @@ func (s *Server) handleStorageDownload(w http.ResponseWriter, r *http.Request) { return } w.Header().Set("Content-Type", transformedCT) + w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Content-Length", strconv.Itoa(len(out))) w.Header().Set("Cache-Control", "private, max-age=300") if obj.ETag != "" { @@ -427,7 +428,7 @@ func (s *Server) handleStorageDownload(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Last-Modified", obj.LastModified.UTC().Format(http.TimeFormat)) w.WriteHeader(http.StatusOK) - _, _ = w.Write(out) + _, _ = w.Write(out) // #nosec G705 -- image bytes with an image/* Content-Type + nosniff return } w.Header().Set("Content-Type", ct) diff --git a/services/runtime/internal/server/tenant_resolver.go b/services/runtime/internal/server/tenant_resolver.go index ba48470c..218dc8bc 100644 --- a/services/runtime/internal/server/tenant_resolver.go +++ b/services/runtime/internal/server/tenant_resolver.go @@ -46,6 +46,7 @@ import ( "log/slog" "net/http" "strings" + "time" "github.com/Agent-Field/backai/services/runtime/internal/hooks" "github.com/Agent-Field/backai/services/runtime/internal/tenancy" @@ -259,7 +260,7 @@ func (s *Server) tenantResolver(next http.Handler) http.Handler { // tenancy.Manager (tests), and a nil manager must not panic. if s.tenancy != nil { go func(id string) { - touchCtx, cancel := context.WithTimeout(context.Background(), 2_000_000_000) + touchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second) defer cancel() s.tenancy.TouchKey(touchCtx, id) }(k.ID) diff --git a/services/runtime/internal/skills/installer.go b/services/runtime/internal/skills/installer.go index 3fd07e6a..9aeae882 100644 --- a/services/runtime/internal/skills/installer.go +++ b/services/runtime/internal/skills/installer.go @@ -116,12 +116,12 @@ func (i *Installer) installLocal(src Source, tenantID string) (Skill, error) { if info.IsDir() { tomlPath := filepath.Join(abs, "skill.toml") jsonPath := filepath.Join(abs, "skill.json") - if data, err := os.ReadFile(tomlPath); err == nil { + if data, err := os.ReadFile(filepath.Clean(tomlPath)); err == nil { // #nosec G304 -- local skill manifest under resolved abs root if err := toml.Unmarshal(data, &mfst); err != nil { return Skill{}, fmt.Errorf("%w: skill.toml: %v", ErrSourceUnreadable, err) } read = true - } else if data, err := os.ReadFile(jsonPath); err == nil { + } else if data, err := os.ReadFile(filepath.Clean(jsonPath)); err == nil { // #nosec G304 -- local skill manifest under resolved abs root if err := json.Unmarshal(data, &mfst); err != nil { return Skill{}, fmt.Errorf("%w: skill.json: %v", ErrSourceUnreadable, err) } @@ -129,7 +129,7 @@ func (i *Installer) installLocal(src Source, tenantID string) (Skill, error) { } } else { // Caller pointed straight at a manifest file. - data, err := os.ReadFile(abs) + data, err := os.ReadFile(filepath.Clean(abs)) // #nosec G304 -- local skill manifest under resolved abs root if err != nil { return Skill{}, fmt.Errorf("%w: %v", ErrSourceUnreadable, err) } @@ -246,4 +246,4 @@ func appendDefaultIfNil(values []string, defaults ...string) []string { out := make([]string, len(defaults)) copy(out, defaults) return out -} \ No newline at end of file +} diff --git a/services/runtime/internal/webhooks/verify.go b/services/runtime/internal/webhooks/verify.go index 09d4585e..b11ef814 100644 --- a/services/runtime/internal/webhooks/verify.go +++ b/services/runtime/internal/webhooks/verify.go @@ -17,7 +17,7 @@ package webhooks import ( "crypto/hmac" - "crypto/sha1" + "crypto/sha1" // #nosec G505 -- legacy provider HMAC (GitHub "sha1=" signatures) "crypto/sha256" "encoding/hex" "fmt" @@ -69,4 +69,4 @@ func VerifyHMAC(secret string, body []byte, signature, algorithm string) error { return ErrSignatureMismatch } return nil -} \ No newline at end of file +} From 3a414e1c187454a501902e2b877b8609fb6551f9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 15:50:43 +0000 Subject: [PATCH 2/3] fix(security): confine local skill installs to OpenRoot manifests CodeQL flagged the three ReadFile sites in installLocal as path injection (user-controlled source). Read only skill.toml / skill.json through os.OpenRoot so the basename is a constant and sibling files cannot be opened. Signed-off-by: Cursor Agent Co-authored-by: Santosh kumar --- services/runtime/internal/skills/installer.go | 95 +++++++++++-------- .../runtime/internal/skills/interface_test.go | 51 ++++++++-- 2 files changed, 101 insertions(+), 45 deletions(-) diff --git a/services/runtime/internal/skills/installer.go b/services/runtime/internal/skills/installer.go index 9aeae882..339f970a 100644 --- a/services/runtime/internal/skills/installer.go +++ b/services/runtime/internal/skills/installer.go @@ -24,7 +24,9 @@ package skills import ( "encoding/json" + "errors" "fmt" + "io" "os" "path/filepath" "strings" @@ -109,52 +111,37 @@ func (i *Installer) installLocal(src Source, tenantID string) (Skill, error) { return Skill{}, fmt.Errorf("%w: %v", ErrSourceUnreadable, err) } - var ( - mfst manifest - read bool - ) - if info.IsDir() { - tomlPath := filepath.Join(abs, "skill.toml") - jsonPath := filepath.Join(abs, "skill.json") - if data, err := os.ReadFile(filepath.Clean(tomlPath)); err == nil { // #nosec G304 -- local skill manifest under resolved abs root - if err := toml.Unmarshal(data, &mfst); err != nil { - return Skill{}, fmt.Errorf("%w: skill.toml: %v", ErrSourceUnreadable, err) - } - read = true - } else if data, err := os.ReadFile(filepath.Clean(jsonPath)); err == nil { // #nosec G304 -- local skill manifest under resolved abs root - if err := json.Unmarshal(data, &mfst); err != nil { - return Skill{}, fmt.Errorf("%w: skill.json: %v", ErrSourceUnreadable, err) - } - read = true + dir := abs + if !info.IsDir() { + // Only the two canonical manifest names are readable as a file + // target so the open stays inside OpenRoot + a constant basename. + base := strings.ToLower(filepath.Base(abs)) + if base != "skill.toml" && base != "skill.json" { + return Skill{}, fmt.Errorf("%w: %q is neither skill.toml nor skill.json", ErrSourceUnreadable, abs) } - } else { - // Caller pointed straight at a manifest file. - data, err := os.ReadFile(filepath.Clean(abs)) // #nosec G304 -- local skill manifest under resolved abs root - if err != nil { - return Skill{}, fmt.Errorf("%w: %v", ErrSourceUnreadable, err) - } - switch strings.ToLower(filepath.Ext(abs)) { - case ".toml": - if err := toml.Unmarshal(data, &mfst); err != nil { - return Skill{}, fmt.Errorf("%w: %v", ErrSourceUnreadable, err) - } - case ".json": - if err := json.Unmarshal(data, &mfst); err != nil { - return Skill{}, fmt.Errorf("%w: %v", ErrSourceUnreadable, err) - } - default: - return Skill{}, fmt.Errorf("%w: %q is neither .toml nor .json", ErrSourceUnreadable, abs) - } - read = true + dir = filepath.Dir(abs) + } + + data, name, err := readSkillManifest(dir) + if err != nil { + return Skill{}, err } - if !read { - return Skill{}, fmt.Errorf("%w: no skill.toml or skill.json found at %s", ErrSourceUnreadable, abs) + var mfst manifest + switch name { + case "skill.toml": + if err := toml.Unmarshal(data, &mfst); err != nil { + return Skill{}, fmt.Errorf("%w: skill.toml: %v", ErrSourceUnreadable, err) + } + case "skill.json": + if err := json.Unmarshal(data, &mfst); err != nil { + return Skill{}, fmt.Errorf("%w: skill.json: %v", ErrSourceUnreadable, err) + } } if mfst.Name == "" { // Fall back to the directory name if the manifest omitted it. - mfst.Name = filepath.Base(abs) + mfst.Name = filepath.Base(dir) } if mfst.Version == "" { mfst.Version = "0.0.1" @@ -236,6 +223,36 @@ func nilIfEmpty(s string) *string { // appendDefaultIfNil ensures the slice is non-nil. When values is nil // and defaults is supplied, defaults wins so the wire emits an array // with sensible content rather than an empty placeholder. +// readSkillManifest opens only the two canonical manifest names under +// dir via os.OpenRoot so a source path cannot escape into sibling files. +func readSkillManifest(dir string) ([]byte, string, error) { + root, err := os.OpenRoot(dir) + if err != nil { + return nil, "", fmt.Errorf("%w: %v", ErrSourceUnreadable, err) + } + defer root.Close() + + for _, name := range []string{"skill.toml", "skill.json"} { + data, err := readRootFile(root, name) + if err == nil { + return data, name, nil + } + if !errors.Is(err, os.ErrNotExist) { + return nil, "", fmt.Errorf("%w: %s: %v", ErrSourceUnreadable, name, err) + } + } + return nil, "", fmt.Errorf("%w: no skill.toml or skill.json found at %s", ErrSourceUnreadable, dir) +} + +func readRootFile(root *os.Root, name string) ([]byte, error) { + f, err := root.Open(name) + if err != nil { + return nil, err + } + defer f.Close() + return io.ReadAll(f) +} + func appendDefaultIfNil(values []string, defaults ...string) []string { if values != nil { return values diff --git a/services/runtime/internal/skills/interface_test.go b/services/runtime/internal/skills/interface_test.go index 42eb2b1d..73ff256e 100644 --- a/services/runtime/internal/skills/interface_test.go +++ b/services/runtime/internal/skills/interface_test.go @@ -4,6 +4,8 @@ package skills import ( "errors" + "os" + "path/filepath" "strings" "testing" ) @@ -96,11 +98,11 @@ func TestParseSourceErrors(t *testing.T) { cases := []string{ "", " ", - "af-skill://", // missing vendor/name - "af-skill://onlyvendor", // vendor present, no name - "af-skill://acme/name@", // empty version after @ - "embedded:", // missing name - "unknown-format-no-prefix", // not classified + "af-skill://", // missing vendor/name + "af-skill://onlyvendor", // vendor present, no name + "af-skill://acme/name@", // empty version after @ + "embedded:", // missing name + "unknown-format-no-prefix", // not classified } for _, in := range cases { _, err := ParseSource(in) @@ -187,4 +189,41 @@ func TestStoreNilHasPool(t *testing.T) { if s2.HasPool() { t.Errorf("zero-pool Store.HasPool() = true, want false") } -} \ No newline at end of file +} + +func TestInstallLocalManifest(t *testing.T) { + t.Parallel() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "skill.toml"), []byte(` +name = "confine-me" +version = "1.0.0" +`), 0o600); err != nil { + t.Fatal(err) + } + // Sibling secret must not be readable via a crafted source. + if err := os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("nope"), 0o600); err != nil { + t.Fatal(err) + } + + inst := NewInstaller() + sk, err := inst.Install(dir, "") + if err != nil { + t.Fatalf("Install dir: %v", err) + } + if sk.Name != "confine-me" { + t.Errorf("Name = %q, want confine-me", sk.Name) + } + + sk, err = inst.Install(filepath.Join(dir, "skill.toml"), "") + if err != nil { + t.Fatalf("Install skill.toml: %v", err) + } + if sk.Name != "confine-me" { + t.Errorf("file target Name = %q, want confine-me", sk.Name) + } + + _, err = inst.Install(filepath.Join(dir, "secret.txt"), "") + if !errors.Is(err, ErrSourceUnreadable) { + t.Fatalf("Install secret.txt error = %v, want ErrSourceUnreadable", err) + } +} From 6895e93cf2015d3b4a06f7564b10d46b10656ba9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 16:11:10 +0000 Subject: [PATCH 3/3] fix(security): confine backup-test script and cover skill.json installs Review follow-up: resolve BACKUP_TEST_SCRIPT under the working tree (basename-only was bypassable) and add a json-only local skill install test so the OpenRoot confinement path is covered for both manifests. Signed-off-by: Cursor Agent Co-authored-by: Santosh kumar --- services/runtime/cmd/af-stack/backuptest.go | 38 +++++++++++++--- .../runtime/cmd/af-stack/backuptest_test.go | 44 +++++++++++++++++++ .../runtime/internal/skills/interface_test.go | 23 ++++++++++ 3 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 services/runtime/cmd/af-stack/backuptest_test.go diff --git a/services/runtime/cmd/af-stack/backuptest.go b/services/runtime/cmd/af-stack/backuptest.go index d9323da9..48ce5280 100644 --- a/services/runtime/cmd/af-stack/backuptest.go +++ b/services/runtime/cmd/af-stack/backuptest.go @@ -57,14 +57,10 @@ func registerBackupTestCron(sched *crons.SystemScheduler, log *slog.Logger) { // the timestamp. The command inherits the process env (AF_STACK_*_DATABASE_URL, // BACKUP_TEST_SCRATCH_URL, etc.). func runBackupRestoreTest(ctx context.Context, log *slog.Logger) error { - script := strings.TrimSpace(os.Getenv("BACKUP_TEST_SCRIPT")) - if script == "" { - script = "scripts/backup-restore-test.sh" - } - if filepath.Base(script) != "backup-restore-test.sh" { - return fmt.Errorf("backup-test: refused unexpected script %q", script) + script, err := resolveBackupTestScript() + if err != nil { + return err } - script = filepath.Clean(script) timeout := 10 * time.Minute if v := strings.TrimSpace(os.Getenv("BACKUP_TEST_TIMEOUT_SECONDS")); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { @@ -89,6 +85,34 @@ func runBackupRestoreTest(ctx context.Context, log *slog.Logger) error { return nil } +// resolveBackupTestScript returns the in-checkout verification script. +// BACKUP_TEST_SCRIPT may override the path, but the resolved file must +// stay under the process working directory and be named +// backup-restore-test.sh so an env var cannot point at /tmp/evil/.... +func resolveBackupTestScript() (string, error) { + script := strings.TrimSpace(os.Getenv("BACKUP_TEST_SCRIPT")) + if script == "" { + script = filepath.Join("scripts", "backup-restore-test.sh") + } + abs, err := filepath.Abs(script) + if err != nil { + return "", fmt.Errorf("backup-test: resolve script: %w", err) + } + abs = filepath.Clean(abs) + if filepath.Base(abs) != "backup-restore-test.sh" { + return "", fmt.Errorf("backup-test: refused unexpected script %q", script) + } + wd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("backup-test: working directory: %w", err) + } + rel, err := filepath.Rel(filepath.Clean(wd), abs) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("backup-test: refused unexpected script %q", script) + } + return abs, nil +} + // tailOutput returns the last limit bytes of command output for a bounded log. func tailOutput(b []byte, limit int) string { if len(b) <= limit { diff --git a/services/runtime/cmd/af-stack/backuptest_test.go b/services/runtime/cmd/af-stack/backuptest_test.go new file mode 100644 index 00000000..e0e63d23 --- /dev/null +++ b/services/runtime/cmd/af-stack/backuptest_test.go @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveBackupTestScript(t *testing.T) { + wd := t.TempDir() + t.Chdir(wd) + if err := os.MkdirAll("scripts", 0o750); err != nil { + t.Fatal(err) + } + allowed := filepath.Join(wd, "scripts", "backup-restore-test.sh") + if err := os.WriteFile(allowed, []byte("#!/bin/bash\n"), 0o700); err != nil { + t.Fatal(err) + } + + t.Setenv("BACKUP_TEST_SCRIPT", "") + got, err := resolveBackupTestScript() + if err != nil { + t.Fatalf("default: %v", err) + } + if got != filepath.Clean(allowed) { + t.Errorf("default = %q, want %q", got, allowed) + } + + t.Setenv("BACKUP_TEST_SCRIPT", "scripts/backup-restore-test.sh") + if _, err := resolveBackupTestScript(); err != nil { + t.Fatalf("relative override: %v", err) + } + + evil := filepath.Join(t.TempDir(), "backup-restore-test.sh") + if err := os.WriteFile(evil, []byte("x"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("BACKUP_TEST_SCRIPT", evil) + if _, err := resolveBackupTestScript(); err == nil { + t.Fatal("expected reject of script outside the working tree") + } +} diff --git a/services/runtime/internal/skills/interface_test.go b/services/runtime/internal/skills/interface_test.go index 73ff256e..bd568c6b 100644 --- a/services/runtime/internal/skills/interface_test.go +++ b/services/runtime/internal/skills/interface_test.go @@ -227,3 +227,26 @@ version = "1.0.0" t.Fatalf("Install secret.txt error = %v, want ErrSourceUnreadable", err) } } + +func TestInstallLocalJSONOnly(t *testing.T) { + t.Parallel() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "skill.json"), []byte(`{"name":"json-only","version":"2.0.0"}`), 0o600); err != nil { + t.Fatal(err) + } + sk, err := NewInstaller().Install(dir, "") + if err != nil { + t.Fatalf("Install json-only dir: %v", err) + } + if sk.Name != "json-only" || sk.Version != "2.0.0" { + t.Errorf("got %s@%s, want json-only@2.0.0", sk.Name, sk.Version) + } + + sk, err = NewInstaller().Install(filepath.Join(dir, "skill.json"), "") + if err != nil { + t.Fatalf("Install skill.json: %v", err) + } + if sk.Name != "json-only" { + t.Errorf("file target Name = %q, want json-only", sk.Name) + } +}