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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,31 @@ jobs:
}
Write-Host "installer parses, web requests basic-parsed, no unguarded exit, arch survives without .NET, no preference leaks"

# Four vulnerabilities in the standard library this repo compiled against
# went unnoticed until somebody ran this by hand. Nothing here was looking,
# so nothing was going to say so - not on the next release either.
#
# govulncheck reports only what the code actually reaches, which is why it
# can be a failing check rather than a list to triage: a finding here is a
# path from this CLI to the bug, and the fix is usually a toolchain or a
# dependency bump.
vulnerabilities:
name: Known vulnerabilities
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

# A fixed version rather than @latest: a check that can fail the build
# should not change under it on the morning somebody tags that module.
- name: govulncheck
run: go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./...

# The other half of the same gap: scripts/install.sh is the way in for every
# member on macOS and Linux, and nothing had ever run it end to end. It was
# syntax-checked and its failure path exercised by hand; the path that
Expand Down
60 changes: 54 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ on:

permissions:
contents: write
# For the attestation below. The runner asks GitHub's OIDC provider for a
# token that says which workflow, in which repository, at which commit is
# running, and that token is what signs the archives - there is no key here
# to keep, rotate or lose.
id-token: write
attestations: write

jobs:
release:
Expand All @@ -44,18 +50,36 @@ jobs:
go-version-file: go.mod
cache: true

# Through the environment, and checked before it goes anywhere near a
# shell. A workflow_dispatch input pasted straight into a `run:` block is
# substituted before bash ever sees it, so whatever was typed into the
# box becomes part of the script - and every step below puts this value
# on a command line. Only something shaped like one of our tags gets
# past here.
- name: Resolve the tag
id: tag
run: echo "value=${{ github.event.inputs.tag || github.ref_name }}" >> "$GITHUB_OUTPUT"
env:
INPUT_TAG: ${{ github.event.inputs.tag }}
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
tag="${INPUT_TAG:-$REF_NAME}"
if ! printf '%s' "$tag" | grep -Eq '^v[0-9]+[.][0-9]+[.][0-9]+([-][A-Za-z0-9.]+)?$'; then
echo "not a version tag: $tag" >&2
exit 1
fi
echo "value=$tag" >> "$GITHUB_OUTPUT"

# The version shown by `nan --version` is a constant in the source
# (internal/tui.Version). A tag that disagrees with it ships a binary
# that lies about which build it is, and the first person to report a
# bug reports the wrong version.
- name: Check the tag matches internal/tui.Version
env:
TAG: ${{ steps.tag.outputs.value }}
run: |
declared="$(grep -oP 'const Version = "\K[^"]+' internal/tui/tui.go)"
tag="${{ steps.tag.outputs.value }}"
tag="$TAG"
if [ "v$declared" != "$tag" ]; then
echo "tag $tag does not match internal/tui.Version ($declared)" >&2
echo "bump the constant or retag" >&2
Expand Down Expand Up @@ -95,30 +119,54 @@ jobs:
# Installing what we are about to publish, the same way a member would,
# so a broken artifact fails here instead of on their machine.
- name: Smoke test the linux/amd64 archive
env:
TAG: ${{ steps.tag.outputs.value }}
run: |
set -euo pipefail
tar -xzf "dist/nan-cli_${{ steps.tag.outputs.value }}_linux_amd64.tar.gz" -C /tmp
tar -xzf "dist/nan-cli_${TAG}_linux_amd64.tar.gz" -C /tmp
/tmp/nan --version
/tmp/nan --help > /dev/null

# The Windows binaries cannot be run here, but an archive that unpacks
# to nothing, or to a name Windows will not execute, can still be caught.
- name: Check the Windows archives carry an .exe
env:
TAG: ${{ steps.tag.outputs.value }}
run: |
set -euo pipefail
for arch in amd64 arm64; do
zip="dist/nan-cli_${{ steps.tag.outputs.value }}_windows_${arch}.zip"
zip="dist/nan-cli_${TAG}_windows_${arch}.zip"
unzip -l "$zip" | grep -q "nan.exe" || {
echo "$zip does not contain nan.exe" >&2
exit 1
}
done

# checksums.txt proves the bytes arrived whole. It proves nothing about
# where they came from: whoever can replace an archive on a release can
# replace the checksum sitting next to it, and the installer would verify
# the download against the attacker's own number and call it good.
#
# This signs the archives themselves. GitHub keeps the attestation, so
# anyone can ask what built a binary they already have:
#
# gh attestation verify nan-cli_v1.2.3_linux_amd64.tar.gz --repo helmcode/nan-cli
#
# and get back the workflow, the repository and the commit - none of
# which a release someone else published can produce.
- name: Attest what was built
uses: actions/attest-build-provenance@v4
with:
subject-path: |
dist/*.tar.gz
dist/*.zip

- name: Publish the release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.tag.outputs.value }}
run: |
gh release create "${{ steps.tag.outputs.value }}" \
--title "${{ steps.tag.outputs.value }}" \
gh release create "$TAG" \
--title "$TAG" \
--generate-notes \
dist/*.tar.gz dist/*.zip dist/checksums.txt
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ Thanks for your interest in contributing. This document covers how to get the pr

## Prerequisites

- **Go 1.26+** — the project uses [mise](https://mise.jdx.dev/) to pin the version. Run `mise install` in the repo root and Go will be available automatically.
- **Go 1.26.8+** — the version in `go.mod`, which is where the standard
library carries the current security fixes. The project uses [mise](https://mise.jdx.dev/) to pin the version. Run `mise install` in the repo root and Go will be available automatically.
- A [nan.builders](https://nan.builders) account with an API key (needed to test the TUI at runtime).

## Getting started
Expand Down
28 changes: 24 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,32 @@ irm https://nan.builders/install.ps1 | iex
```

Same work: latest release, the `.zip` for your architecture, checksum verified,
`nan.exe` into `%LOCALAPPDATA%\Programs
an` and that directory added to your
`nan.exe` into `%LOCALAPPDATA%\Programs\nan` and that directory added to your
user `PATH`. Override with `-InstallDir`:

```powershell
& ([scriptblock]::Create((irm https://nan.builders/install.ps1))) -InstallDir "C: ools"
& ([scriptblock]::Create((irm https://nan.builders/install.ps1))) -InstallDir "C:\tools"
```

## Verifying a download

Both installers check the archive against `checksums.txt` from the same
release. That catches a download that arrived damaged; it cannot catch a
release that was published by somebody else, because whoever replaces an
archive can replace the checksum beside it.

Every release is signed with [build provenance](https://docs.github.com/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds),
which is not something a release you did not build can carry. If you have the
[GitHub CLI](https://cli.github.com), the installers check it for you, and you
can ask about any archive yourself:

```bash
gh attestation verify nan-cli_v0.1.19_linux_amd64.tar.gz --repo helmcode/nan-cli
```

It answers with the workflow, the repository and the commit the binary was
built from.

## Usage

Run `nan` to open the TUI dashboard:
Expand Down Expand Up @@ -70,7 +88,9 @@ inside each tool later.

## Build from source

Requires Go 1.26+ ([mise](https://mise.jdx.dev/) recommended):
Requires Go 1.26.8+ ([mise](https://mise.jdx.dev/) recommended) — the version
in `go.mod`, which is where the standard library carries the current security
fixes:

```bash
git clone https://github.com/helmcode/nan-cli
Expand Down
61 changes: 61 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Security policy

## Reporting a vulnerability

Use GitHub's **[Report a vulnerability](https://github.com/helmcode/nan-cli/security/advisories/new)**
button, under the Security tab. It opens a private thread with the maintainers
— nothing is public until there is a fix.

Please don't open a normal issue for a security problem. If the button is not
there yet, open an issue asking for a private channel and leave the details
out of it.

What helps, roughly in order:

- what an attacker gets, and what they need in the first place
- the steps to reproduce it, with the version (`nan --version`) and the OS
- whether it has already been exploited or disclosed anywhere

You'll get an acknowledgement as soon as somebody has read it, and an
assessment before anything ships. If you want to be credited in the advisory,
say so and say how.

## What this covers

This repository: the `nan` CLI, the two installers under `scripts/`, and the
release workflow that builds and signs what they download.

The nan.builders platform — the API the CLI talks to and the keys it issues —
is a separate system. A report about it is welcome through the same channel and
will be passed on.

## Versions

Fixes go into the next release. There are no backports: the installers fetch
the latest release, and `nan --version` against
[the releases page](https://github.com/helmcode/nan-cli/releases) is the whole
of the support matrix.

## Known, accepted, and not a finding

Two things about this CLI look like vulnerabilities and are deliberate, so
you're not wasting your time reporting them:

**Your API key is stored in plaintext.** It goes into `~/.config/nan/session.json`
and into each configured tool's own config file. Those tools read a literal key
out of their configs, so there is nowhere else to put it. Every file the CLI
writes it into is created `0600` and rewritten through a temp file so the mode
holds even where the file already existed, and `nan auth logout` takes the key
back out of every one of them. Hermes is the exception, because it has a
secrets file of its own: there the key goes into its `.env` and the config
carries a `${NAN_API_KEY}` reference, which is also why it never reaches a
command line.

**On Windows the mode bits do nothing.** The files inherit the ACL of the user
profile they sit in, which already excludes other non-administrator accounts,
and an administrator can take ownership regardless. If you keep your home
directory on a network share, this is worth knowing about.

What *is* worth reporting: the key reaching somewhere neither of those covers —
a log, an error message, a command line, a crash dump, a file with a wider
mode, or a tool's config after you have logged out.
67 changes: 60 additions & 7 deletions cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,21 @@ package cmd
import (
"bufio"
"fmt"
"io"
"os"
"strings"

"github.com/nxssie/nan-cli/internal/auth"
"github.com/nxssie/nan-cli/internal/session"
"github.com/nxssie/nan-cli/internal/tui"
"github.com/spf13/cobra"
)

var (
tokenFlag string
emailFlag string
linkFlag string
tokenFlag string
emailFlag string
linkFlag string
keepToolsFlag bool
)

var authCmd = &cobra.Command{
Expand All @@ -39,8 +42,9 @@ func init() {
authCmd.AddCommand(loginCmd)
authCmd.AddCommand(logoutCmd)
loginCmd.Flags().StringVar(&emailFlag, "email", "", "Email to send the sign-in link to")
loginCmd.Flags().StringVar(&linkFlag, "link", "", "Finish the login with the link from the email")
loginCmd.Flags().StringVar(&tokenFlag, "token", "", "Save a nan_session token directly, skipping the email")
loginCmd.Flags().StringVar(&linkFlag, "link", "", `Finish the login with the link from the email ("-" reads it from stdin, keeping the token out of your shell history)`)
loginCmd.Flags().StringVar(&tokenFlag, "token", "", `Save a nan_session token directly, skipping the email ("-" reads it from stdin, keeping it out of your shell history)`)
logoutCmd.Flags().BoolVar(&keepToolsFlag, "keep-tools", false, "Leave the API key in the tools this CLI configured")
}

// The platform signs in by emailed link. It used to be Discord OAuth, and this
Expand All @@ -49,14 +53,22 @@ func init() {
// and the command then asked for a cookie that no longer existed.
func runLogin(cmd *cobra.Command, args []string) error {
if tokenFlag != "" {
return saveToken(tokenFlag)
token, err := flagValue(tokenFlag)
if err != nil {
return err
}
return saveToken(token)
}

// `--link` picks the flow up at its second half, for a shell that cannot
// answer a prompt: a script, a CI step, or a terminal that runs one command
// at a time.
if linkFlag != "" {
token, err := auth.TokenFromLink(strings.TrimSpace(linkFlag))
pasted, err := flagValue(linkFlag)
if err != nil {
return err
}
token, err := auth.TokenFromLink(pasted)
if err != nil {
return err
}
Expand Down Expand Up @@ -131,11 +143,52 @@ func runLogin(cmd *cobra.Command, args []string) error {
return saveToken(sessionToken)
}

// A flag whose value is a credential, with "-" meaning stdin.
//
// Both of these take a secret, and a secret spelled out on a command line is
// not one for long: the shell writes it to ~/.bash_history or ~/.zsh_history,
// and while the command runs `ps` shows the whole line to anything running as
// the member - on Linux /proc/<pid>/cmdline to other accounts as well. A CI
// step or a script can pipe it in instead:
//
// echo "$NAN_LINK" | nan auth login --link -
func flagValue(value string) (string, error) {
if value != "-" {
return strings.TrimSpace(value), nil
}
read, err := io.ReadAll(os.Stdin)
if err != nil {
return "", fmt.Errorf("could not read it from stdin: %w", err)
}
value = strings.TrimSpace(string(read))
if value == "" {
return "", fmt.Errorf("nothing arrived on stdin")
}
return value, nil
}

func runLogout(cmd *cobra.Command, args []string) error {
if err := session.Delete(); err != nil {
return err
}
fmt.Println("Logged out.")

// Deleting session.json is only half of what logging out means here. The
// API key was copied into every tool `nan` configured, and it goes on
// working from those files: a member who logs out on a machine they are
// giving back would have been logged out of everything except the cluster
// their key bills.
if keepToolsFlag {
fmt.Println("Your API key is still in the tools — run it again without --keep-tools to take it out.")
return nil
}
removed, failed := tui.RemoveNanFromTools()
if len(removed) > 0 {
fmt.Println("Removed your API key from " + strings.Join(removed, ", ") + ".")
}
if len(failed) > 0 {
return fmt.Errorf("your API key is still in %s", strings.Join(failed, ", "))
}
return nil
}

Expand Down
Loading
Loading