diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index ca7ad62f..17397253 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -87,6 +87,7 @@ jobs: cp dist/${{ matrix.artifact }} archgate tar -czf ${{ matrix.artifact }}.tar.gz archgate rm archgate + shasum -a 256 ${{ matrix.artifact }}.tar.gz > ${{ matrix.artifact }}.tar.gz.sha256 - name: Prepare release asset (Windows) if: runner.os == 'Windows' @@ -95,6 +96,7 @@ jobs: Copy-Item "dist/${{ matrix.artifact }}.exe" "archgate.exe" Compress-Archive -Path "archgate.exe" -DestinationPath "${{ matrix.artifact }}.zip" Remove-Item "archgate.exe" + (Get-FileHash "${{ matrix.artifact }}.zip" -Algorithm SHA256).Hash.ToLower() + " ${{ matrix.artifact }}.zip" | Out-File -Encoding ascii "${{ matrix.artifact }}.zip.sha256" - name: Upload release asset (Unix) if: runner.os != 'Windows' @@ -102,7 +104,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | TAG="${{ github.event.release.tag_name || inputs.tag }}" - gh release upload "$TAG" "${{ matrix.artifact }}.tar.gz" --clobber + gh release upload "$TAG" "${{ matrix.artifact }}.tar.gz" "${{ matrix.artifact }}.tar.gz.sha256" --clobber - name: Upload release asset (Windows) if: runner.os == 'Windows' @@ -111,4 +113,4 @@ jobs: GH_TOKEN: ${{ github.token }} run: | $tag = "${{ github.event.release.tag_name || inputs.tag }}" - gh release upload $tag "${{ matrix.artifact }}.zip" --clobber + gh release upload $tag "${{ matrix.artifact }}.zip" "${{ matrix.artifact }}.zip.sha256" --clobber diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index e5b8dbb7..d964093f 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -219,6 +219,7 @@ export default defineConfig({ { label: "Copilot CLI Plugin", slug: "guides/copilot-cli-plugin" }, { label: "Cursor Integration", slug: "guides/cursor-integration" }, { label: "Pre-commit Hooks", slug: "guides/pre-commit-hooks" }, + { label: "Security", slug: "guides/security" }, ], }, { diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index b4e5dcdf..b75b35f5 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -1676,6 +1676,148 @@ Each command runs independently. If any command exits with a non-zero code, the --- +## Guides: Security + +Source: https://cli.archgate.dev/guides/security/ + +Archgate executes TypeScript rules from `.rules.ts` files in your repository. This page explains the trust model, what rules can and cannot do, and how to run checks safely. + +## Trust model + +**`.rules.ts` files are executable code.** When you run `archgate check`, the CLI dynamically imports every `.rules.ts` companion file and runs its `check` functions. This is equivalent to running `bun .archgate/adrs/*.rules.ts` -- the code has the same capabilities as any other script on your machine. + +This means: + +- Only run `archgate check` on repositories you trust. +- Review `.rules.ts` files with the same scrutiny as any other source code in the project. +- In open-source projects, treat `.rules.ts` changes in pull requests as security-sensitive. + +### What rules can access + +Rules receive a `RuleContext` object with sandboxed file operations. All `RuleContext` methods (`readFile`, `readJSON`, `grep`, `grepFiles`, `glob`) are restricted to the project root directory -- path traversal via `../`, absolute paths, and symbolic links are blocked and throw an error. + +However, because rules are standard TypeScript modules, they can also use any Bun/Node.js API directly: + +| Capability | Via RuleContext | Via direct API calls | +| -------------------------- | --------------- | ------------------------ | +| Read files in project | Yes (sandboxed) | Yes (unrestricted) | +| Read files outside project | Blocked | Yes (`Bun.file()`, `fs`) | +| Network requests | No API provided | Yes (`fetch()`) | +| Spawn subprocesses | No API provided | Yes (`Bun.spawn()`) | +| Environment variables | No API provided | Yes (`process.env`) | + +The `RuleContext` sandbox prevents accidental path traversal in well-intentioned rules. It does not protect against deliberately malicious code -- a rule that imports `fs` directly can bypass the sandbox. + +### What rules cannot do + +- **Write files** -- the `RuleContext` API is read-only. Rules report violations but cannot modify the codebase. +- **Escape the 30-second timeout** -- each rule is killed after 30 seconds of wall-clock time. +- **Affect other rules** -- rules from different ADRs run in parallel but share no mutable state through the context API. + +## CI/CD best practices + +Running `archgate check` in CI is safe when you control the repository content. Extra care is needed for pull requests from external contributors. + +### Trusted branches + +For pushes to `main` or other protected branches, `archgate check` runs code that has already been reviewed and merged. This is safe: + +```yaml +on: + push: + branches: [main] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: archgate/check-action@v1 +``` + +### Pull requests from forks + +When a pull request comes from a fork, the `.rules.ts` files in the PR may contain arbitrary code. This is the same risk as running any untrusted CI script. + +**Option 1: Require approval before running.** Use GitHub's environment protection rules or `pull_request_target` with manual approval to gate CI on review: + +```yaml +on: + pull_request_target: + +jobs: + check: + runs-on: ubuntu-latest + environment: pr-check # Requires manual approval + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + - uses: archgate/check-action@v1 +``` + +**Option 2: Only run checks on trusted files.** Use a separate workflow that checks out the base branch's `.rules.ts` files and runs them against the PR's source files. This ensures only reviewed rules execute. + +**Option 3: Skip checks on fork PRs.** If your rules are primarily for internal governance, skip automated checks on fork PRs and run them manually after review: + +```yaml +on: + pull_request: + +jobs: + check: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: archgate/check-action@v1 +``` + +### Least-privilege runners + +Run `archgate check` on runners with minimal permissions. The job only needs read access to the repository -- no secrets, deployment keys, or write permissions are required: + +```yaml +jobs: + check: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: archgate/check-action@v1 +``` + +## Local development + +### Reviewing rules in new repositories + +When cloning or forking a repository that uses Archgate, review the `.archgate/adrs/*.rules.ts` files before running `archgate check` for the first time. Look for: + +- Imports of `fs`, `child_process`, `net`, or other system modules +- Calls to `fetch()`, `Bun.spawn()`, or `Bun.file()` outside the `RuleContext` API +- Top-level code that runs on import (before the `check` function is called) + +Well-behaved rules only use the `RuleContext` methods (`ctx.readFile`, `ctx.grep`, `ctx.glob`, etc.) and `ctx.report` for output. + +### Credentials + +The `archgate login` command stores an authentication token at `~/.archgate/credentials` with owner-only file permissions (`0600` on Unix). This token is used for plugin installation and is never sent to third parties beyond the Archgate plugins service. + +- Do not commit `~/.archgate/credentials` to version control. +- Do not share the token in CI logs. Plugin installation commands pass credentials via authenticated URLs to git, which may appear in process listings. Avoid running `archgate plugin install` with verbose logging in shared CI environments. +- To revoke access, run `archgate logout` or delete `~/.archgate/credentials`. + +### Self-update integrity + +When you run `archgate upgrade`, the CLI downloads the release binary from GitHub Releases and verifies its SHA256 checksum before extraction. If the checksum does not match, the upgrade is aborted. This protects against tampered downloads due to network interception or compromised mirrors. + +## Reporting vulnerabilities + +If you discover a security issue in Archgate, please report it responsibly by opening a [GitHub issue](https://github.com/archgate/cli/issues) or contacting the maintainers directly. Do not include exploit code in public issues. + +--- + ## Guides: VS Code Plugin Source: https://cli.archgate.dev/guides/vscode-plugin/ diff --git a/docs/src/content/docs/guides/security.mdx b/docs/src/content/docs/guides/security.mdx new file mode 100644 index 00000000..db4f7e63 --- /dev/null +++ b/docs/src/content/docs/guides/security.mdx @@ -0,0 +1,140 @@ +--- +title: Security +description: Understand the Archgate CLI trust model, how rules are executed, and best practices for running checks safely in CI/CD and local development. +--- + +Archgate executes TypeScript rules from `.rules.ts` files in your repository. This page explains the trust model, what rules can and cannot do, and how to run checks safely. + +## Trust model + +**`.rules.ts` files are executable code.** When you run `archgate check`, the CLI dynamically imports every `.rules.ts` companion file and runs its `check` functions. This is equivalent to running `bun .archgate/adrs/*.rules.ts` -- the code has the same capabilities as any other script on your machine. + +This means: + +- Only run `archgate check` on repositories you trust. +- Review `.rules.ts` files with the same scrutiny as any other source code in the project. +- In open-source projects, treat `.rules.ts` changes in pull requests as security-sensitive. + +### What rules can access + +Rules receive a `RuleContext` object with sandboxed file operations. All `RuleContext` methods (`readFile`, `readJSON`, `grep`, `grepFiles`, `glob`) are restricted to the project root directory -- path traversal via `../`, absolute paths, and symbolic links are blocked and throw an error. + +However, because rules are standard TypeScript modules, they can also use any Bun/Node.js API directly: + +| Capability | Via RuleContext | Via direct API calls | +| -------------------------- | --------------- | ------------------------ | +| Read files in project | Yes (sandboxed) | Yes (unrestricted) | +| Read files outside project | Blocked | Yes (`Bun.file()`, `fs`) | +| Network requests | No API provided | Yes (`fetch()`) | +| Spawn subprocesses | No API provided | Yes (`Bun.spawn()`) | +| Environment variables | No API provided | Yes (`process.env`) | + +The `RuleContext` sandbox prevents accidental path traversal in well-intentioned rules. It does not protect against deliberately malicious code -- a rule that imports `fs` directly can bypass the sandbox. + +### What rules cannot do + +- **Write files** -- the `RuleContext` API is read-only. Rules report violations but cannot modify the codebase. +- **Escape the 30-second timeout** -- each rule is killed after 30 seconds of wall-clock time. +- **Affect other rules** -- rules from different ADRs run in parallel but share no mutable state through the context API. + +## CI/CD best practices + +Running `archgate check` in CI is safe when you control the repository content. Extra care is needed for pull requests from external contributors. + +### Trusted branches + +For pushes to `main` or other protected branches, `archgate check` runs code that has already been reviewed and merged. This is safe: + +```yaml +on: + push: + branches: [main] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: archgate/check-action@v1 +``` + +### Pull requests from forks + +When a pull request comes from a fork, the `.rules.ts` files in the PR may contain arbitrary code. This is the same risk as running any untrusted CI script. + +**Option 1: Require approval before running.** Use GitHub's environment protection rules or `pull_request_target` with manual approval to gate CI on review: + +```yaml +on: + pull_request_target: + +jobs: + check: + runs-on: ubuntu-latest + environment: pr-check # Requires manual approval + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + - uses: archgate/check-action@v1 +``` + +**Option 2: Only run checks on trusted files.** Use a separate workflow that checks out the base branch's `.rules.ts` files and runs them against the PR's source files. This ensures only reviewed rules execute. + +**Option 3: Skip checks on fork PRs.** If your rules are primarily for internal governance, skip automated checks on fork PRs and run them manually after review: + +```yaml +on: + pull_request: + +jobs: + check: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: archgate/check-action@v1 +``` + +### Least-privilege runners + +Run `archgate check` on runners with minimal permissions. The job only needs read access to the repository -- no secrets, deployment keys, or write permissions are required: + +```yaml +jobs: + check: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: archgate/check-action@v1 +``` + +## Local development + +### Reviewing rules in new repositories + +When cloning or forking a repository that uses Archgate, review the `.archgate/adrs/*.rules.ts` files before running `archgate check` for the first time. Look for: + +- Imports of `fs`, `child_process`, `net`, or other system modules +- Calls to `fetch()`, `Bun.spawn()`, or `Bun.file()` outside the `RuleContext` API +- Top-level code that runs on import (before the `check` function is called) + +Well-behaved rules only use the `RuleContext` methods (`ctx.readFile`, `ctx.grep`, `ctx.glob`, etc.) and `ctx.report` for output. + +### Credentials + +The `archgate login` command stores an authentication token at `~/.archgate/credentials` with owner-only file permissions (`0600` on Unix). This token is used for plugin installation and is never sent to third parties beyond the Archgate plugins service. + +- Do not commit `~/.archgate/credentials` to version control. +- Do not share the token in CI logs. Plugin installation commands pass credentials via authenticated URLs to git, which may appear in process listings. Avoid running `archgate plugin install` with verbose logging in shared CI environments. +- To revoke access, run `archgate logout` or delete `~/.archgate/credentials`. + +### Self-update integrity + +When you run `archgate upgrade`, the CLI downloads the release binary from GitHub Releases and verifies its SHA256 checksum before extraction. If the checksum does not match, the upgrade is aborted. This protects against tampered downloads due to network interception or compromised mirrors. + +## Reporting vulnerabilities + +If you discover a security issue in Archgate, please report it responsibly by opening a [GitHub issue](https://github.com/archgate/cli/issues) or contacting the maintainers directly. Do not include exploit code in public issues. diff --git a/docs/src/content/docs/pt-br/guides/security.mdx b/docs/src/content/docs/pt-br/guides/security.mdx new file mode 100644 index 00000000..75b81764 --- /dev/null +++ b/docs/src/content/docs/pt-br/guides/security.mdx @@ -0,0 +1,140 @@ +--- +title: Seguranca +description: Entenda o modelo de confianca do Archgate CLI, como as regras sao executadas e as melhores praticas para rodar verificacoes com seguranca em CI/CD e desenvolvimento local. +--- + +O Archgate executa regras TypeScript a partir de arquivos `.rules.ts` no seu repositorio. Esta pagina explica o modelo de confianca, o que as regras podem e nao podem fazer, e como rodar verificacoes com seguranca. + +## Modelo de confianca + +**Arquivos `.rules.ts` sao codigo executavel.** Quando voce roda `archgate check`, o CLI importa dinamicamente cada arquivo `.rules.ts` complementar e executa suas funcoes `check`. Isso equivale a rodar `bun .archgate/adrs/*.rules.ts` -- o codigo tem as mesmas capacidades que qualquer outro script na sua maquina. + +Isso significa: + +- Rode `archgate check` apenas em repositorios nos quais voce confia. +- Revise arquivos `.rules.ts` com o mesmo rigor que qualquer outro codigo-fonte do projeto. +- Em projetos open-source, trate mudancas em `.rules.ts` em pull requests como sensíveis do ponto de vista de seguranca. + +### O que as regras podem acessar + +As regras recebem um objeto `RuleContext` com operacoes de arquivo isoladas. Todos os metodos do `RuleContext` (`readFile`, `readJSON`, `grep`, `grepFiles`, `glob`) sao restritos ao diretorio raiz do projeto -- travessia de caminho via `../`, caminhos absolutos e links simbolicos sao bloqueados e lancam um erro. + +Porem, como as regras sao modulos TypeScript padrao, elas tambem podem usar qualquer API do Bun/Node.js diretamente: + +| Capacidade | Via RuleContext | Via chamadas diretas de API | +| ---------------------------- | --------------------- | --------------------------- | +| Ler arquivos no projeto | Sim (isolado) | Sim (sem restricao) | +| Ler arquivos fora do projeto | Bloqueado | Sim (`Bun.file()`, `fs`) | +| Requisicoes de rede | Nenhuma API fornecida | Sim (`fetch()`) | +| Executar subprocessos | Nenhuma API fornecida | Sim (`Bun.spawn()`) | +| Variaveis de ambiente | Nenhuma API fornecida | Sim (`process.env`) | + +O sandbox do `RuleContext` previne travessia de caminho acidental em regras bem-intencionadas. Ele nao protege contra codigo deliberadamente malicioso -- uma regra que importa `fs` diretamente pode contornar o sandbox. + +### O que as regras nao podem fazer + +- **Escrever arquivos** -- a API do `RuleContext` e somente leitura. Regras reportam violacoes mas nao podem modificar o codebase. +- **Escapar do timeout de 30 segundos** -- cada regra e encerrada apos 30 segundos de tempo de execucao. +- **Afetar outras regras** -- regras de ADRs diferentes rodam em paralelo mas nao compartilham estado mutavel atraves da API de contexto. + +## Boas praticas para CI/CD + +Rodar `archgate check` em CI e seguro quando voce controla o conteudo do repositorio. Cuidado extra e necessario para pull requests de contribuidores externos. + +### Branches confiáveis + +Para pushes em `main` ou outras branches protegidas, `archgate check` executa codigo que ja foi revisado e mesclado. Isso e seguro: + +```yaml +on: + push: + branches: [main] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: archgate/check-action@v1 +``` + +### Pull requests de forks + +Quando um pull request vem de um fork, os arquivos `.rules.ts` no PR podem conter codigo arbitrario. Esse e o mesmo risco de rodar qualquer script de CI nao confiavel. + +**Opcao 1: Exigir aprovacao antes de rodar.** Use regras de protecao de ambiente do GitHub ou `pull_request_target` com aprovacao manual para condicionar o CI a revisao: + +```yaml +on: + pull_request_target: + +jobs: + check: + runs-on: ubuntu-latest + environment: pr-check # Requer aprovacao manual + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + - uses: archgate/check-action@v1 +``` + +**Opcao 2: Rodar verificacoes apenas em arquivos confiaveis.** Use um workflow separado que faz checkout dos arquivos `.rules.ts` da branch base e os executa contra os arquivos-fonte do PR. Isso garante que apenas regras revisadas sejam executadas. + +**Opcao 3: Pular verificacoes em PRs de forks.** Se suas regras sao principalmente para governanca interna, pule verificacoes automatizadas em PRs de forks e rode-as manualmente apos revisao: + +```yaml +on: + pull_request: + +jobs: + check: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: archgate/check-action@v1 +``` + +### Runners com privilegio minimo + +Rode `archgate check` em runners com permissoes minimas. O job precisa apenas de acesso de leitura ao repositorio -- nao sao necessarios secrets, chaves de deploy ou permissoes de escrita: + +```yaml +jobs: + check: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: archgate/check-action@v1 +``` + +## Desenvolvimento local + +### Revisando regras em novos repositorios + +Ao clonar ou fazer fork de um repositorio que usa Archgate, revise os arquivos `.archgate/adrs/*.rules.ts` antes de rodar `archgate check` pela primeira vez. Procure por: + +- Imports de `fs`, `child_process`, `net` ou outros modulos do sistema +- Chamadas a `fetch()`, `Bun.spawn()` ou `Bun.file()` fora da API do `RuleContext` +- Codigo de nivel superior que executa no import (antes da funcao `check` ser chamada) + +Regras bem-comportadas usam apenas os metodos do `RuleContext` (`ctx.readFile`, `ctx.grep`, `ctx.glob`, etc.) e `ctx.report` para saida. + +### Credenciais + +O comando `archgate login` armazena um token de autenticacao em `~/.archgate/credentials` com permissoes de arquivo somente para o proprietario (`0600` em Unix). Este token e usado para instalacao de plugins e nunca e enviado a terceiros alem do servico de plugins do Archgate. + +- Nao commite `~/.archgate/credentials` no controle de versao. +- Nao exponha o token em logs de CI. Comandos de instalacao de plugins passam credenciais via URLs autenticadas para o git, que podem aparecer em listagens de processos. Evite rodar `archgate plugin install` com logging verbose em ambientes de CI compartilhados. +- Para revogar acesso, rode `archgate logout` ou delete `~/.archgate/credentials`. + +### Integridade da atualizacao + +Quando voce roda `archgate upgrade`, o CLI baixa o binario de release do GitHub Releases e verifica o checksum SHA256 antes da extracao. Se o checksum nao corresponder, a atualizacao e abortada. Isso protege contra downloads adulterados por interceptacao de rede ou mirrors comprometidos. + +## Reportando vulnerabilidades + +Se voce descobrir um problema de seguranca no Archgate, por favor reporte de forma responsavel abrindo uma [issue no GitHub](https://github.com/archgate/cli/issues) ou entrando em contato com os mantenedores diretamente. Nao inclua codigo de exploit em issues publicas. diff --git a/src/commands/adr/list.ts b/src/commands/adr/list.ts index e6018086..5bcc96b4 100644 --- a/src/commands/adr/list.ts +++ b/src/commands/adr/list.ts @@ -1,4 +1,5 @@ import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; import { styleText } from "node:util"; import type { Command } from "@commander-js/extra-typings"; @@ -13,7 +14,7 @@ async function loadAdrs(adrsDir: string): Promise { const results = await Promise.all( files.map(async (file) => { try { - const content = await Bun.file(`${adrsDir}/${file}`).text(); + const content = await Bun.file(join(adrsDir, file)).text(); return parseAdr(content, file); } catch { return null; diff --git a/src/engine/runner.ts b/src/engine/runner.ts index 9b44fdb9..f02ae91c 100644 --- a/src/engine/runner.ts +++ b/src/engine/runner.ts @@ -1,4 +1,5 @@ -import { join, relative, isAbsolute } from "node:path"; +import { lstatSync } from "node:fs"; +import { relative, resolve, isAbsolute } from "node:path"; import type { GrepMatch, @@ -8,6 +9,50 @@ import type { } from "../formats/rules"; import { logDebug } from "../helpers/log"; import { resolveScopedFiles, getStagedFiles } from "./git-files"; + +/** + * Resolve a user-supplied path and ensure it stays within projectRoot. + * Throws if the resolved path escapes the project boundary or is a symlink. + */ +function safePath(projectRoot: string, userPath: string): string { + const root = resolve(projectRoot); + const absPath = isAbsolute(userPath) + ? resolve(userPath) + : resolve(root, userPath); + // On Windows, paths on different drives produce a full absolute relative() + // result rather than a ".." prefix — use startsWith on the normalized paths. + if ( + !absPath.startsWith(root + "/") && + !absPath.startsWith(root + "\\") && + absPath !== root + ) { + throw new Error(`Path "${userPath}" escapes project root — access denied`); + } + // Reject symlinks to prevent following links to files outside the project + try { + if (lstatSync(absPath).isSymbolicLink()) { + throw new Error(`Path "${userPath}" is a symbolic link — access denied`); + } + } catch (err) { + // Re-throw our own errors; ignore ENOENT (file may not exist yet for glob results) + if (err instanceof Error && err.message.includes("access denied")) { + throw err; + } + } + return absPath; +} + +/** + * Validate that a glob pattern cannot escape projectRoot via `..` segments. + */ +function safeGlob(pattern: string): void { + if (pattern.includes("..")) { + throw new Error(`Glob pattern "${pattern}" contains ".." — access denied`); + } + if (isAbsolute(pattern)) { + throw new Error(`Glob pattern "${pattern}" is absolute — access denied`); + } +} import type { LoadedAdr } from "./loader"; const RULE_TIMEOUT_MS = 30_000; @@ -56,6 +101,7 @@ function createRuleContext( report, async glob(pattern: string): Promise { + safeGlob(pattern); const g = new Bun.Glob(pattern); const results: string[] = []; for await (const file of g.scan({ cwd: projectRoot, dot: false })) { @@ -65,7 +111,7 @@ function createRuleContext( }, async grep(file: string, pattern: RegExp): Promise { - const absPath = isAbsolute(file) ? file : join(projectRoot, file); + const absPath = safePath(projectRoot, file); const content = await Bun.file(absPath).text(); const lines = content.split("\n"); const matches: GrepMatch[] = []; @@ -86,12 +132,13 @@ function createRuleContext( }, async grepFiles(pattern: RegExp, fileGlob: string): Promise { + safeGlob(fileGlob); const g = new Bun.Glob(fileGlob); const allMatches: GrepMatch[] = []; for await (const file of g.scan({ cwd: projectRoot, dot: false })) { const normalized = file.replaceAll("\\", "/"); - const absPath = join(projectRoot, file); + const absPath = safePath(projectRoot, file); try { const content = await Bun.file(absPath).text(); const lines = content.split("\n"); @@ -116,12 +163,12 @@ function createRuleContext( }, readFile(path: string): Promise { - const absPath = isAbsolute(path) ? path : join(projectRoot, path); + const absPath = safePath(projectRoot, path); return Bun.file(absPath).text(); }, readJSON(path: string): Promise { - const absPath = isAbsolute(path) ? path : join(projectRoot, path); + const absPath = safePath(projectRoot, path); return Bun.file(absPath).json(); }, }; diff --git a/src/helpers/auth.ts b/src/helpers/auth.ts index bda75eee..a1ed845c 100644 --- a/src/helpers/auth.ts +++ b/src/helpers/auth.ts @@ -5,7 +5,7 @@ * plus local storage of the archgate plugin token in ~/.archgate/credentials. */ -import { unlinkSync } from "node:fs"; +import { chmodSync, unlinkSync } from "node:fs"; import { logDebug } from "./log"; import { internalPath, createPathIfNotExists } from "./paths"; @@ -199,6 +199,7 @@ export async function claimArchgateToken(githubToken: string): Promise { }, body: JSON.stringify({ github_token: githubToken }), signal: AbortSignal.timeout(15_000), + redirect: "error", }); if (!response.ok) { @@ -240,11 +241,14 @@ export async function saveCredentials( credentials: StoredCredentials ): Promise { createPathIfNotExists(internalPath()); - await Bun.write( - credentialsPath(), - JSON.stringify(credentials, null, 2) + "\n" - ); - logDebug("Credentials saved to", credentialsPath()); + const filePath = credentialsPath(); + await Bun.write(filePath, JSON.stringify(credentials, null, 2) + "\n"); + try { + chmodSync(filePath, 0o600); + } catch { + // chmod may fail on Windows — NTFS uses ACLs instead + } + logDebug("Credentials saved to", filePath); } /** diff --git a/src/helpers/binary-upgrade.ts b/src/helpers/binary-upgrade.ts index 1f26b13d..111a8f2a 100644 --- a/src/helpers/binary-upgrade.ts +++ b/src/helpers/binary-upgrade.ts @@ -1,7 +1,9 @@ +import { createHash } from "node:crypto"; import { chmodSync, mkdtempSync, renameSync, unlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { logDebug } from "./log"; import { isWindows } from "./platform"; // --------------------------------------------------------------------------- @@ -89,7 +91,9 @@ export async function downloadReleaseBinary( tag: string, artifact: ArtifactInfo ): Promise { - const archiveUrl = `https://github.com/${GITHUB_REPO}/releases/download/${tag}/${artifact.name}${artifact.ext}`; + const baseUrl = `https://github.com/${GITHUB_REPO}/releases/download/${tag}`; + const archiveUrl = `${baseUrl}/${artifact.name}${artifact.ext}`; + const checksumUrl = `${baseUrl}/${artifact.name}${artifact.ext}.sha256`; const response = await fetch(archiveUrl, { headers: { "User-Agent": "archgate-cli" }, @@ -101,12 +105,61 @@ export async function downloadReleaseBinary( } const buffer = await response.arrayBuffer(); + + // Verify SHA256 checksum when available (releases after this change) + try { + const checksumResponse = await fetch(checksumUrl, { + headers: { "User-Agent": "archgate-cli" }, + signal: AbortSignal.timeout(15000), + }); + if (checksumResponse.ok) { + const checksumText = await checksumResponse.text(); + const expectedHash = checksumText.trim().split(/\s+/)[0].toLowerCase(); + const actualHash = createHash("sha256") + .update(new Uint8Array(buffer)) + .digest("hex"); + if (actualHash !== expectedHash) { + throw new Error( + `Checksum mismatch for ${artifact.name}${artifact.ext}: expected ${expectedHash}, got ${actualHash}` + ); + } + logDebug("Checksum verified:", actualHash); + } else { + logDebug("No checksum file available — skipping verification"); + } + } catch (err) { + if (err instanceof Error && err.message.startsWith("Checksum mismatch")) { + throw err; + } + logDebug("Checksum verification skipped:", err); + } const tmpDir = mkdtempSync(join(tmpdir(), "archgate-upgrade-")); const archivePath = join(tmpDir, `archgate${artifact.ext}`); await Bun.write(archivePath, buffer); if (artifact.ext === ".tar.gz") { + // Validate archive entries before extraction to prevent path traversal + const listProc = Bun.spawn(["tar", "-tzf", archivePath], { + stdout: "pipe", + stderr: "pipe", + }); + const listing = await new Response(listProc.stdout).text(); + await listProc.exited; + + for (const entry of listing.split("\n").filter(Boolean)) { + const normalized = entry.replaceAll("\\", "/").trim(); + if ( + normalized.startsWith("/") || + normalized.includes("../") || + normalized === ".." + ) { + throw new Error( + `Unsafe path in release archive: "${entry}" — aborting extraction` + ); + } + } + const proc = Bun.spawn(["tar", "-xzf", archivePath, "-C", tmpDir], { stdout: "pipe", stderr: "pipe", diff --git a/src/helpers/claude-settings.ts b/src/helpers/claude-settings.ts index 54db9191..5d8a7bc5 100644 --- a/src/helpers/claude-settings.ts +++ b/src/helpers/claude-settings.ts @@ -80,7 +80,11 @@ export async function configureClaudeSettings( // Read existing settings or start with empty object let existing: ClaudeSettings = {}; if (existsSync(settingsPath)) { - existing = (await Bun.file(settingsPath).json()) as ClaudeSettings; + try { + existing = (await Bun.file(settingsPath).json()) as ClaudeSettings; + } catch { + // Corrupted settings file — start fresh + } } const merged = mergeClaudeSettings(existing, ARCHGATE_CLAUDE_SETTINGS); diff --git a/src/helpers/login-flow.ts b/src/helpers/login-flow.ts index 713d300f..0d9e2b0d 100644 --- a/src/helpers/login-flow.ts +++ b/src/helpers/login-flow.ts @@ -140,6 +140,19 @@ async function runSignupPrompt( v.trim().length > 0 || "Please describe your use case", }); + const { confirmed } = await inquirer.prompt({ + type: "confirm", + name: "confirmed", + message: + "I agree to be contacted by the Archgate team to provide feedback during the beta period.", + default: true, + }); + + if (!confirmed) { + logInfo("Signup cancelled."); + return null; + } + logInfo("\nSubmitting signup request..."); const result = await requestSignup(githubUser, email, useCase, editor!); diff --git a/src/helpers/plugin-install.ts b/src/helpers/plugin-install.ts index 31e98189..c99ee7ad 100644 --- a/src/helpers/plugin-install.ts +++ b/src/helpers/plugin-install.ts @@ -43,7 +43,9 @@ async function run( * Claude Code and Copilot CLI both use the .claude-plugin/ manifest format. */ export function buildMarketplaceUrl(credentials: StoredCredentials): string { - return `https://${credentials.github_user}:${credentials.token}@plugins.archgate.dev/archgate.git`; + const user = encodeURIComponent(credentials.github_user); + const token = encodeURIComponent(credentials.token); + return `https://${user}:${token}@plugins.archgate.dev/archgate.git`; } /** @@ -53,7 +55,9 @@ export function buildMarketplaceUrl(credentials: StoredCredentials): string { export function buildVscodeMarketplaceUrl( credentials: StoredCredentials ): string { - return `https://${credentials.github_user}:${credentials.token}@plugins.archgate.dev/archgate-vscode.git`; + const user = encodeURIComponent(credentials.github_user); + const token = encodeURIComponent(credentials.token); + return `https://${user}:${token}@plugins.archgate.dev/archgate-vscode.git`; } /** @@ -117,6 +121,7 @@ export async function installCursorPlugin( const response = await fetch(`${PLUGINS_API}/api/cursor`, { headers: { Authorization: `Bearer ${token}`, "User-Agent": "archgate-cli" }, signal: AbortSignal.timeout(30_000), + redirect: "error", }); if (response.status === 401) { @@ -185,9 +190,29 @@ export async function installCopilotPlugin( // Shared — tar extraction helper // --------------------------------------------------------------------------- +/** + * Validate that tar archive entries do not escape the destination directory + * via path traversal ("..") or absolute paths. + */ +function validateTarEntries(entries: string[]): void { + for (const entry of entries) { + const normalized = entry.replaceAll("\\", "/"); + if ( + normalized.startsWith("/") || + normalized.includes("../") || + normalized === ".." + ) { + throw new Error( + `Unsafe path in plugin archive: "${entry}" — aborting extraction` + ); + } + } +} + /** * Extract a .tar.gz buffer to a destination directory. * Uses system tar (available on macOS, Linux, and Windows 10+). + * Validates archive entries before extraction to prevent path traversal. */ async function extractTarGz( data: Uint8Array, @@ -200,7 +225,16 @@ async function extractTarGz( try { mkdirSync(destDir, { recursive: true }); - // Extract using tar (available on macOS, Linux, and Windows 10+) + // List entries first and validate before extracting + const listResult = await run(["tar", "-tzf", tmpArchive]); + const files = listResult.stdout + .split("\n") + .map((f) => f.trim()) + .filter(Boolean); + + validateTarEntries(files); + + // Extract only after validation passes const result = await run(["tar", "-xzf", tmpArchive, "-C", destDir]); if (result.exitCode !== 0) { @@ -209,13 +243,6 @@ async function extractTarGz( ); } - // List extracted files for reporting - const listResult = await run(["tar", "-tzf", tmpArchive]); - const files = listResult.stdout - .split("\n") - .map((f) => f.trim()) - .filter(Boolean); - return files; } finally { try { diff --git a/src/helpers/signup.ts b/src/helpers/signup.ts index 3c123b87..d909eb07 100644 --- a/src/helpers/signup.ts +++ b/src/helpers/signup.ts @@ -52,6 +52,7 @@ export async function requestSignup( }, body: JSON.stringify({ github, email, useCase, editor }), signal: AbortSignal.timeout(15_000), + redirect: "error", }); if (response.status !== 201) { diff --git a/tests/commands/check-security.test.ts b/tests/commands/check-security.test.ts new file mode 100644 index 00000000..20f96aea --- /dev/null +++ b/tests/commands/check-security.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { + mkdtempSync, + rmSync, + mkdirSync, + writeFileSync, + symlinkSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { loadRuleAdrs } from "../../src/engine/loader"; +import { runChecks } from "../../src/engine/runner"; + +describe("check command security", () => { + let tempDir: string; + let adrsDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-check-sec-")); + adrsDir = join(tempDir, ".archgate", "adrs"); + mkdirSync(adrsDir, { recursive: true }); + mkdirSync(join(tempDir, "src"), { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + const adrTemplate = (id: string) => + `---\nid: ${id}\ntitle: Security Test\ndomain: general\nrules: true\n---\n`; + + function writeAdrAndRule(id: string, ruleCode: string): void { + writeFileSync(join(adrsDir, `${id}-sec.md`), adrTemplate(id)); + writeFileSync(join(adrsDir, `${id}-sec.rules.ts`), ruleCode); + } + + test("blocks readFile path traversal via on-disk rule", async () => { + writeAdrAndRule( + "SEC-001", + `export default { + rules: { + "steal-file": { + description: "Attempt to read file outside project", + async check(ctx) { + await ctx.readFile("../../etc/passwd"); + }, + }, + }, +}; +` + ); + + const loaded = await loadRuleAdrs(tempDir); + const result = await runChecks(tempDir, loaded); + expect(result.results[0].error).toContain("access denied"); + }); + + test("blocks readJSON path traversal via on-disk rule", async () => { + writeAdrAndRule( + "SEC-002", + `export default { + rules: { + "steal-json": { + description: "Attempt to read JSON outside project", + async check(ctx) { + await ctx.readJSON("../../../package.json"); + }, + }, + }, +}; +` + ); + + const loaded = await loadRuleAdrs(tempDir); + const result = await runChecks(tempDir, loaded); + expect(result.results[0].error).toContain("access denied"); + }); + + test("blocks grep on file outside project", async () => { + writeAdrAndRule( + "SEC-003", + `export default { + rules: { + "grep-outside": { + description: "Attempt to grep file outside project", + async check(ctx) { + await ctx.grep("../../../etc/hosts", /localhost/); + }, + }, + }, +}; +` + ); + + const loaded = await loadRuleAdrs(tempDir); + const result = await runChecks(tempDir, loaded); + expect(result.results[0].error).toContain("access denied"); + }); + + test("blocks glob with traversal pattern", async () => { + writeAdrAndRule( + "SEC-004", + `export default { + rules: { + "glob-escape": { + description: "Attempt to glob outside project", + async check(ctx) { + await ctx.glob("../../**/*.env"); + }, + }, + }, +}; +` + ); + + const loaded = await loadRuleAdrs(tempDir); + const result = await runChecks(tempDir, loaded); + expect(result.results[0].error).toContain("access denied"); + }); + + test("blocks grepFiles with traversal pattern", async () => { + writeAdrAndRule( + "SEC-005", + `export default { + rules: { + "grepfiles-escape": { + description: "Attempt to grepFiles outside project", + async check(ctx) { + await ctx.grepFiles(/SECRET/, "../**/*.env"); + }, + }, + }, +}; +` + ); + + const loaded = await loadRuleAdrs(tempDir); + const result = await runChecks(tempDir, loaded); + expect(result.results[0].error).toContain("access denied"); + }); + + test("blocks symlink to file outside project", async () => { + // Create a real file outside the project + const outsideDir = mkdtempSync(join(tmpdir(), "archgate-outside-")); + writeFileSync(join(outsideDir, "secret.txt"), "sensitive data"); + + // Create a symlink inside the project pointing outside + try { + symlinkSync( + join(outsideDir, "secret.txt"), + join(tempDir, "src", "linked.txt") + ); + } catch { + // Symlink creation may fail on Windows without admin privileges — skip + rmSync(outsideDir, { recursive: true, force: true }); + return; + } + + writeAdrAndRule( + "SEC-006", + `export default { + rules: { + "read-symlink": { + description: "Attempt to read symlinked file", + async check(ctx) { + await ctx.readFile("src/linked.txt"); + }, + }, + }, +}; +` + ); + + const loaded = await loadRuleAdrs(tempDir); + const result = await runChecks(tempDir, loaded); + expect(result.results[0].error).toContain("symbolic link"); + + rmSync(outsideDir, { recursive: true, force: true }); + }); + + test("allows legitimate file reads within project", async () => { + writeFileSync(join(tempDir, "src", "app.ts"), "export const x = 1;\n"); + + writeAdrAndRule( + "SEC-007", + `export default { + rules: { + "legit-read": { + description: "Legitimate file read within project", + async check(ctx) { + const content = await ctx.readFile("src/app.ts"); + if (!content.includes("export")) { + ctx.report.violation({ message: "Missing export" }); + } + }, + }, + }, +}; +` + ); + + const loaded = await loadRuleAdrs(tempDir); + const result = await runChecks(tempDir, loaded); + expect(result.results[0].error).toBeUndefined(); + expect(result.results[0].violations).toHaveLength(0); + }); + + test("allows legitimate glob within project", async () => { + writeFileSync(join(tempDir, "src", "a.ts"), ""); + writeFileSync(join(tempDir, "src", "b.ts"), ""); + + writeAdrAndRule( + "SEC-008", + `export default { + rules: { + "legit-glob": { + description: "Legitimate glob within project", + async check(ctx) { + const files = await ctx.glob("src/**/*.ts"); + if (files.length === 0) { + ctx.report.violation({ message: "No files found" }); + } + }, + }, + }, +}; +` + ); + + const loaded = await loadRuleAdrs(tempDir); + const result = await runChecks(tempDir, loaded); + expect(result.results[0].error).toBeUndefined(); + expect(result.results[0].violations).toHaveLength(0); + }); + + test("blocks absolute path in readFile", async () => { + writeAdrAndRule( + "SEC-009", + `export default { + rules: { + "abs-read": { + description: "Attempt absolute path read", + async check(ctx) { + await ctx.readFile("/etc/passwd"); + }, + }, + }, +}; +` + ); + + const loaded = await loadRuleAdrs(tempDir); + const result = await runChecks(tempDir, loaded); + expect(result.results[0].error).toContain("access denied"); + }); + + test("blocks absolute glob pattern", async () => { + writeAdrAndRule( + "SEC-010", + `export default { + rules: { + "abs-glob": { + description: "Attempt absolute glob", + async check(ctx) { + await ctx.glob("/tmp/**/*"); + }, + }, + }, +}; +` + ); + + const loaded = await loadRuleAdrs(tempDir); + const result = await runChecks(tempDir, loaded); + expect(result.results[0].error).toContain("access denied"); + }); +}); diff --git a/tests/engine/runner-security.test.ts b/tests/engine/runner-security.test.ts new file mode 100644 index 00000000..4c125a90 --- /dev/null +++ b/tests/engine/runner-security.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { LoadedAdr } from "../../src/engine/loader"; +import { runChecks } from "../../src/engine/runner"; +import type { AdrDocument } from "../../src/formats/adr"; +import type { RuleSet } from "../../src/formats/rules"; + +describe("runChecks path sandboxing", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-runner-sec-")); + mkdirSync(join(tempDir, "src"), { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + const EMPTY_RULE_SET: RuleSet = { rules: {} }; + + function makeLoadedAdr( + overrides: Partial = {}, + ruleSet: RuleSet = EMPTY_RULE_SET + ): LoadedAdr { + return { + adr: { + frontmatter: { + id: "SEC-001", + title: "Security Test", + domain: "general", + rules: true, + ...overrides, + }, + body: "", + filePath: "/test.md", + }, + ruleSet, + }; + } + + test("blocks path traversal via readFile", async () => { + const loaded = makeLoadedAdr( + {}, + { + rules: { + "traversal-test": { + description: "Attempt path traversal", + async check(ctx) { + await ctx.readFile("../../etc/passwd"); + }, + }, + }, + } + ); + + const result = await runChecks(tempDir, [loaded]); + expect(result.results[0].error).toContain("escapes project root"); + }); + + test("blocks path traversal via grep", async () => { + const loaded = makeLoadedAdr( + {}, + { + rules: { + "traversal-grep": { + description: "Attempt path traversal via grep", + async check(ctx) { + await ctx.grep("../../../etc/hosts", /localhost/); + }, + }, + }, + } + ); + + const result = await runChecks(tempDir, [loaded]); + expect(result.results[0].error).toContain("escapes project root"); + }); + + test("blocks absolute path via readFile", async () => { + const loaded = makeLoadedAdr( + {}, + { + rules: { + "abs-path-test": { + description: "Attempt absolute path access", + async check(ctx) { + await ctx.readFile("/etc/passwd"); + }, + }, + }, + } + ); + + const result = await runChecks(tempDir, [loaded]); + expect(result.results[0].error).toContain("escapes project root"); + }); + + test("blocks glob patterns with ..", async () => { + const loaded = makeLoadedAdr( + {}, + { + rules: { + "glob-traversal": { + description: "Attempt glob traversal", + async check(ctx) { + await ctx.glob("../../**/*.ts"); + }, + }, + }, + } + ); + + const result = await runChecks(tempDir, [loaded]); + expect(result.results[0].error).toContain("access denied"); + }); + + test("blocks absolute glob patterns", async () => { + const loaded = makeLoadedAdr( + {}, + { + rules: { + "abs-glob": { + description: "Attempt absolute glob", + async check(ctx) { + await ctx.glob("/etc/**/*"); + }, + }, + }, + } + ); + + const result = await runChecks(tempDir, [loaded]); + expect(result.results[0].error).toContain("access denied"); + }); + + test("blocks grepFiles with traversal pattern", async () => { + const loaded = makeLoadedAdr( + {}, + { + rules: { + "grepfiles-traversal": { + description: "Attempt grepFiles traversal", + async check(ctx) { + await ctx.grepFiles(/secret/, "../**/*.env"); + }, + }, + }, + } + ); + + const result = await runChecks(tempDir, [loaded]); + expect(result.results[0].error).toContain("access denied"); + }); + + test("blocks readJSON with path traversal", async () => { + const loaded = makeLoadedAdr( + {}, + { + rules: { + "json-traversal": { + description: "Attempt readJSON traversal", + async check(ctx) { + await ctx.readJSON("../../../package.json"); + }, + }, + }, + } + ); + + const result = await runChecks(tempDir, [loaded]); + expect(result.results[0].error).toContain("escapes project root"); + }); +});